| |
| import sys |
| sys.path.insert(0, ".") |
|
|
| import os |
| import time |
| import shutil |
| from pathlib import Path |
|
|
| from fastapi import FastAPI, UploadFile, File, Form, HTTPException |
| from fastapi.middleware.cors import CORSMiddleware |
| from pydantic import BaseModel |
|
|
| from backend.parser import parse_document |
| from backend.graph import FinancialGraph |
| from backend.llm import ask, generate_report, compare_companies as llm_compare |
| from backend.red_flags import evaluate_red_flags, get_value |
| from backend.recommendations import evaluate_recommendation |
| from backend.entity_resolver import format_money |
| from backend.verifier import verify_answer |
|
|
| app = FastAPI(title="FinSight API") |
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
| DATA_DIR = Path("data") |
| DATA_DIR.mkdir(exist_ok=True) |
|
|
| GRAPH_PATH = str(DATA_DIR / "graph.json") |
|
|
| UPLOADS_DIR = DATA_DIR / "uploads" |
| UPLOADS_DIR.mkdir(exist_ok=True) |
|
|
| fg = FinancialGraph() |
|
|
| if Path(GRAPH_PATH).exists(): |
| fg.load(GRAPH_PATH) |
|
|
|
|
| class QueryRequest(BaseModel): |
| question: str |
| company: str | None = None |
|
|
|
|
| class CompareRequest(BaseModel): |
| companies: list[str] |
| metric: str = "revenue" |
| question: str | None = None |
|
|
|
|
| def try_direct_metric_answer( |
| question: str, |
| company: str, |
| metrics_by_year: dict |
| ): |
| q_lower = question.lower() |
|
|
| for raw_year, metrics in metrics_by_year.items(): |
| year_str = str(raw_year).strip() |
|
|
| year_matches = year_str in q_lower |
| if not year_matches: |
| try: |
| year_matches = str(int(year_str)) in q_lower |
| except ValueError: |
| year_matches = False |
|
|
| if not year_matches: |
| continue |
|
|
| for key, raw in metrics.items(): |
| metric_words = key.replace("_", " ").lower().split() |
|
|
| if all(w in q_lower for w in metric_words): |
| value, confidence = get_value(raw) |
|
|
| if value is None: |
| continue |
|
|
| label = key.replace("_", " ") |
|
|
| answer = f"{company} {year_str} {label}: {value}" |
|
|
| if confidence in ("low", "medium"): |
| answer += ( |
| f" (confidence: {confidence} β " |
| f"verify before relying on this figure)" |
| ) |
|
|
| return answer |
|
|
| return None |
|
|
|
|
| @app.get("/health") |
| def health(): |
| return { |
| "status": "ok", |
| "service": "FinSight API" |
| } |
|
|
|
|
| @app.post("/upload") |
| async def upload_document( |
| file: UploadFile = File(...), |
| company: str = Form(...), |
| year: str = Form(...) |
| ): |
| file_path = UPLOADS_DIR / file.filename |
|
|
| with open(file_path, "wb") as f: |
| shutil.copyfileobj(file.file, f) |
|
|
| try: |
| parsed = parse_document( |
| str(file_path), |
| company, |
| year |
| ) |
| except Exception as e: |
| raise HTTPException( |
| status_code=400, |
| detail=str(e) |
| ) |
|
|
| |
| |
| |
| use_llm_relations = os.getenv( |
| "RELATION_LLM_FALLBACK", "false" |
| ).strip().lower() == "true" |
|
|
| fg.add_document(parsed, use_llm_fallback=use_llm_relations) |
| fg.save(GRAPH_PATH) |
|
|
| metrics_summary = {} |
| low_confidence_flags = [] |
|
|
| for k, v in parsed["metrics"].items(): |
| if isinstance(v, dict): |
| metrics_summary[k] = v.get("value") |
|
|
| if v.get("needs_clarification"): |
| low_confidence_flags.append(k) |
| else: |
| metrics_summary[k] = v |
|
|
| return { |
| "status": "ok", |
| "company": company, |
| "year": year, |
| "sector": parsed.get("sector", "GENERAL"), |
| "chunks": parsed["chunk_count"], |
| "metrics": parsed["metrics"], |
| "metrics_summary": metrics_summary, |
| "low_confidence_metrics": low_confidence_flags, |
| "entities": len(parsed["entities"]) |
| } |
|
|
|
|
| @app.post("/query") |
| def query(req: QueryRequest): |
| t0 = time.time() |
|
|
| if req.company: |
| all_metrics = fg.get_company_metrics(req.company) |
|
|
| direct = try_direct_metric_answer( |
| req.question, |
| req.company, |
| all_metrics |
| ) |
|
|
| if direct: |
| return { |
| "answer": direct, |
| "latency": round(time.time() - t0, 2), |
| "chunks_used": 0, |
| "source": "direct_lookup" |
| } |
|
|
| chunks = fg.get_relevant_chunks( |
| req.question, |
| req.company |
| ) |
|
|
| metrics_context = "" |
|
|
| if req.company: |
| all_metrics = fg.get_company_metrics(req.company) |
|
|
| if all_metrics: |
| lines = [] |
|
|
| for year, metrics in all_metrics.items(): |
| for key, raw in metrics.items(): |
|
|
| if isinstance(raw, dict): |
| val = raw.get("value") |
| confidence = raw.get( |
| "confidence", |
| "unknown" |
| ) |
| currency = raw.get("currency", "USD") |
| else: |
| val = raw |
| confidence = "n/a" |
| currency = "USD" |
|
|
| if val is None: |
| continue |
|
|
| formatted = format_money( |
| val, |
| currency |
| ) |
|
|
| line = ( |
| f"{req.company} {year} " |
| f"{key}: {formatted}" |
| ) |
|
|
| if confidence in ("low", "medium"): |
| line += ( |
| f" (confidence: {confidence} β " |
| f"verify before stating as certain)" |
| ) |
|
|
| lines.append(line) |
|
|
| metrics_context = ( |
| "Key Metrics:\n" |
| + "\n".join(lines) |
| + "\n\n" |
| ) |
|
|
| context = metrics_context + "\n\n".join(c["text"] for c in chunks) |
|
|
| answer = ask( |
| req.question, |
| context |
| ) |
|
|
| |
| |
| all_metrics_flat = {} |
| if req.company: |
| for _year, m in fg.get_company_metrics(req.company).items(): |
| for k, v in m.items(): |
| all_metrics_flat[f"{_year}_{k}"] = v |
|
|
| verification = verify_answer(answer, context, all_metrics_flat) |
|
|
| citations = [ |
| { |
| "company": c.get("company"), |
| "year": c.get("year"), |
| "page": c.get("page") |
| } |
| for c in chunks |
| ] |
|
|
| return { |
| "answer": answer, |
| "latency": round(time.time() - t0, 2), |
| "chunks_used": len(chunks), |
| "citations": citations, |
| "verification": verification |
| } |
|
|
|
|
| @app.get("/report/{company}/{year}") |
| def get_report(company: str, year: str): |
| t0 = time.time() |
|
|
| metrics = fg.get_company_metrics(company) |
|
|
| if year not in metrics: |
| raise HTTPException( |
| status_code=404, |
| detail=f"No data for {company} {year}" |
| ) |
|
|
| year_metrics = metrics[year] |
|
|
| |
| |
| |
| |
| |
| |
| |
| flat_year_metrics = { |
| k: (v.get("value") if isinstance(v, dict) else v) |
| for k, v in year_metrics.items() |
| } |
|
|
| chunks = fg.get_relevant_chunks( |
| f"{company} financial performance {year}", |
| company |
| ) |
|
|
| context = "\n\n".join(c["text"] for c in chunks) |
|
|
| report = generate_report( |
| company, |
| year, |
| flat_year_metrics, |
| context |
| ) |
|
|
| verification = verify_answer(report, context, year_metrics) |
|
|
| citations = [ |
| { |
| "company": c.get("company"), |
| "year": c.get("year"), |
| "page": c.get("page") |
| } |
| for c in chunks |
| ] |
|
|
| return { |
| "company": company, |
| "year": year, |
| "metrics": year_metrics, |
| "report": report, |
| "latency": round(time.time() - t0, 2), |
| "citations": citations, |
| "verification": verification |
| } |
|
|
|
|
| @app.get("/red_flags/{company}/{year}") |
| def get_red_flags(company: str, year: str): |
| sector = fg.get_filing_sector( |
| company, |
| year |
| ) |
|
|
| if sector is None: |
| raise HTTPException( |
| status_code=404, |
| detail=f"No data for {company} {year}" |
| ) |
|
|
| return evaluate_red_flags( |
| fg, |
| company, |
| year, |
| sector=sector |
| ) |
|
|
|
|
| @app.get("/recommendation/{company}/{year}") |
| def get_recommendation(company: str, year: str): |
| sector = fg.get_filing_sector( |
| company, |
| year |
| ) |
|
|
| if sector is None: |
| raise HTTPException( |
| status_code=404, |
| detail=f"No data for {company} {year}" |
| ) |
|
|
| return evaluate_recommendation( |
| fg, |
| company, |
| year, |
| sector=sector |
| ) |
|
|
|
|
| @app.get("/metrics/{company}") |
| def get_metrics(company: str): |
| metrics = fg.get_company_metrics(company) |
|
|
| if not metrics: |
| raise HTTPException( |
| status_code=404, |
| detail=f"No data for {company}" |
| ) |
|
|
| return { |
| "company": company, |
| "metrics": metrics |
| } |
|
|
|
|
| @app.post("/compare") |
| def compare(req: CompareRequest): |
| t0 = time.time() |
|
|
| metric_data = fg.compare_companies( |
| req.companies, |
| req.metric |
| ) |
|
|
| flat_metric_data = { |
| company: { |
| year: ( |
| value.get("value") |
| if isinstance(value, dict) |
| else value |
| ) |
| for year, value in years.items() |
| } |
| for company, years in metric_data.items() |
| } |
|
|
| analysis = llm_compare( |
| req.companies, |
| flat_metric_data, |
| req.question |
| ) |
|
|
| return { |
| "metric": req.metric, |
| "data": metric_data, |
| "analysis": analysis, |
| "latency": round(time.time() - t0, 2) |
| } |
|
|
|
|
| @app.get("/companies") |
| def list_companies(): |
| companies = set() |
|
|
| for node, data in fg.G.nodes(data=True): |
| if data.get("type") == "company": |
| companies.add(node) |
|
|
| return { |
| "companies": list(companies) |
| } |
|
|
|
|
| |
| |
| |
| |
| _FRONTEND_DIST = Path(__file__).resolve().parent.parent / "frontend" / "dist" |
| if _FRONTEND_DIST.is_dir(): |
| from fastapi.staticfiles import StaticFiles |
| app.mount("/", StaticFiles(directory=str(_FRONTEND_DIST), html=True), name="frontend") |
|
|
|
|
| @app.get("/graph/{company}") |
| def get_graph(company: str): |
| return fg.get_company_graph(company) |
|
|