"""FinSight v3 API. Run locally from finsight-v3/backend: uvicorn main:app --reload --port 8000 """ import json import logging import threading from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from agent import run_agent from config import get_settings from db import query from diff_engine import diff_filings, list_diffable, narration_prompt from market import get_market_overview, get_price_history, get_quote from results import build_results from screener import build_screener from tools import METRICS, query_facts logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") def _warm_caches() -> None: """Pre-build the dashboard's heavy caches (market overview, screener, results) in the background so the first page load after a restart is fast instead of paying the full yfinance + XBRL cost on the user's request.""" for label, fn in ( ("market", lambda: get_market_overview([r[0] for r in query("select ticker from companies")])), ("screener", build_screener), ("results", build_results), ): try: fn() except Exception: logging.exception("cache warm failed: %s", label) @asynccontextmanager async def lifespan(app: FastAPI): threading.Thread(target=_warm_caches, name="cache-warm", daemon=True).start() yield app = FastAPI(title="FinSight v3 API", version="3.0.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=get_settings().cors_origin_list, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) class ChatMessage(BaseModel): role: str = Field(pattern="^(user|assistant)$") content: str class ChatRequest(BaseModel): messages: list[ChatMessage] = Field(min_length=1) @app.post("/chat") def chat_stream(request: ChatRequest): """SSE stream: tool_call / tool_result events, then the verified answer.""" def event_stream(): try: for event in run_agent([m.model_dump() for m in request.messages]): yield f"event: {event.type}\ndata: {json.dumps(event.data)}\n\n" except Exception as exc: logging.exception("agent failed") yield f"event: error\ndata: {json.dumps({'message': str(exc)})}\n\n" yield "event: done\ndata: {}\n\n" return StreamingResponse(event_stream(), media_type="text/event-stream") @app.post("/chat/sync") def chat_sync(request: ChatRequest): """Non-streaming variant: returns the final answer payload.""" events = list(run_agent([m.model_dump() for m in request.messages])) answer = next((e for e in reversed(events) if e.type == "answer"), None) return { "tool_trace": [ {"type": e.type, **e.data} for e in events if e.type in ("tool_call", "tool_result") ], **(answer.data if answer else {"answer": None, "error": "no answer produced"}), } def companies(): rows = query("select ticker, name, cik, sector, country from companies order by ticker") return {"companies": [ {"ticker": t, "name": n, "cik": c, "sector": s, "country": country} for t, n, c, s, country in rows ]} @app.get("/companies/{ticker}/financials") def financials(ticker: str, annual: bool = True, years: int = 6): core = ["revenue", "gross_profit", "operating_income", "net_income", "eps_diluted", "operating_cash_flow", "buybacks"] return query_facts(ticker, core, annual=annual, years=years) @app.get("/companies/{ticker}/filings") def company_filings(ticker: str): rows = query( """ select f.form, f.filing_date::text, f.period_end::text, f.accession from filings f join companies c on c.cik = f.cik where c.ticker = %s order by f.filing_date desc """, (ticker.upper(),), ) return {"filings": [ {"form": f, "filing_date": d, "period_end": p, "accession": a} for f, d, p, a in rows ]} @app.get("/quote/{ticker}") def quote(ticker: str): return get_quote(ticker) @app.get("/companies/{ticker}/prices") def prices(ticker: str, period: str = "1y"): return get_price_history(ticker, period) @app.get("/screener") def screener(): return {"rows": build_screener()} @app.get("/results/latest") def latest_results(): """Results Center: latest reported quarter per Indian company with QoQ/YoY deltas, each row citing its NSE results filing.""" return {"rows": build_results()} @app.get("/compare") def compare(tickers: str, metrics: str = "revenue,net_income,eps_diluted", years: int = 4): ticker_list = [t.strip().upper() for t in tickers.split(",") if t.strip()][:6] metric_list = [m.strip() for m in metrics.split(",") if m.strip() in METRICS] return { "results": [query_facts(t, metric_list, annual=True, years=years) for t in ticker_list] } @app.get("/market/overview") def market_overview(): universe = [row[0] for row in query("select ticker from companies")] return get_market_overview(universe) @app.get("/diff/{ticker}/filings") def diffable_filings(ticker: str, form: str = "10-K"): return {"filings": list_diffable(ticker, form)} @app.get("/diff/{ticker}") def filing_diff(ticker: str, form: str = "10-K", new: str | None = None, old: str | None = None): return diff_filings(ticker, form, accession_new=new, accession_old=old) @app.post("/diff/{ticker}/narrate") def filing_diff_narrate(ticker: str, form: str = "10-K", new: str | None = None, old: str | None = None): from agent import get_client from google.genai import types as genai_types diff = diff_filings(ticker, form, accession_new=new, accession_old=old) if "error" in diff: return diff try: response = get_client().models.generate_content( model=get_settings().gemini_model, contents=narration_prompt(diff), config=genai_types.GenerateContentConfig(temperature=0.2), ) return {"narration": (response.text or "").strip(), **diff} except Exception as exc: return {"narration": None, "narration_error": str(exc), **diff} @app.get("/metrics") def metrics(): return {"metrics": sorted(METRICS)} @app.get("/health") def health(): counts = { table: query(f"select count(*) from {table}")[0][0] for table in ("companies", "filings", "xbrl_facts", "chunks") } return {"status": "healthy", "model": get_settings().gemini_model, "data": counts}