File size: 5,294 Bytes
448d7a9
0e87c05
eaf2b94
0e87c05
 
 
 
 
 
cb01390
0e87c05
 
eaf2b94
0e87c05
448d7a9
0e87c05
eaf2b94
0e87c05
 
 
 
 
 
 
 
 
 
eaf2b94
0e87c05
448d7a9
0e87c05
 
 
 
 
 
 
 
 
 
 
 
448d7a9
0e87c05
 
 
 
eaf2b94
0e87c05
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eaf2b94
0e87c05
 
 
 
 
448d7a9
0e87c05
448d7a9
0e87c05
 
eaf2b94
0e87c05
448d7a9
 
 
0e87c05
 
 
 
 
cb01390
0e87c05
 
cb01390
0e87c05
 
448d7a9
0e87c05
 
 
 
 
 
448d7a9
0e87c05
 
 
 
 
 
cb01390
0e87c05
 
cb01390
0e87c05
 
 
 
 
 
 
 
 
 
448d7a9
0e87c05
 
cb01390
0e87c05
 
 
cb01390
0e87c05
 
cb01390
448d7a9
0e87c05
 
 
 
 
 
 
 
 
eaf2b94
0e87c05
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
"""
CryptoSentinel AI β€” High-performance FastAPI application.

Features:
- Fully asynchronous architecture using modern FastAPI lifespan and background tasks.
- Integrates a robust, async PriceFetcher with multi-API fallback.
- Provides real-time sentiment analysis via an efficient, non-polling SSE stream.
- Centralized state management for testability and clarity.
"""
import asyncio
import json
from contextlib import asynccontextmanager

import httpx
from fastapi import FastAPI, Request, BackgroundTasks
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, constr

from .price_fetcher import PriceFetcher
from .sentiment import SentimentAnalyzer

# --- Configuration & Models ---

class SentimentRequest(BaseModel):
    """Pydantic model for validating sentiment analysis requests."""
    text: constr(strip_whitespace=True, min_length=1)

# --- Application Lifespan Management ---

@asynccontextmanager
async def lifespan(app: FastAPI):
    """
    Manages application startup and shutdown events. This is the modern
    replacement for @app.on_event("startup") and "shutdown".
    """
    # -- Startup --
    # Create a single, shared httpx client for the application's lifespan.
    async with httpx.AsyncClient() as client:
        # Initialize our stateful services
        price_fetcher = PriceFetcher(client=client, coins=["bitcoin", "ethereum", "dogecoin"])
        sentiment_analyzer = SentimentAnalyzer()

        # Store service instances in the app's state for access in routes
        app.state.price_fetcher = price_fetcher
        app.state.sentiment_analyzer = sentiment_analyzer
        app.state.request_counter = 0

        # Create a cancellable background task for periodic price updates
        price_update_task = asyncio.create_task(
            run_periodic_updates(price_fetcher, interval_seconds=10)
        )
        
        print("πŸš€ CryptoSentinel AI started successfully.")
        yield  # The application is now running
        
        # -- Shutdown --
        print("⏳ Shutting down background tasks...")
        price_update_task.cancel()
        try:
            await price_update_task
        except asyncio.CancelledError:
            print("Price update task cancelled successfully.")
        print("βœ… Shutdown complete.")

async def run_periodic_updates(fetcher: PriceFetcher, interval_seconds: int):
    """A simple, robust asyncio background task runner."""
    while True:
        await fetcher.update_prices_async()
        await asyncio.sleep(interval_seconds)

# --- FastAPI App Initialization ---

templates = Jinja2Templates(directory="app/templates")
app = FastAPI(title="CryptoSentinel AI", lifespan=lifespan)

# --- Routes ---

@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
    """Renders the main single-page application view."""
    return templates.TemplateResponse("index.html", {"request": request})

@app.get("/api/prices", response_class=HTMLResponse)
async def get_prices_fragment(request: Request):
    """
    Returns an HTML fragment with the latest crypto prices.
    Designed to be called by HTMX.
    """
    price_fetcher: PriceFetcher = request.app.state.price_fetcher
    prices = price_fetcher.get_current_prices()

    html_fragment = ""
    for coin, price in prices.items():
        price_str = f"${price:,.2f}" if isinstance(price, (int, float)) else price
        html_fragment += f"<div><strong>{coin.capitalize()}:</strong> {price_str}</div>"
    
    return HTMLResponse(content=html_fragment)

@app.post("/api/sentiment")
async def analyze_sentiment(
    payload: SentimentRequest,
    request: Request,
    background_tasks: BackgroundTasks
):
    """
    Accepts text for sentiment analysis, validates it, and queues it
    for processing in the background.
    """
    analyzer: SentimentAnalyzer = request.app.state.sentiment_analyzer
    
    # Use a simple counter for unique event IDs
    request.app.state.request_counter += 1
    request_id = request.app.state.request_counter

    # Add the heavy computation to the background so the API returns instantly
    background_tasks.add_task(analyzer.compute_and_publish, payload.text, request_id)
    
    return {"status": "queued", "request_id": request_id}

@app.get("/api/sentiment/stream")
async def sentiment_stream(request: Request):
    """
    Server-Sent Events (SSE) endpoint.
    This long-lived connection efficiently waits for new sentiment results
    from the queue and pushes them to the client.
    """
    analyzer: SentimentAnalyzer = request.app.state.sentiment_analyzer

    async def event_generator():
        while True:
            try:
                # This is the key: efficiently wait for a result to be put in the queue
                result_payload = await analyzer.get_next_result()
                payload_str = json.dumps(result_payload)
                yield f"id:{result_payload['id']}\nevent: sentiment_update\ndata:{payload_str}\n\n"
            except asyncio.CancelledError:
                # Handle client disconnect
                print("Client disconnected from SSE stream.")
                break

    return StreamingResponse(event_generator(), media_type="text/event-stream")