Spaces:
Running
Running
| """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) | |
| 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) | |
| 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") | |
| 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 | |
| ]} | |
| 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) | |
| 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 | |
| ]} | |
| def quote(ticker: str): | |
| return get_quote(ticker) | |
| def prices(ticker: str, period: str = "1y"): | |
| return get_price_history(ticker, period) | |
| def screener(): | |
| return {"rows": build_screener()} | |
| 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()} | |
| 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] | |
| } | |
| def market_overview(): | |
| universe = [row[0] for row in query("select ticker from companies")] | |
| return get_market_overview(universe) | |
| def diffable_filings(ticker: str, form: str = "10-K"): | |
| return {"filings": list_diffable(ticker, form)} | |
| 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) | |
| 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} | |
| def metrics(): | |
| return {"metrics": sorted(METRICS)} | |
| 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} | |