sharmaaryan commited on
Commit
b2931f4
Β·
1 Parent(s): 1a88339

FinRAG backend

Browse files
Files changed (43) hide show
  1. .dockerignore +36 -0
  2. .gitattributes +1 -0
  3. Dockerfile +49 -0
  4. README.md +22 -5
  5. backend/.python-version +1 -0
  6. backend/README.md +0 -0
  7. backend/pyproject.toml +54 -0
  8. backend/src/finrag/__init__.py +0 -0
  9. backend/src/finrag/agent/__init__.py +13 -0
  10. backend/src/finrag/agent/graph.py +62 -0
  11. backend/src/finrag/agent/nodes.py +227 -0
  12. backend/src/finrag/agent/state.py +25 -0
  13. backend/src/finrag/config.py +53 -0
  14. backend/src/finrag/eval/__init__.py +0 -0
  15. backend/src/finrag/eval/dataset.py +180 -0
  16. backend/src/finrag/eval/harness.py +283 -0
  17. backend/src/finrag/eval/metrics.py +183 -0
  18. backend/src/finrag/eval/smoke.py +232 -0
  19. backend/src/finrag/guardrails.py +131 -0
  20. backend/src/finrag/ingestion/__init__.py +0 -0
  21. backend/src/finrag/ingestion/edgar.py +229 -0
  22. backend/src/finrag/ingestion/embed.py +212 -0
  23. backend/src/finrag/ingestion/facts.py +448 -0
  24. backend/src/finrag/ingestion/parse.py +292 -0
  25. backend/src/finrag/llm/__init__.py +109 -0
  26. backend/src/finrag/llm/base.py +143 -0
  27. backend/src/finrag/llm/claude.py +300 -0
  28. backend/src/finrag/llm/gemini.py +326 -0
  29. backend/src/finrag/llm/local.py +229 -0
  30. backend/src/finrag/main.py +226 -0
  31. backend/src/finrag/retrieval/__init__.py +0 -0
  32. backend/src/finrag/retrieval/hybrid.py +136 -0
  33. backend/src/finrag/retrieval/lexical.py +211 -0
  34. backend/src/finrag/retrieval/rerank.py +146 -0
  35. backend/src/finrag/retrieval/vector.py +187 -0
  36. backend/src/finrag/tools/__init__.py +127 -0
  37. backend/src/finrag/tools/calculator.py +91 -0
  38. backend/src/finrag/tools/citation.py +37 -0
  39. backend/src/finrag/tools/sql.py +152 -0
  40. backend/uv.lock +0 -0
  41. data/bm25_index.pkl +3 -0
  42. data/duckdb/.gitkeep +0 -0
  43. data/duckdb/finrag.duckdb +3 -0
.dockerignore ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build context is the repo root (see backend/Dockerfile). Keep the image lean
2
+ # and secret-free: ship only the backend source + the two runtime data artifacts.
3
+
4
+ # Secrets β€” never bake keys into the image (use Fly secrets / env vars instead)
5
+ .env
6
+ .env.*
7
+ !.env.example
8
+
9
+ # Frontend deploys separately (Vercel)
10
+ frontend/
11
+
12
+ # Python / build cruft
13
+ **/__pycache__/
14
+ **/*.py[cod]
15
+ backend/.venv/
16
+ .venv/
17
+ *.egg-info/
18
+ .pytest_cache/
19
+ .ruff_cache/
20
+
21
+ # Heavy data NOT needed at runtime (offline ingestion inputs + eval outputs).
22
+ # data/duckdb/ and data/bm25_index.pkl are intentionally NOT ignored β€” the
23
+ # runtime needs them and the Dockerfile COPYs them explicitly.
24
+ data/raw/
25
+ data/processed/
26
+ data/*.json
27
+ data/eval_results_*.json
28
+ data/smoke_results.json
29
+
30
+ # Repo meta
31
+ .git/
32
+ .github/
33
+ docs/
34
+ *.md
35
+ PLAN.md
36
+ infra/
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.duckdb filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FinRAG backend image β€” portable across hosts (Hugging Face Spaces, Render,
2
+ # Cloud Run, local). Build context = repo ROOT (it needs backend/ + data/):
3
+ # docker build -t finrag-api .
4
+ #
5
+ # Image layout mirrors the dev tree so REPO_ROOT resolves identically:
6
+ # config.py at /app/backend/src/finrag/config.py β†’ parents[3] == /app
7
+ # β†’ DuckDB + BM25 (REPO_ROOT/data/...) live at /app/data. WORKDIR=/app keeps
8
+ # settings.duckdb_path ("./data/...") valid too.
9
+ #
10
+ # Port: listens on $PORT if the host injects one (Render/Cloud Run), else 8000.
11
+ # On Hugging Face Spaces, declare `app_port: 8000` in the Space README frontmatter.
12
+
13
+ # ---- builder: resolve + install deps into a venv (no ingestion/dev groups) ----
14
+ FROM python:3.11-slim AS builder
15
+ COPY --from=ghcr.io/astral-sh/uv:0.11.15 /uv /uvx /bin/
16
+ ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
17
+ WORKDIR /app/backend
18
+ COPY backend/pyproject.toml backend/uv.lock ./
19
+ RUN uv sync --frozen --no-install-project --no-dev
20
+ COPY backend/ ./
21
+ RUN uv sync --frozen --no-dev
22
+
23
+ # ---- runtime: slim, non-root ----
24
+ FROM python:3.11-slim AS runtime
25
+ # libgomp1 covers numpy/rank-bm25's OpenMP dependency on slim.
26
+ RUN apt-get update && apt-get install -y --no-install-recommends libgomp1 \
27
+ && rm -rf /var/lib/apt/lists/*
28
+ RUN useradd -m -u 1000 appuser
29
+ WORKDIR /app
30
+
31
+ # Installed venv + editable project source (finrag β†’ backend/src), then the two
32
+ # runtime data artifacts (Qdrant vectors live in the cloud cluster, not here).
33
+ COPY --from=builder --chown=appuser:appuser /app/backend /app/backend
34
+ COPY --chown=appuser:appuser data/duckdb /app/data/duckdb
35
+ COPY --chown=appuser:appuser data/bm25_index.pkl /app/data/bm25_index.pkl
36
+
37
+ ENV PATH="/app/backend/.venv/bin:$PATH" \
38
+ PYTHONUNBUFFERED=1 \
39
+ PYTHONIOENCODING=utf-8 \
40
+ PORT=8000
41
+ USER appuser
42
+ EXPOSE 8000
43
+
44
+ # Healthcheck honors $PORT so it stays correct on any host.
45
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=25s --retries=3 \
46
+ CMD python -c "import os,sys,urllib.request; p=os.environ.get('PORT','8000'); sys.exit(0 if urllib.request.urlopen(f'http://localhost:{p}/health',timeout=3).status==200 else 1)"
47
+
48
+ # Shell form so ${PORT} expands at runtime.
49
+ CMD ["sh", "-c", "uvicorn finrag.main:app --host 0.0.0.0 --port ${PORT:-8000}"]
README.md CHANGED
@@ -1,10 +1,27 @@
1
  ---
2
- title: Finrag Api
3
- emoji: 🐠
4
- colorFrom: gray
5
- colorTo: green
6
  sdk: docker
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: FinRAG API
3
+ emoji: πŸ“Š
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
+ app_port: 8000
8
  pinned: false
9
+ short_description: Agentic RAG over SEC 10-K filings β€” cost-capped public demo backend
10
  ---
11
 
12
+ # FinRAG β€” backend (Hugging Face Space)
13
+
14
+ This Space runs the **FinRAG** FastAPI backend as a Docker container. The chat
15
+ frontend is hosted separately (Vercel) and calls this Space's URL. Full source,
16
+ architecture, and the evaluation writeup live in the GitHub repo.
17
+
18
+ **Endpoints:** `/health` Β· `/query` Β· `/answer` Β· `/agent` Β· `/agent/stream`
19
+
20
+ **Guardrails are active** (this is a public demo on a funded key): a per-IP rate
21
+ limit and a global daily question cap (the agent runs on Claude Haiku). Hit
22
+ `/health` to see the remaining daily quota. For unlimited use, run it locally β€”
23
+ see the GitHub README.
24
+
25
+ > NOTE: this file is the **Space's** README (HF reads the frontmatter above for
26
+ > the Docker SDK + port). It is intentionally separate from the GitHub repo's
27
+ > README. Copy it to the Space repo root as `README.md`.
backend/.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.11
backend/README.md ADDED
File without changes
backend/pyproject.toml ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "finrag"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Aryan Sharma", email = "aryan250403@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "anthropic>=0.40",
12
+ "cohere>=6.1.0",
13
+ "duckdb>=1.5.3",
14
+ "fastapi[standard]>=0.136.1",
15
+ "google-genai>=2.8.0",
16
+ "langgraph>=1.2.4",
17
+ "openai>=1.50",
18
+ "pydantic>=2.12.5",
19
+ "pydantic-settings>=2.14.1",
20
+ "qdrant-client>=1.18.0",
21
+ "rank-bm25>=0.2.2",
22
+ ]
23
+
24
+
25
+ [build-system]
26
+ requires = ["uv_build>=0.11.15,<0.12.0"]
27
+ build-backend = "uv_build"
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "pytest>=9.0.3",
32
+ "pytest-asyncio>=1.3.0",
33
+ "ruff>=0.15.13",
34
+ ]
35
+ # Offline ingestion only (parse.py). Pulls torch/transformers/onnxruntime via
36
+ # unstructured[pdf] β€” multi-GB and slow β€” so it is deliberately OUT of the
37
+ # runtime dependency set: the serving image (and `uv sync --no-dev`) skip it.
38
+ # Build/refresh the corpus with: uv sync --group ingestion
39
+ ingestion = [
40
+ "unstructured[pdf]>=0.22.29",
41
+ ]
42
+
43
+ [tool.ruff]
44
+ line-length = 100
45
+ target-version = "py311"
46
+
47
+ [tool.ruff.lint]
48
+ select = ["E", "F", "I", "B", "UP", "N"]
49
+ # E: pycodestyle errors
50
+ # F: pyflakes (unused imports, undefined names)
51
+ # I: isort (import order)
52
+ # B: bugbear (likely bugs)
53
+ # UP: pyupgrade (modernize syntax)
54
+ # N: pep8-naming
backend/src/finrag/__init__.py ADDED
File without changes
backend/src/finrag/agent/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FinRAG agent β€” LangGraph state machine over the Decision-15 tools.
2
+
3
+ Public surface: `run_agent(question)` returns the final AgentState
4
+ (answer + route + chunks + trace + usage). `main.py`'s /agent endpoint and the
5
+ graph's own __main__ are the two callers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from finrag.agent.graph import build_graph, get_agent, run_agent
11
+ from finrag.agent.state import AgentState
12
+
13
+ __all__ = ["run_agent", "get_agent", "build_graph", "AgentState"]
backend/src/finrag/agent/graph.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Wire the nodes into a LangGraph state machine.
2
+
3
+ START β†’ plan ──(vector|both)──→ retrieve ─┐
4
+ └──────(sql)─────────────────
5
+ ↓
6
+ agent(tool-loop) β†’ END
7
+
8
+ `plan` does rewrite+route in one call (free-tier request budget). The
9
+ conditional edge is the one branch: sql-only questions skip vector retrieval
10
+ and go straight to the tool-loop (the agent calls sql_query itself);
11
+ vector/both questions pre-fetch chunks first.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from functools import lru_cache
17
+
18
+ from langgraph.graph import END, START, StateGraph
19
+
20
+ from finrag.agent import nodes
21
+ from finrag.agent.state import AgentState
22
+
23
+
24
+ def _after_route(state: AgentState) -> str:
25
+ return "retrieve" if state.get("route") in ("vector", "both") else "agent"
26
+
27
+
28
+ def build_graph():
29
+ g = StateGraph(AgentState)
30
+ g.add_node("plan", nodes.plan)
31
+ g.add_node("retrieve", nodes.retrieve)
32
+ g.add_node("agent", nodes.agent)
33
+
34
+ g.add_edge(START, "plan")
35
+ g.add_conditional_edges(
36
+ "plan", _after_route, {"retrieve": "retrieve", "agent": "agent"}
37
+ )
38
+ g.add_edge("retrieve", "agent")
39
+ g.add_edge("agent", END)
40
+ return g.compile()
41
+
42
+
43
+ @lru_cache(maxsize=1)
44
+ def get_agent():
45
+ """Compiled graph, built once per process (compilation is non-trivial)."""
46
+ return build_graph()
47
+
48
+
49
+ def run_agent(question: str) -> AgentState:
50
+ return get_agent().invoke({"question": question, "trace": []})
51
+
52
+
53
+ if __name__ == "__main__":
54
+ import json
55
+
56
+ final = run_agent("How did Apple's services revenue change in fiscal 2023, and by what percent?")
57
+ print("ROUTE :", final.get("route"))
58
+ print("ANSWER:", final.get("answer"))
59
+ print("USAGE :", final.get("usage"))
60
+ print("TRACE :")
61
+ for step in final.get("trace", []):
62
+ print(" -", json.dumps(step)[:200])
backend/src/finrag/agent/nodes.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The agent's LangGraph nodes.
2
+
3
+ Flow: rewrite_query β†’ route β†’ (retrieve?) β†’ agent(tool-loop+synthesis) β†’ END.
4
+
5
+ Design note: the handoff listed tool-loop and synthesize as separate steps, but
6
+ with native function-calling they're one node by construction β€” the loop runs
7
+ until the model stops emitting function_calls and produces its final text, and
8
+ that terminal text *is* the synthesis. We still emit a distinct 'synthesize'
9
+ trace event so the frontend (Decision 18) can render it as its own step.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from functools import lru_cache
15
+
16
+ from finrag.agent.state import AgentState
17
+ from finrag.ingestion.facts import corpus_companies, corpus_years
18
+ from finrag.llm import generate_text, run_tool_loop_stream, synthesize
19
+ from finrag.llm.base import ToolCall, format_chunks_for_prompt
20
+ from finrag.retrieval.rerank import rerank_search
21
+
22
+ MAX_TOOL_ITERS = 5 # hard cap so a confused model can't loop forever
23
+
24
+
25
+ @lru_cache(maxsize=1)
26
+ def _corpus_grounding() -> str:
27
+ """Tell the model the exact known universe so vague references ('these three
28
+ companies', 'all of them') resolve to real corpus members instead of the
29
+ model guessing (it would otherwise pull in Microsoft/Google). Data-driven β€”
30
+ reads the loaded DuckDB, so it can never drift from what's queryable."""
31
+ companies = corpus_companies()
32
+ if not companies: # corpus not loaded β€” emit nothing rather than a wrong claim
33
+ return ""
34
+ lo, hi = corpus_years()
35
+ span = f"fiscal years {lo}–{hi}" if lo and hi else "the available fiscal years"
36
+ listing = "; ".join(f"{name} ({ticker})" for ticker, name in companies)
37
+ return (
38
+ f"\n\nKNOWN CORPUS β€” the dataset contains EXACTLY these {len(companies)} "
39
+ f"companies, {span}: {listing}.\n"
40
+ "When the question refers to the companies without naming them ('these "
41
+ "companies', 'the three companies', 'all of them', 'each company'), it means "
42
+ "exactly this set β€” resolve the reference to these names. Never introduce a "
43
+ "company outside this set; if asked about one that isn't listed, say it is not "
44
+ "in the corpus rather than answering from general knowledge."
45
+ )
46
+
47
+
48
+ def _stream_writer():
49
+ """LangGraph custom-stream writer when the graph is driven by
50
+ `graph.stream(..., stream_mode=[..., "custom"])` (the /agent/stream SSE
51
+ path); a no-op otherwise (plain invoke / direct call). The same agent node
52
+ therefore serves both /agent and /agent/stream without branching."""
53
+ try:
54
+ from langgraph.config import get_stream_writer
55
+
56
+ return get_stream_writer()
57
+ except Exception:
58
+ return lambda _data: None
59
+
60
+ # ── Prompts ────────────────────────────────────────────────────────────────
61
+ # rewrite + route merged into ONE call to save a request against the free-tier
62
+ # 5-req/min cap (the agent is call-heavy). The route hint is also tightened:
63
+ # segment-level figures (services/product revenue) live in narrative, not in
64
+ # our top-level XBRL facts, so they must route to vector β€” this fixes the
65
+ # earlier mis-route that answered "services revenue" with total revenue.
66
+ _PLAN_SYSTEM = """You prepare a question about SEC 10-K filings for retrieval. Do two things:
67
+
68
+ 1. Rewrite it as a concise, self-contained search query: resolve vague references, and make the company and fiscal year explicit if implied.
69
+ 2. Classify what it needs:
70
+ - vector : qualitative/narrative content, OR segment-level figures like services/product/regional revenue (these live in the filing text, not the figures database)
71
+ - sql : precise TOP-LEVEL financials (total revenue, net income, total assets, margins, multi-year or cross-company comparisons)
72
+ - both : needs narrative AND exact top-level figures
73
+
74
+ Output EXACTLY two lines, nothing else:
75
+ QUERY: <rewritten query>
76
+ ROUTE: <vector|sql|both>"""
77
+
78
+ _AGENT_SYSTEM = """You are a financial analyst assistant answering questions about SEC 10-K filings.
79
+
80
+ You have tools:
81
+ - sql_query: get EXACT figures for TOP-LEVEL metrics only (total revenue, net income, total assets, margins). Prefer it for those over reading numbers from text. It does NOT have segment/product/regional figures (e.g. services revenue, iPhone revenue) β€” for those, read the value from the context chunks and cite [N]. If sql_query returns an error, fall back to the context.
82
+ - calculator: do arithmetic (growth rates, margins, ratios). Extract numbers, then compute β€” never do multi-digit math in your head.
83
+ - lookup_citation: re-fetch a chunk's full text by chunk_id if you need to quote it exactly. Pass the exact id shown as (id=...) in the chunk's header β€” never the [N] anchor.
84
+
85
+ Rules:
86
+ 1. Ground every claim in the provided context chunks or tool results. If neither contains the answer, say so β€” do not use prior knowledge.
87
+ 2. A figure must actually match what was asked. If a tool returns a number for a different metric than the question, do not report it β€” use the context instead.
88
+ 3. Cite narrative facts from the context with [N], where N is the chunk index shown. Cite even when paraphrasing.
89
+ 4. Quote exact figures; never round unless asked.
90
+ 5. Be careful with fiscal vs calendar year (Apple's fiscal year ends in late September).
91
+ 6. Be concise β€” match the question's scope.
92
+ 7. Do not embellish. State only what the context or tool results actually support. Do not add provenance you cannot see (e.g. "as disclosed in the 10-K" when the figure came from the figures database), characterizations ("a record-setting profit", "strong performance"), or outside facts not present in the context/tool results. A correct figure with ungrounded commentary is still a faithfulness failure.
93
+ """
94
+
95
+
96
+ def _parse_plan(raw: str, fallback_query: str) -> tuple[str, str]:
97
+ """Parse the two-line plan output into (rewritten_query, route)."""
98
+ query, route = fallback_query, "both"
99
+ for line in raw.splitlines():
100
+ s = line.strip()
101
+ low = s.lower()
102
+ if low.startswith("query:"):
103
+ query = s.split(":", 1)[1].strip() or fallback_query
104
+ elif low.startswith("route:"):
105
+ r = s.split(":", 1)[1].strip().lower()
106
+ if "both" in r:
107
+ route = "both"
108
+ elif "sql" in r:
109
+ route = "sql"
110
+ elif "vector" in r:
111
+ route = "vector"
112
+ return query, route
113
+
114
+
115
+ def plan(state: AgentState) -> AgentState:
116
+ """One call that both rewrites the query and routes it. Emits two trace
117
+ events so the frontend still shows rewrite and route as distinct steps."""
118
+ original = state["question"]
119
+ # Ground the rewrite in the known corpus so "these 3 companies" expands to the
120
+ # real names here, before retrieval and the agent ever see the query.
121
+ raw = generate_text(_PLAN_SYSTEM + _corpus_grounding(), original, max_output_tokens=128)
122
+ rewritten, decision = _parse_plan(raw, original)
123
+ return {
124
+ "rewritten_query": rewritten,
125
+ "route": decision,
126
+ "trace": [
127
+ {
128
+ "node": "plan",
129
+ "type": "rewrite",
130
+ "data": {"original": original, "rewritten": rewritten},
131
+ },
132
+ {"node": "plan", "type": "route", "data": {"route": decision}},
133
+ ],
134
+ }
135
+
136
+
137
+ def retrieve(state: AgentState) -> AgentState:
138
+ """Vector retrieval via the Day-2 funnel. Reached only when the route
139
+ includes vector (conditional edge in graph.py)."""
140
+ chunks = rerank_search(question=state["rewritten_query"], top_k=8)
141
+ return {
142
+ "chunks": chunks,
143
+ "trace": [
144
+ {
145
+ "node": "retrieve",
146
+ "type": "retrieve",
147
+ "data": {
148
+ "n_chunks": len(chunks),
149
+ "top": [
150
+ {"chunk_id": c.chunk_id, "ticker": c.ticker, "fy": c.fiscal_year}
151
+ for c in chunks[:3]
152
+ ],
153
+ },
154
+ }
155
+ ],
156
+ }
157
+
158
+
159
+ def agent(state: AgentState) -> AgentState:
160
+ """Provider-neutral tool-calling loop (Claude tool_use or Gemini function
161
+ calling, per llm_provider). Runs tools until a final text answer, surfacing
162
+ each tool call in the trace (the SQL/args are the demo payload)."""
163
+ chunks = state.get("chunks", [])
164
+ context = (
165
+ format_chunks_for_prompt(chunks)
166
+ if chunks
167
+ else "(no vector context retrieved β€” rely on tools)"
168
+ )
169
+ user_text = f"Question: {state['rewritten_query']}\n\nContext chunks:\n\n{context}"
170
+
171
+ # Push live events to the SSE stream (no-op under plain /agent). Tokens are
172
+ # the final answer forming; tool_call fires the instant a tool runs.
173
+ writer = _stream_writer()
174
+
175
+ def on_text(delta: str) -> None:
176
+ writer({"type": "token", "text": delta})
177
+
178
+ def on_tool_call(tc: ToolCall) -> None:
179
+ writer(
180
+ {
181
+ "type": "tool_call",
182
+ "node": "agent",
183
+ "data": {"tool": tc.tool, "args": tc.args, "result": tc.result},
184
+ }
185
+ )
186
+
187
+ result = run_tool_loop_stream(
188
+ _AGENT_SYSTEM + _corpus_grounding(),
189
+ user_text,
190
+ # 1024 truncated detailed multi-company answers mid-sentence (e.g. a risk
191
+ # comparison table got cut off). 4096 comfortably fits the longest answers
192
+ # we produce while staying well under Sonnet's output limit.
193
+ max_tokens=4096,
194
+ max_iters=MAX_TOOL_ITERS,
195
+ on_text=on_text,
196
+ on_tool_call=on_tool_call,
197
+ )
198
+
199
+ trace: list[dict] = [
200
+ {
201
+ "node": "agent",
202
+ "type": "tool_call",
203
+ "data": {"tool": tc.tool, "args": tc.args, "result": tc.result},
204
+ }
205
+ for tc in result.tool_calls
206
+ ]
207
+ usage = {"input_tokens": result.input_tokens, "output_tokens": result.output_tokens}
208
+ answer = result.answer
209
+
210
+ # Reliability floor: if the tool-loop yields no answer (e.g. a backend that
211
+ # intermittently botches a tool call), fall back to plain synthesis over the
212
+ # retrieved chunks β€” the proven /answer path, no tool-calling involved.
213
+ if not answer.strip() and chunks:
214
+ fb = synthesize(state["rewritten_query"], chunks)
215
+ answer = fb.answer
216
+ usage["input_tokens"] += fb.input_tokens
217
+ usage["output_tokens"] += fb.output_tokens
218
+ trace.append(
219
+ {
220
+ "node": "agent",
221
+ "type": "fallback",
222
+ "data": {"reason": "tool-loop produced no answer; synthesized from retrieved chunks"},
223
+ }
224
+ )
225
+
226
+ trace.append({"node": "agent", "type": "synthesize", "data": {"answer": answer}})
227
+ return {"answer": answer, "usage": usage, "trace": trace}
backend/src/finrag/agent/state.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LangGraph state for the FinRAG agent.
2
+
3
+ The state is a TypedDict threaded through every node; each node returns a
4
+ partial dict that LangGraph merges in. `trace` uses an additive reducer so
5
+ every node *appends* its step (rather than overwriting) β€” that accumulated
6
+ list is what Decision 18's frontend renders as the agent's visible reasoning.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import operator
12
+ from typing import Annotated, Any, TypedDict
13
+
14
+ from finrag.retrieval.vector import RetrievedChunk
15
+
16
+
17
+ class AgentState(TypedDict, total=False):
18
+ question: str # raw user question
19
+ rewritten_query: str # normalized, self-contained query
20
+ route: str # "vector" | "sql" | "both"
21
+ chunks: list[RetrievedChunk] # vector context (empty for sql-only routes)
22
+ answer: str # final grounded answer
23
+ usage: dict[str, int] # token totals across all agent LLM calls
24
+ # Additive: nodes append step records; the reducer concatenates them.
25
+ trace: Annotated[list[dict[str, Any]], operator.add]
backend/src/finrag/config.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/finrag/config.py
2
+ from pydantic_settings import BaseSettings, SettingsConfigDict
3
+ from pathlib import Path
4
+ REPO_ROOT = Path(__file__).resolve().parents[3]
5
+ # config.py β†’ finrag/ β†’ src/ β†’ backend/ β†’ REPO_ROOT
6
+
7
+ class Settings(BaseSettings):
8
+ # Both provider keys are optional: the active one is decided by
9
+ # `llm_provider`, and we validate presence lazily at call time so a
10
+ # Gemini-only setup doesn't need an Anthropic key (and vice versa).
11
+ anthropic_api_key: str | None = None
12
+ gemini_api_key: str | None = None
13
+ cohere_api_key: str
14
+ qdrant_url: str = "http://localhost:6333"
15
+ qdrant_api_key: str | None = None
16
+ duckdb_path: str = "./data/duckdb/finrag.duckdb"
17
+ llm_mode: str = "cloud"
18
+ # Which backend the finrag.llm dispatchers use: "anthropic" | "gemini" | "local".
19
+ llm_provider: str = "anthropic"
20
+ # Anthropic model. Default = the eval-validated Sonnet; the public demo deploy
21
+ # overrides this to Haiku (CLAUDE_MODEL=claude-haiku-4-5-20251001) to cut the
22
+ # per-question cost ~10x behind the guardrails below.
23
+ claude_model: str = "claude-sonnet-4-6"
24
+
25
+ # ── Public-deploy guardrails (only bite when a real client calls) ──
26
+ # CORS allow-list, comma-separated. Dev defaults to the Next.js dev server;
27
+ # the prod deploy sets this to the exact Vercel origin (see docs/deploy.md).
28
+ allowed_origins: str = "http://localhost:3000,http://127.0.0.1:3000"
29
+ # Per-IP sliding-window limit on the PAID endpoints (/answer,/agent[,/stream]).
30
+ rate_limit_per_min: int = 8
31
+ # Global hard ceiling on paid questions per UTC day β€” the cost circuit-breaker.
32
+ # 300 Haiku agent-questions β‰ˆ a couple of dollars worst case; past it the API
33
+ # returns 429 until midnight UTC instead of burning the key.
34
+ daily_question_cap: int = 300
35
+
36
+ @property
37
+ def allowed_origins_list(self) -> list[str]:
38
+ return [o.strip() for o in self.allowed_origins.split(",") if o.strip()]
39
+
40
+ # ── Local / edge provider (Ollama, OpenAI-compatible API on :11434) ──
41
+ # Used only when llm_provider == "local". base_url is the OpenAI-compat
42
+ # endpoint, so the same backend would target vLLM/llama.cpp/LM Studio by
43
+ # swapping this one value. local_use_tools is the kill-switch: True runs the
44
+ # real agentic tool-loop (tests whether a 3B model can drive it); False runs
45
+ # degraded synthesis-only over retrieved context (the documented fallback for
46
+ # small models whose tool-calling is unreliable β€” see docs/handoff.md Day 5).
47
+ local_model: str = "llama3.2:3b"
48
+ local_base_url: str = "http://localhost:11434/v1"
49
+ local_use_tools: bool = True
50
+
51
+ model_config = SettingsConfigDict(env_file=REPO_ROOT / ".env", extra="ignore")
52
+
53
+ settings = Settings() # validates at import time
backend/src/finrag/eval/__init__.py ADDED
File without changes
backend/src/finrag/eval/dataset.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Day-4 evaluation set β€” the questions that make the system *provable*.
2
+
3
+ Four tiers, each probing a distinct failure mode of an agentic RAG system:
4
+
5
+ factual β†’ sql route. One exact top-level figure. We assert the number
6
+ appears in the answer. Ground truth is read from our own
7
+ `financial_facts` table (correct-by-construction), so this tier
8
+ is really a regression check: does the agent route to sql and
9
+ report the figure *faithfully*, or hallucinate / mis-label it
10
+ (the exact bug that survived Day 2)?
11
+ narrative β†’ vector route. Qualitative content. No numeric ground truth;
12
+ graded by the LLM judge on faithfulness + answer relevance, and
13
+ by a deterministic check that the answer actually cites [N].
14
+ multihop β†’ both + calculator. A figure that must be *computed* (YoY growth,
15
+ margin) or a cross-company comparison. Tests retrieval + tool use
16
+ + arithmetic end to end.
17
+ honesty β†’ unanswerable from the corpus (a company/year we don't have, or a
18
+ forward-looking number). The agent must DECLINE, not fabricate β€”
19
+ the single most important behaviour for a finance assistant.
20
+
21
+ Ground truth for the numeric tiers is a `gt` callable resolved lazily against
22
+ DuckDB, so this module imports without touching the DB and the numbers can
23
+ never drift from what the agent can actually query.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from collections.abc import Callable
29
+ from dataclasses import dataclass
30
+
31
+ from finrag.ingestion.facts import query
32
+
33
+ # ── Tiers ────────────────────────────────────────────────────────────────
34
+ FACTUAL = "factual"
35
+ NARRATIVE = "narrative"
36
+ MULTIHOP = "multihop"
37
+ HONESTY = "honesty"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class EvalCase:
42
+ id: str
43
+ tier: str
44
+ question: str
45
+ # Soft expectation β€” the plan node's route. We record match/mismatch but a
46
+ # mismatch isn't a hard failure (the agent can still answer correctly via a
47
+ # different path).
48
+ route_expected: str | None = None
49
+ # Numeric ground truth, resolved lazily from the DB at run time.
50
+ gt: Callable[[], float] | None = None
51
+ gt_kind: str = "currency" # currency | per_share | percent
52
+ # Relative tolerance for currency/per_share; for percent it's the absolute
53
+ # percentage-point floor (combined with a 5% relative band in the matcher).
54
+ tol: float = 0.015
55
+ # For name-style answers ("which company…") and any case where a substring
56
+ # must appear (e.g. the right company name).
57
+ expect_substring: str | None = None
58
+ # Honesty tier: the agent must refuse / say it's not in the corpus.
59
+ must_decline: bool = False
60
+
61
+
62
+ # ── Ground-truth helper ──────────────────────────────────────────────────
63
+ def _v(ticker: str, line_item: str, fy: int, unit: str = "USD") -> float:
64
+ """The single authoritative value for a (ticker, line_item, year) from
65
+ financial_facts β€” the same table sql_query reads. LIMIT 1 because each of
66
+ our three filers resolves to one surviving GAAP concept per annual period
67
+ (see facts.py dedup); ordering by value desc is a stable tie-break."""
68
+ rows = query(
69
+ """
70
+ SELECT value FROM financial_facts
71
+ WHERE ticker = ? AND line_item = ? AND fiscal_year = ?
72
+ AND fiscal_period = 'FY' AND unit = ?
73
+ ORDER BY value DESC LIMIT 1
74
+ """,
75
+ [ticker, line_item, fy, unit],
76
+ )
77
+ if not rows:
78
+ raise LookupError(f"no fact for {ticker} {line_item} FY{fy} ({unit})")
79
+ return float(rows[0]["value"])
80
+
81
+
82
+ def _yoy(ticker: str, line_item: str, y0: int, y1: int) -> float:
83
+ """Year-over-year percent growth from y0 to y1."""
84
+ a, b = _v(ticker, line_item, y0), _v(ticker, line_item, y1)
85
+ return (b - a) / a * 100.0
86
+
87
+
88
+ def _margin(ticker: str, part: str, whole: str, fy: int) -> float:
89
+ """A margin in percent (e.g. gross_profit / revenue)."""
90
+ return _v(ticker, part, fy) / _v(ticker, whole, fy) * 100.0
91
+
92
+
93
+ # ── The set ──────────────────────────────────────────────────────────────
94
+ CASES: list[EvalCase] = [
95
+ # ─────────── FACTUAL (sql route, exact figure) ───────────
96
+ EvalCase("f01", FACTUAL, "What was Apple's net income in fiscal 2023?",
97
+ route_expected="sql", gt=lambda: _v("AAPL", "net_income", 2023)),
98
+ EvalCase("f02", FACTUAL, "What was Apple's total revenue in fiscal 2024?",
99
+ route_expected="sql", gt=lambda: _v("AAPL", "revenue", 2024)),
100
+ EvalCase("f03", FACTUAL, "How much did Tesla spend on research and development in fiscal 2023?",
101
+ route_expected="sql", gt=lambda: _v("TSLA", "rd_expense", 2023)),
102
+ EvalCase("f04", FACTUAL, "What were JPMorgan's total assets at the end of fiscal 2023?",
103
+ route_expected="sql", gt=lambda: _v("JPM", "total_assets", 2023)),
104
+ EvalCase("f05", FACTUAL, "What was Apple's operating income in fiscal 2022?",
105
+ route_expected="sql", gt=lambda: _v("AAPL", "operating_income", 2022)),
106
+ EvalCase("f06", FACTUAL, "What was Tesla's total revenue in fiscal 2024?",
107
+ route_expected="sql", gt=lambda: _v("TSLA", "revenue", 2024)),
108
+ EvalCase("f07", FACTUAL, "What was JPMorgan's net income in fiscal 2024?",
109
+ route_expected="sql", gt=lambda: _v("JPM", "net_income", 2024)),
110
+ EvalCase("f08", FACTUAL, "What was Apple's gross profit in fiscal 2023?",
111
+ route_expected="sql", gt=lambda: _v("AAPL", "gross_profit", 2023)),
112
+ EvalCase("f09", FACTUAL, "What was Tesla's diluted earnings per share in fiscal 2023?",
113
+ route_expected="sql", gt=lambda: _v("TSLA", "eps_diluted", 2023, "USD/shares"),
114
+ gt_kind="per_share"),
115
+ EvalCase("f10", FACTUAL, "What was JPMorgan's net interest income in fiscal 2024?",
116
+ route_expected="sql", gt=lambda: _v("JPM", "net_interest_income", 2024)),
117
+
118
+ # ─────────── NARRATIVE (vector route, faithfulness + citation) ───────────
119
+ EvalCase("n01", NARRATIVE, "How does Apple describe the risks to its supply chain in its 10-K?",
120
+ route_expected="vector"),
121
+ EvalCase("n02", NARRATIVE, "What does Tesla say about competition in the electric-vehicle market?",
122
+ route_expected="vector"),
123
+ EvalCase("n03", NARRATIVE, "How does JPMorgan describe credit risk in its filing?",
124
+ route_expected="vector"),
125
+ EvalCase("n04", NARRATIVE, "How does Apple characterize its Services business and what drives its growth?",
126
+ route_expected="vector"),
127
+ EvalCase("n05", NARRATIVE, "What risks does Tesla cite related to its dependence on key personnel?",
128
+ route_expected="vector"),
129
+ EvalCase("n06", NARRATIVE, "How does Apple describe foreign-currency exchange-rate risk?",
130
+ route_expected="vector"),
131
+ EvalCase("n07", NARRATIVE, "What does Tesla say about risks in ramping production and manufacturing?",
132
+ route_expected="vector"),
133
+ EvalCase("n08", NARRATIVE, "How does JPMorgan describe the regulatory and capital requirements it faces?",
134
+ route_expected="vector"),
135
+
136
+ # ─────────── MULTIHOP (sql + calculator; route left soft) ───────────
137
+ # route_expected is None: each of these is answerable from structured facts
138
+ # plus arithmetic, so the router legitimately picks `sql` over `both`. The
139
+ # tier tests multi-step reasoning (fetch figure(s) β†’ compute), not the route.
140
+ EvalCase("m01", MULTIHOP, "By what percentage did Apple's net income change from fiscal 2022 to fiscal 2023?",
141
+ gt=lambda: _yoy("AAPL", "net_income", 2022, 2023), gt_kind="percent"),
142
+ EvalCase("m02", MULTIHOP, "By what percentage did Tesla's revenue grow from fiscal 2023 to fiscal 2024?",
143
+ gt=lambda: _yoy("TSLA", "revenue", 2023, 2024), gt_kind="percent"),
144
+ EvalCase("m03", MULTIHOP, "What was Apple's gross margin in fiscal 2023?",
145
+ gt=lambda: _margin("AAPL", "gross_profit", "revenue", 2023), gt_kind="percent"),
146
+ EvalCase("m04", MULTIHOP, "What was Tesla's net profit margin in fiscal 2023?",
147
+ gt=lambda: _margin("TSLA", "net_income", "revenue", 2023), gt_kind="percent"),
148
+ EvalCase("m05", MULTIHOP, "What was Apple's operating margin in fiscal 2024?",
149
+ gt=lambda: _margin("AAPL", "operating_income", "revenue", 2024), gt_kind="percent"),
150
+ EvalCase("m06", MULTIHOP, "Which of the three companies had the highest net income in fiscal 2023?",
151
+ expect_substring="Apple"),
152
+ EvalCase("m07", MULTIHOP, "By what percentage did JPMorgan's net income change from fiscal 2023 to fiscal 2024?",
153
+ gt=lambda: _yoy("JPM", "net_income", 2023, 2024), gt_kind="percent"),
154
+
155
+ # ─────────── HONESTY (must decline β€” outside the corpus) ───────────
156
+ EvalCase("h01", HONESTY, "What was Microsoft's net income in fiscal 2023?",
157
+ must_decline=True),
158
+ EvalCase("h02", HONESTY, "What was Apple's total revenue in fiscal 2019?",
159
+ must_decline=True), # outside our FY2022–2024 span
160
+ EvalCase("h03", HONESTY, "How many employees does Amazon have according to its 10-K?",
161
+ must_decline=True), # company not in corpus
162
+ EvalCase("h04", HONESTY, "What will Tesla's revenue be in fiscal 2026?",
163
+ must_decline=True), # forward-looking, not in any filing
164
+ EvalCase("h05", HONESTY, "What was Google's operating margin in fiscal 2023?",
165
+ must_decline=True), # company not in corpus
166
+ ]
167
+
168
+
169
+ def cases_for(tier: str | None = None) -> list[EvalCase]:
170
+ return [c for c in CASES if tier is None or c.tier == tier]
171
+
172
+
173
+ def one_per_tier() -> list[EvalCase]:
174
+ """A 4-case smoke subset β€” first case of each tier. Used to validate the
175
+ harness end to end before spending on a full run, and as the Gemini A/B
176
+ subset that fits the free-tier daily quota."""
177
+ out: list[EvalCase] = []
178
+ for tier in (FACTUAL, NARRATIVE, MULTIHOP, HONESTY):
179
+ out.append(cases_for(tier)[0])
180
+ return out
backend/src/finrag/eval/harness.py ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Day-4 eval harness β€” runs the agent over the tiered set, scores each answer,
2
+ and reports per-tier + overall metrics. Provider-parametrized so the same run
3
+ drives the Claude vs Gemini A/B.
4
+
5
+ uv run python -m finrag.eval.harness # full set, Claude
6
+ uv run python -m finrag.eval.harness --smoke # 1 per tier (cheap)
7
+ uv run python -m finrag.eval.harness --tier factual # one tier
8
+ uv run python -m finrag.eval.harness --provider gemini --smoke # A/B subset
9
+
10
+ The judge is always Claude (see metrics.py); only the *system under test* flips
11
+ with --provider, so the grader is held constant across the A/B.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import sys
19
+ import time
20
+ from dataclasses import asdict, dataclass, field
21
+ from pathlib import Path
22
+
23
+ from finrag.config import settings
24
+ from finrag.eval import dataset as ds
25
+ from finrag.eval import metrics as mt
26
+ from finrag.llm.base import format_chunks_for_prompt
27
+
28
+ REPO_ROOT = Path(__file__).resolve().parents[4]
29
+ OUT_DIR = REPO_ROOT / "data"
30
+
31
+ # Rough public list prices ($/M tokens) for an order-of-magnitude cost number.
32
+ # We only have the agent node's tokens (the plan call's usage isn't threaded
33
+ # into state), so this is a floor, labelled as such in the report.
34
+ _RATES = {"anthropic": (3.0, 15.0), "gemini": (0.10, 0.40), "local": (0.0, 0.0)}
35
+
36
+
37
+ @dataclass
38
+ class CaseResult:
39
+ id: str
40
+ tier: str
41
+ question: str
42
+ route_expected: str | None
43
+ route_actual: str | None = None
44
+ answer: str = ""
45
+ n_chunks: int = 0
46
+ n_tool_calls: int = 0
47
+ latency_ms: int = 0
48
+ input_tokens: int = 0
49
+ output_tokens: int = 0
50
+ # tier-specific scores (None when not applicable)
51
+ correct: bool | None = None # factual/multihop exact-match; honesty=declined
52
+ route_match: bool | None = None
53
+ citation_valid: bool | None = None # narrative
54
+ faithfulness: float | None = None
55
+ relevance: float | None = None
56
+ context_precision: float | None = None
57
+ declined: bool | None = None # honesty
58
+ notes: list[str] = field(default_factory=list)
59
+ error: str | None = None
60
+
61
+
62
+ def _grounding_context(final: dict) -> str:
63
+ """Reconstruct what the agent was grounded on: retrieved chunks + every tool
64
+ result it saw. This is the context the faithfulness judge scores against."""
65
+ parts: list[str] = []
66
+ chunks = final.get("chunks") or []
67
+ if chunks:
68
+ parts.append(format_chunks_for_prompt(chunks))
69
+ for step in final.get("trace", []):
70
+ if step.get("type") == "tool_call":
71
+ d = step["data"]
72
+ parts.append(f"[tool:{d['tool']}] args={d.get('args')} -> {d.get('result')}")
73
+ return "\n\n".join(parts) if parts else "(no context β€” model answered without retrieval or tools)"
74
+
75
+
76
+ def _chunk_listing(chunks: list) -> str:
77
+ lines = []
78
+ for i, c in enumerate(chunks, 1):
79
+ head = f"[{i}] {c.ticker} FY{c.fiscal_year} {getattr(c, 'section_title', '') or ''}".strip()
80
+ lines.append(f"{head}: {c.text[:200]}")
81
+ return "\n".join(lines)
82
+
83
+
84
+ def run_case(case: ds.EvalCase, *, judge: bool = True) -> CaseResult:
85
+ from finrag.agent.graph import run_agent # lazy: heavy import
86
+
87
+ r = CaseResult(id=case.id, tier=case.tier, question=case.question,
88
+ route_expected=case.route_expected)
89
+ t0 = time.perf_counter()
90
+ try:
91
+ final = run_agent(case.question)
92
+ except Exception as e: # a backend hiccup shouldn't abort the whole run
93
+ r.error = f"{type(e).__name__}: {e}"
94
+ r.latency_ms = int((time.perf_counter() - t0) * 1000)
95
+ return r
96
+ r.latency_ms = int((time.perf_counter() - t0) * 1000)
97
+
98
+ r.answer = final.get("answer", "") or ""
99
+ r.route_actual = final.get("route")
100
+ r.route_match = (case.route_expected is None) or (r.route_actual == case.route_expected)
101
+ chunks = final.get("chunks") or []
102
+ r.n_chunks = len(chunks)
103
+ r.n_tool_calls = sum(1 for s in final.get("trace", []) if s.get("type") == "tool_call")
104
+ usage = final.get("usage") or {}
105
+ r.input_tokens = usage.get("input_tokens", 0)
106
+ r.output_tokens = usage.get("output_tokens", 0)
107
+
108
+ # ── deterministic tier checks ──
109
+ if case.tier == ds.HONESTY:
110
+ declined = mt.looks_like_refusal(r.answer)
111
+ if judge and not declined: # keyword backstop missed β€” ask the judge
112
+ verdict = mt.judge_refusal(case.question, r.answer)
113
+ declined = bool(verdict.get("declined", False))
114
+ if verdict.get("reason"):
115
+ r.notes.append(f"refusal-judge: {verdict['reason']}")
116
+ r.declined = declined
117
+ r.correct = declined
118
+ elif case.gt is not None:
119
+ try:
120
+ expected = case.gt()
121
+ r.correct = mt.number_hit(expected, case.gt_kind, r.answer, case.tol)
122
+ r.notes.append(f"expectedβ‰ˆ{expected:.4g} ({case.gt_kind})")
123
+ except Exception as e:
124
+ r.notes.append(f"gt-error: {e}")
125
+ elif case.expect_substring is not None:
126
+ r.correct = case.expect_substring.lower() in r.answer.lower()
127
+ r.notes.append(f"expect substring '{case.expect_substring}'")
128
+
129
+ if case.tier == ds.NARRATIVE:
130
+ valid, bad = mt.citation_validity(r.answer, r.n_chunks)
131
+ r.citation_valid = valid
132
+ if bad:
133
+ r.notes.append(f"out-of-range citations: {bad}")
134
+
135
+ # ── LLM-judge layer ──
136
+ if judge and case.tier != ds.HONESTY:
137
+ context = _grounding_context(final)
138
+ f = mt.judge_faithfulness(context, r.answer)
139
+ r.faithfulness = f.get("faithfulness")
140
+ if f.get("unsupported_claims"):
141
+ r.notes.append(f"unsupported: {f['unsupported_claims']}")
142
+ rel = mt.judge_relevance(case.question, r.answer)
143
+ r.relevance = rel.get("relevance")
144
+ if chunks and case.tier in (ds.NARRATIVE, ds.MULTIHOP):
145
+ p = mt.judge_precision(case.question, _chunk_listing(chunks))
146
+ idxs = p.get("relevant_indices") or []
147
+ if r.n_chunks:
148
+ r.context_precision = len([i for i in idxs if 1 <= i <= r.n_chunks]) / r.n_chunks
149
+ # Honesty tier is scored only by `declined`/accuracy β€” judging "relevance" of
150
+ # a correct refusal is misleading (the judge penalizes not answering), so we
151
+ # skip it.
152
+
153
+ return r
154
+
155
+
156
+ # ── Aggregation ────────────────────────────────────────────────────────────
157
+ def _mean(vals: list[float | None]) -> float | None:
158
+ nums = [v for v in vals if v is not None]
159
+ return sum(nums) / len(nums) if nums else None
160
+
161
+
162
+ def _rate(vals: list[bool | None]) -> float | None:
163
+ bs = [v for v in vals if v is not None]
164
+ return sum(1 for v in bs if v) / len(bs) if bs else None
165
+
166
+
167
+ def aggregate(results: list[CaseResult]) -> dict:
168
+ tiers: dict[str, list[CaseResult]] = {}
169
+ for r in results:
170
+ tiers.setdefault(r.tier, []).append(r)
171
+
172
+ def block(rs: list[CaseResult]) -> dict:
173
+ return {
174
+ "n": len(rs),
175
+ "errors": sum(1 for r in rs if r.error),
176
+ "accuracy": _rate([r.correct for r in rs]),
177
+ "route_match": _rate([r.route_match for r in rs]),
178
+ "citation_valid": _rate([r.citation_valid for r in rs]),
179
+ "faithfulness": _mean([r.faithfulness for r in rs]),
180
+ "relevance": _mean([r.relevance for r in rs]),
181
+ "context_precision": _mean([r.context_precision for r in rs]),
182
+ "avg_latency_ms": int(_mean([float(r.latency_ms) for r in rs]) or 0),
183
+ }
184
+
185
+ return {
186
+ "overall": block(results),
187
+ "by_tier": {tier: block(rs) for tier, rs in sorted(tiers.items())},
188
+ }
189
+
190
+
191
+ def _fmt(v) -> str:
192
+ if v is None:
193
+ return " – "
194
+ if isinstance(v, float):
195
+ return f"{v:5.2f}"
196
+ return str(v)
197
+
198
+
199
+ def print_report(provider: str, results: list[CaseResult], agg: dict) -> None:
200
+ in_tok = sum(r.input_tokens for r in results)
201
+ out_tok = sum(r.output_tokens for r in results)
202
+ ri, ro = _RATES.get(provider, (0, 0))
203
+ cost = in_tok / 1e6 * ri + out_tok / 1e6 * ro
204
+
205
+ print(f"\n{'='*78}\n EVAL REPORT β€” provider={provider} ({len(results)} cases)\n{'='*78}")
206
+ print(f" {'case':5} {'tier':9} {'ok':3} {'route':5} {'cite':4} {'faith':6} {'rel':6} {'prec':6} {'ms':6}")
207
+ print(f" {'-'*72}")
208
+ for r in results:
209
+ ok = "ERR" if r.error else ("βœ“" if r.correct else ("Β·" if r.correct is None else "βœ—"))
210
+ print(f" {r.id:5} {r.tier:9} {ok:3} "
211
+ f"{('βœ“' if r.route_match else 'βœ—') if r.route_match is not None else '–':5} "
212
+ f"{('βœ“' if r.citation_valid else 'βœ—') if r.citation_valid is not None else '–':4} "
213
+ f"{_fmt(r.faithfulness):6} {_fmt(r.relevance):6} {_fmt(r.context_precision):6} {r.latency_ms:6}")
214
+
215
+ print(f"\n {'TIER':10} {'n':3} {'acc':6} {'route':6} {'cite':6} {'faith':6} {'rel':6} {'prec':6}")
216
+ print(f" {'-'*60}")
217
+ for tier, b in agg["by_tier"].items():
218
+ print(f" {tier:10} {b['n']:3} {_fmt(b['accuracy']):6} {_fmt(b['route_match']):6} "
219
+ f"{_fmt(b['citation_valid']):6} {_fmt(b['faithfulness']):6} "
220
+ f"{_fmt(b['relevance']):6} {_fmt(b['context_precision']):6}")
221
+ o = agg["overall"]
222
+ print(f" {'-'*60}")
223
+ print(f" {'OVERALL':10} {o['n']:3} {_fmt(o['accuracy']):6} {_fmt(o['route_match']):6} "
224
+ f"{_fmt(o['citation_valid']):6} {_fmt(o['faithfulness']):6} "
225
+ f"{_fmt(o['relevance']):6} {_fmt(o['context_precision']):6}")
226
+ print(f"\n errors={o['errors']} agent-tokens in={in_tok} out={out_tok} "
227
+ f"approx-cost=${cost:.3f} (agent node only; excludes plan call)")
228
+ # tokens/sec is the edge-relevant throughput number for the local model.
229
+ # Derived from wall-clock latency (no separate decode timer), so it's a
230
+ # coarse end-to-end rate, not a pure-decode tok/s.
231
+ total_ms = sum(r.latency_ms for r in results)
232
+ if provider == "local" and total_ms:
233
+ print(f" local throughputβ‰ˆ{out_tok / (total_ms / 1000):.1f} output tok/s "
234
+ f"(end-to-end, over {total_ms/1000:.1f}s wall)\n")
235
+ else:
236
+ print()
237
+
238
+
239
+ def main() -> int:
240
+ ap = argparse.ArgumentParser(description="FinRAG Day-4 eval harness")
241
+ ap.add_argument("--provider", default=None, help="anthropic | gemini (default: current setting)")
242
+ ap.add_argument("--tier", default=None, choices=[ds.FACTUAL, ds.NARRATIVE, ds.MULTIHOP, ds.HONESTY])
243
+ ap.add_argument("--smoke", action="store_true", help="one case per tier (cheap pipeline check / A/B subset)")
244
+ ap.add_argument("--limit", type=int, default=None)
245
+ ap.add_argument("--no-judge", action="store_true", help="skip LLM-judge metrics (deterministic only)")
246
+ ap.add_argument("--out", default=None, help="results JSON path")
247
+ args = ap.parse_args()
248
+
249
+ if args.provider:
250
+ settings.llm_provider = args.provider # dispatcher reads this live
251
+ provider = (settings.llm_provider or "anthropic").lower()
252
+
253
+ if args.smoke:
254
+ cases = ds.one_per_tier()
255
+ else:
256
+ cases = ds.cases_for(args.tier)
257
+ if args.limit:
258
+ cases = cases[: args.limit]
259
+
260
+ print(f"Running {len(cases)} cases Β· provider={provider} Β· judge={not args.no_judge}")
261
+ results: list[CaseResult] = []
262
+ for i, case in enumerate(cases, 1):
263
+ print(f" [{i:2}/{len(cases)}] {case.id} {case.tier:9} {case.question[:54]}…", flush=True)
264
+ r = run_case(case, judge=not args.no_judge)
265
+ results.append(r)
266
+ tag = "ERR" if r.error else ("βœ“" if r.correct else ("Β·" if r.correct is None else "βœ—"))
267
+ print(f" β†’ {tag} route={r.route_actual} {r.latency_ms}ms", flush=True)
268
+ if r.error:
269
+ print(f" ! {r.error}")
270
+
271
+ agg = aggregate(results)
272
+ print_report(provider, results, agg)
273
+
274
+ out = Path(args.out) if args.out else OUT_DIR / f"eval_results_{provider}.json"
275
+ out.parent.mkdir(parents=True, exist_ok=True)
276
+ payload = {"provider": provider, "aggregate": agg, "cases": [asdict(r) for r in results]}
277
+ out.write_text(json.dumps(payload, indent=2))
278
+ print(f" wrote {out}")
279
+ return 0
280
+
281
+
282
+ if __name__ == "__main__":
283
+ sys.exit(main())
backend/src/finrag/eval/metrics.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Scoring for the Day-4 eval β€” two layers.
2
+
3
+ Deterministic (no LLM, cheap, objective):
4
+ - number matching: does the answer state the ground-truth figure? Robust to
5
+ formatting ($96.995 billion / $96,995 million / 96.995B / -2.8%).
6
+ - citation validity: are the [N] anchors in the answer real (1..n_chunks)?
7
+ - refusal detection: keyword backstop for the honesty tier.
8
+
9
+ LLM-judge (Claude, always β€” even when the *system under test* is Gemini, so the
10
+ grader is held constant across the A/B; self-preference bias is named in the
11
+ writeup):
12
+ - faithfulness: is every claim grounded in the provided context/tool results?
13
+ - answer relevance: does the answer actually address the question?
14
+ - context precision: what fraction of retrieved chunks are relevant?
15
+ - refusal judgement: did the agent appropriately decline?
16
+
17
+ The judge is deliberately pinned to claude.generate_text (not the provider
18
+ dispatcher) so the eval never grades an answer with the same model that wrote it
19
+ when that model is the variable under test.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import re
26
+
27
+ from finrag.llm.claude import generate_text as _claude_generate
28
+
29
+ # ── Deterministic: number extraction ─────────────────────────────────────
30
+ _SCALE = {
31
+ "trillion": 1e12, "tn": 1e12,
32
+ "billion": 1e9, "bn": 1e9,
33
+ "million": 1e6, "mn": 1e6,
34
+ "thousand": 1e3,
35
+ }
36
+ # $-led or scale-worded magnitudes. Plain bare integers (e.g. "2023") are NOT
37
+ # matched as currency β€” they must carry a $ or a scale word to count.
38
+ _CURRENCY_RE = re.compile(
39
+ r"\$\s?(-?\d[\d,]*(?:\.\d+)?)\s*(trillion|billion|million|thousand|tn|bn|mn)?"
40
+ r"|(-?\d[\d,]*(?:\.\d+)?)\s+(trillion|billion|million|thousand)",
41
+ re.IGNORECASE,
42
+ )
43
+ _PERCENT_RE = re.compile(r"(-?\d+(?:\.\d+)?)\s*(?:%|percent)", re.IGNORECASE)
44
+ _DECIMAL_RE = re.compile(r"-?\d+\.\d+")
45
+
46
+
47
+ def extract_currency(text: str) -> list[float]:
48
+ """All monetary magnitudes in `text`, normalized to absolute dollars."""
49
+ out: list[float] = []
50
+ for m in _CURRENCY_RE.finditer(text):
51
+ num = m.group(1) or m.group(3)
52
+ scale = (m.group(2) or m.group(4) or "").lower()
53
+ if num is None:
54
+ continue
55
+ try:
56
+ val = float(num.replace(",", ""))
57
+ except ValueError:
58
+ continue
59
+ out.append(val * _SCALE.get(scale, 1.0))
60
+ return out
61
+
62
+
63
+ def extract_percentages(text: str) -> list[float]:
64
+ return [float(m.group(1)) for m in _PERCENT_RE.finditer(text)]
65
+
66
+
67
+ def extract_decimals(text: str) -> list[float]:
68
+ return [float(m.group(0)) for m in _DECIMAL_RE.finditer(text)]
69
+
70
+
71
+ def number_hit(expected: float, kind: str, text: str, tol: float) -> bool:
72
+ """Does `text` state `expected`? `tol` is a relative tolerance for currency
73
+ and per-share; for percent it's the absolute pp floor, OR'd with a 5%
74
+ relative band so both '44%' and '44.13%' match a 44.13 ground truth."""
75
+ if kind == "currency":
76
+ band = abs(expected) * tol
77
+ return any(abs(c - expected) <= band for c in extract_currency(text))
78
+ if kind == "per_share":
79
+ band = max(abs(expected) * tol, 0.01)
80
+ cands = extract_decimals(text) + extract_currency(text)
81
+ return any(abs(c - expected) <= band for c in cands)
82
+ if kind == "percent":
83
+ band = max(tol, abs(expected) * 0.05)
84
+ return any(abs(abs(c) - abs(expected)) <= band for c in extract_percentages(text))
85
+ raise ValueError(f"unknown gt_kind {kind!r}")
86
+
87
+
88
+ # ── Deterministic: citations ──────────────────────────────────────────────
89
+ _CITE_RE = re.compile(r"\[(\d+)\]")
90
+
91
+
92
+ def citations(text: str) -> list[int]:
93
+ return [int(m.group(1)) for m in _CITE_RE.finditer(text)]
94
+
95
+
96
+ def citation_validity(text: str, n_chunks: int) -> tuple[bool, list[int]]:
97
+ """True if the answer cites at least one chunk and every [N] is in range.
98
+ Returns (valid, out_of_range_anchors)."""
99
+ cites = citations(text)
100
+ if not cites:
101
+ return False, []
102
+ bad = [c for c in cites if c < 1 or c > n_chunks]
103
+ return (not bad), bad
104
+
105
+
106
+ # ── Deterministic: refusal backstop ───────────────────────────────────────
107
+ _REFUSAL_MARKERS = (
108
+ "not in the corpus", "not in the provided", "isn't in the corpus",
109
+ "is not in the corpus", "not available in", "cannot answer", "can't answer",
110
+ "do not have", "don't have", "not included in", "no information",
111
+ "not contain", "outside the", "not part of the corpus", "unable to",
112
+ "not found in", "not covered",
113
+ )
114
+
115
+
116
+ def looks_like_refusal(text: str) -> bool:
117
+ low = text.lower()
118
+ return any(m in low for m in _REFUSAL_MARKERS)
119
+
120
+
121
+ # ── LLM judge ─────────────────────────────────────────────────��───────────
122
+ def _judge_json(system: str, user: str, *, max_tokens: int = 400) -> dict:
123
+ """Call the Claude judge and parse its JSON verdict. Tolerant of code-fence
124
+ wrapping; returns {} on parse failure so a flaky judge degrades to a missing
125
+ score rather than crashing the whole run."""
126
+ raw = _claude_generate(system, user, max_output_tokens=max_tokens, temperature=0.0)
127
+ m = re.search(r"\{.*\}", raw, re.DOTALL)
128
+ if not m:
129
+ return {}
130
+ try:
131
+ return json.loads(m.group(0))
132
+ except json.JSONDecodeError:
133
+ return {}
134
+
135
+
136
+ _FAITH_SYS = (
137
+ "You are a meticulous evaluator of financial question-answering. You are given "
138
+ "CONTEXT (retrieved filing excerpts and tool results) and an ANSWER. Judge "
139
+ "ONLY whether each factual claim in the ANSWER is supported by the CONTEXT β€” "
140
+ "not whether it is true in general. Ignore citation markers like [1]. "
141
+ 'Reply with ONLY JSON: {"faithfulness": <float 0..1>, '
142
+ '"unsupported_claims": [<short strings>], "reason": "<one sentence>"}. '
143
+ "faithfulness is the fraction of claims supported by the context."
144
+ )
145
+
146
+ _RELEVANCE_SYS = (
147
+ "You evaluate whether an ANSWER addresses the QUESTION asked (relevance, not "
148
+ "correctness). A direct, on-topic answer scores 1.0; a partial or evasive one "
149
+ "lower; an off-topic one near 0. A correct refusal for an unanswerable "
150
+ "question is fully relevant (1.0). "
151
+ 'Reply with ONLY JSON: {"relevance": <float 0..1>, "reason": "<one sentence>"}.'
152
+ )
153
+
154
+ _PRECISION_SYS = (
155
+ "You judge retrieval quality. Given a QUESTION and a numbered list of "
156
+ "retrieved CHUNKS (by index), decide which chunks are relevant to answering "
157
+ "the question. "
158
+ 'Reply with ONLY JSON: {"relevant_indices": [<int>], "reason": "<one sentence>"}.'
159
+ )
160
+
161
+ _REFUSAL_SYS = (
162
+ "A QA system should DECLINE this question because the answer is NOT in its "
163
+ "corpus (the company, year, or forward-looking figure is unavailable). Given "
164
+ "the ANSWER, decide whether it appropriately declined / stated it cannot "
165
+ "answer from the available filings, rather than fabricating a figure. "
166
+ 'Reply with ONLY JSON: {"declined": <true|false>, "reason": "<one sentence>"}.'
167
+ )
168
+
169
+
170
+ def judge_faithfulness(context: str, answer: str) -> dict:
171
+ return _judge_json(_FAITH_SYS, f"CONTEXT:\n{context}\n\nANSWER:\n{answer}")
172
+
173
+
174
+ def judge_relevance(question: str, answer: str) -> dict:
175
+ return _judge_json(_RELEVANCE_SYS, f"QUESTION:\n{question}\n\nANSWER:\n{answer}")
176
+
177
+
178
+ def judge_precision(question: str, chunks_listing: str) -> dict:
179
+ return _judge_json(_PRECISION_SYS, f"QUESTION:\n{question}\n\nCHUNKS:\n{chunks_listing}")
180
+
181
+
182
+ def judge_refusal(question: str, answer: str) -> dict:
183
+ return _judge_json(_REFUSAL_SYS, f"QUESTION:\n{question}\n\nANSWER:\n{answer}")
backend/src/finrag/eval/smoke.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """End-to-end smoke test for retrieval.
2
+
3
+ Runs a fixed canary set of questions through `retrieval.vector.search` and
4
+ flags structural breakage (zero results, filter violations, suspiciously
5
+ low scores). Does NOT grade answer quality β€” that's Day 4's eval harness.
6
+
7
+ Usage:
8
+ uv run python -m finrag.eval.smoke
9
+
10
+ Exits with code 0 on full pass, 1 if any case fails. Useful in CI later.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import sys
17
+ import time
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from pydantic import BaseModel
22
+
23
+ from finrag.retrieval.rerank import rerank_search
24
+ from finrag.retrieval.vector import RetrievedChunk
25
+
26
+ # parse.py-style root resolution
27
+ REPO_ROOT = Path(__file__).resolve().parents[4]
28
+ RESULTS_PATH = REPO_ROOT / "data" / "smoke_results.json"
29
+
30
+ # Smoke now hits rerank_search β€” same path as /query. Score is Cohere
31
+ # Rerank v3's relevance_score in [0, 1]. A relevant top-1 is typically
32
+ # > 0.5 for well-formed queries. < 0.1 means the reranker thinks none of
33
+ # the candidates actually answer the query β€” usually a sign of retrieval
34
+ # upstream returning irrelevant candidates.
35
+ SCORE_FLOOR = 0.10
36
+
37
+
38
+ # ── Canary cases ─────────────────────────────────────────────────────────
39
+ class Case(BaseModel):
40
+ name: str
41
+ question: str
42
+ top_k: int = 5
43
+ ticker: str | None = None
44
+ fiscal_year: int | None = None
45
+ chunk_type: str | None = None
46
+ # Optional soft expectation β€” we don't fail the case if this doesn't
47
+ # match, but we surface it in output so you eyeball whether the right
48
+ # company is showing up in the top results.
49
+ expect_ticker_in_top: str | None = None
50
+
51
+
52
+ CASES: list[Case] = [
53
+ Case(
54
+ name="01_aapl_services_revenue",
55
+ question="How did Apple's services revenue change in 2023?",
56
+ expect_ticker_in_top="AAPL",
57
+ ),
58
+ Case(
59
+ name="02_tsla_rnd_spend",
60
+ question="How much did Tesla spend on research and development?",
61
+ expect_ticker_in_top="TSLA",
62
+ ),
63
+ Case(
64
+ name="03_supply_chain_risks",
65
+ question="What are the risks related to supply chain disruptions?",
66
+ ),
67
+ Case(
68
+ name="04_jpm_net_interest_income",
69
+ question="What was JPMorgan's net interest income?",
70
+ expect_ticker_in_top="JPM",
71
+ ),
72
+ Case(
73
+ name="05_aapl_2024_filter",
74
+ question="total revenue",
75
+ ticker="AAPL",
76
+ fiscal_year=2024,
77
+ expect_ticker_in_top="AAPL",
78
+ ),
79
+ Case(
80
+ name="06_tables_only_filter",
81
+ question="income statement",
82
+ chunk_type="table",
83
+ ),
84
+ Case(
85
+ name="07_tsla_deliveries_multi_year",
86
+ question="How have Tesla vehicle deliveries changed year over year?",
87
+ expect_ticker_in_top="TSLA",
88
+ top_k=8,
89
+ ),
90
+ Case(
91
+ name="08_cross_company_ai",
92
+ question="risks related to artificial intelligence",
93
+ top_k=8,
94
+ ),
95
+ ]
96
+
97
+
98
+ # ── Execution ─────────────────────────────────────────────────────────────
99
+ class CaseResult(BaseModel):
100
+ name: str
101
+ passed: bool
102
+ reasons: list[str]
103
+ n_chunks: int
104
+ top_score: float | None
105
+ top_ticker: str | None
106
+ top_fiscal_year: int | None
107
+ duration_ms: int
108
+
109
+
110
+ def _check_case(case: Case, chunks: list[RetrievedChunk]) -> CaseResult:
111
+ """Apply pass/fail rules to a case's results."""
112
+ reasons: list[str] = []
113
+
114
+ if not chunks:
115
+ reasons.append("returned zero chunks")
116
+ return CaseResult(
117
+ name=case.name,
118
+ passed=False,
119
+ reasons=reasons,
120
+ n_chunks=0,
121
+ top_score=None,
122
+ top_ticker=None,
123
+ top_fiscal_year=None,
124
+ duration_ms=0, # filled in by caller
125
+ )
126
+
127
+ top = chunks[0]
128
+
129
+ # Score floor β€” catches embedder mismatches (e.g. wrong input_type).
130
+ if top.score < SCORE_FLOOR:
131
+ reasons.append(f"top score {top.score:.3f} below floor {SCORE_FLOOR}")
132
+
133
+ # Filter compliance β€” every returned chunk must satisfy any filter we set.
134
+ if case.ticker:
135
+ bad = [c for c in chunks if c.ticker != case.ticker]
136
+ if bad:
137
+ reasons.append(
138
+ f"ticker filter violated: {len(bad)}/{len(chunks)} chunks have "
139
+ f"ticker != {case.ticker}"
140
+ )
141
+ if case.fiscal_year:
142
+ bad = [c for c in chunks if c.fiscal_year != case.fiscal_year]
143
+ if bad:
144
+ reasons.append(
145
+ f"fiscal_year filter violated: {len(bad)}/{len(chunks)} chunks have "
146
+ f"fiscal_year != {case.fiscal_year}"
147
+ )
148
+ if case.chunk_type:
149
+ bad = [c for c in chunks if c.chunk_type != case.chunk_type]
150
+ if bad:
151
+ reasons.append(
152
+ f"chunk_type filter violated: {len(bad)}/{len(chunks)} chunks have "
153
+ f"chunk_type != {case.chunk_type}"
154
+ )
155
+
156
+ # Soft expectation β€” log only, don't fail
157
+ if case.expect_ticker_in_top:
158
+ top_tickers = {c.ticker for c in chunks[:3]}
159
+ if case.expect_ticker_in_top not in top_tickers:
160
+ reasons.append(
161
+ f"⚠ soft: expected {case.expect_ticker_in_top} in top-3 tickers, "
162
+ f"got {sorted(top_tickers)}"
163
+ )
164
+
165
+ # Only hard failures (filter violations, empty results, score floor)
166
+ # count toward `passed`. Soft warnings start with "⚠".
167
+ hard_failures = [r for r in reasons if not r.startswith("⚠")]
168
+
169
+ return CaseResult(
170
+ name=case.name,
171
+ passed=not hard_failures,
172
+ reasons=reasons,
173
+ n_chunks=len(chunks),
174
+ top_score=top.score,
175
+ top_ticker=top.ticker,
176
+ top_fiscal_year=top.fiscal_year,
177
+ duration_ms=0,
178
+ )
179
+
180
+
181
+ def run_case(case: Case) -> CaseResult:
182
+ t0 = time.perf_counter()
183
+ chunks = rerank_search(
184
+ question=case.question,
185
+ top_k=case.top_k,
186
+ ticker=case.ticker,
187
+ fiscal_year=case.fiscal_year,
188
+ chunk_type=case.chunk_type,
189
+ )
190
+ elapsed_ms = int((time.perf_counter() - t0) * 1000)
191
+ result = _check_case(case, chunks)
192
+ result.duration_ms = elapsed_ms
193
+ return result
194
+
195
+
196
+ # ── CLI ───────────────────────────────────────────────────────────────────
197
+ def main() -> int:
198
+ print(f"Running {len(CASES)} smoke cases against retrieval.search\n")
199
+
200
+ results: list[CaseResult] = []
201
+ for case in CASES:
202
+ r = run_case(case)
203
+ results.append(r)
204
+
205
+ status = "PASS" if r.passed else "FAIL"
206
+ top = f"{r.top_ticker} FY{r.top_fiscal_year} @ {r.top_score:.3f}" if r.top_score else "β€”"
207
+ print(f" [{status}] {r.name:35s} n={r.n_chunks} top={top:24s} {r.duration_ms}ms")
208
+ for reason in r.reasons:
209
+ print(f" {reason}")
210
+
211
+ n_pass = sum(1 for r in results if r.passed)
212
+ n_total = len(results)
213
+ print(f"\n{n_pass}/{n_total} cases passed.")
214
+
215
+ # Persist results for future diffing / regression tracking
216
+ payload: dict[str, Any] = {
217
+ "summary": {
218
+ "passed": n_pass,
219
+ "total": n_total,
220
+ "all_passed": n_pass == n_total,
221
+ },
222
+ "cases": [r.model_dump() for r in results],
223
+ }
224
+ RESULTS_PATH.parent.mkdir(parents=True, exist_ok=True)
225
+ RESULTS_PATH.write_text(json.dumps(payload, indent=2))
226
+ print(f"Wrote {RESULTS_PATH}")
227
+
228
+ return 0 if n_pass == n_total else 1
229
+
230
+
231
+ if __name__ == "__main__":
232
+ sys.exit(main())
backend/src/finrag/guardrails.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cost/abuse guardrails for the public deploy.
2
+
3
+ The agent endpoints call a funded Anthropic key, so a public URL without limits
4
+ is an open invitation to burn money. Two independent defenses, both enforced as
5
+ a FastAPI dependency on the paid endpoints (/answer, /agent, /agent/stream):
6
+
7
+ 1. Per-IP sliding-window rate limit β†’ stops one client hammering the agent.
8
+ 2. Global daily question cap β†’ the hard cost circuit-breaker; once the
9
+ day's paid questions hit the cap, every further request 429s until midnight
10
+ UTC, no matter who sends it. This is the line between "a few dollars" and
11
+ "a surprise bill."
12
+
13
+ Both are in-process (a dict + a counter under one lock). That's correct for the
14
+ single-instance Fly deploy we target; a multi-instance/multi-worker setup would
15
+ need a shared store (Redis) instead β€” called out in docs/deploy.md, not hidden.
16
+
17
+ Retrieval-only /query is intentionally NOT guarded here: it makes no LLM call
18
+ (Cohere rerank only, ~$0.002) so it isn't the cost risk the cap exists for.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import threading
24
+ import time
25
+ from collections import defaultdict, deque
26
+ from datetime import datetime, timezone
27
+
28
+ from fastapi import HTTPException, Request
29
+
30
+ from finrag.config import settings
31
+
32
+ _WINDOW_SECONDS = 60.0
33
+
34
+ _lock = threading.Lock()
35
+ # ip -> timestamps of accepted paid requests within the last _WINDOW_SECONDS
36
+ _hits: dict[str, deque[float]] = defaultdict(deque)
37
+ # (utc_date_str, count) β€” the global daily paid-question tally
38
+ _day: str = ""
39
+ _day_count: int = 0
40
+
41
+
42
+ def _client_ip(request: Request) -> str:
43
+ """Best-effort real client IP. Behind Fly's proxy the socket peer is the
44
+ proxy, so prefer the forwarded headers Fly/Vercel set; fall back to the
45
+ socket. X-Forwarded-For is a chain β€” the original client is the first hop."""
46
+ xff = request.headers.get("x-forwarded-for")
47
+ if xff:
48
+ return xff.split(",")[0].strip()
49
+ fly = request.headers.get("fly-client-ip")
50
+ if fly:
51
+ return fly.strip()
52
+ return request.client.host if request.client else "unknown"
53
+
54
+
55
+ def _today_utc() -> str:
56
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d")
57
+
58
+
59
+ def _seconds_until_utc_midnight() -> int:
60
+ now = datetime.now(timezone.utc)
61
+ tomorrow = now.date().toordinal() + 1
62
+ midnight = datetime.fromordinal(tomorrow).replace(tzinfo=timezone.utc)
63
+ return max(1, int((midnight - now).total_seconds()))
64
+
65
+
66
+ def enforce(request: Request) -> None:
67
+ """FastAPI dependency: raise 429 if this paid request breaches either the
68
+ per-IP rate limit or the global daily cap; otherwise record it and return.
69
+
70
+ Recording happens here (not after the call) so an in-flight burst can't slip
71
+ past the cap β€” we count on admission, which is the conservative choice for a
72
+ cost ceiling."""
73
+ global _day, _day_count
74
+ now = time.monotonic()
75
+ ip = _client_ip(request)
76
+
77
+ with _lock:
78
+ # ── global daily cap (reset on UTC date rollover) ──
79
+ today = _today_utc()
80
+ if today != _day:
81
+ _day, _day_count = today, 0
82
+ if _day_count >= settings.daily_question_cap:
83
+ raise HTTPException(
84
+ status_code=429,
85
+ detail=(
86
+ "Daily demo limit reached. This is a cost-capped public "
87
+ "demo; it resets at 00:00 UTC. Run it locally for unlimited "
88
+ "use β€” see the repo README."
89
+ ),
90
+ headers={"Retry-After": str(_seconds_until_utc_midnight())},
91
+ )
92
+
93
+ # ── per-IP sliding window ──
94
+ bucket = _hits[ip]
95
+ cutoff = now - _WINDOW_SECONDS
96
+ while bucket and bucket[0] < cutoff:
97
+ bucket.popleft()
98
+ if len(bucket) >= settings.rate_limit_per_min:
99
+ retry = max(1, int(_WINDOW_SECONDS - (now - bucket[0])))
100
+ raise HTTPException(
101
+ status_code=429,
102
+ detail=(
103
+ f"Rate limit: max {settings.rate_limit_per_min} questions/min. "
104
+ f"Try again in ~{retry}s."
105
+ ),
106
+ headers={"Retry-After": str(retry)},
107
+ )
108
+
109
+ # Admitted β€” record against both limiters.
110
+ bucket.append(now)
111
+ _day_count += 1
112
+
113
+ # Opportunistic cleanup so idle IPs don't accumulate forever.
114
+ if len(_hits) > 4096:
115
+ for k in [k for k, v in _hits.items() if not v]:
116
+ del _hits[k]
117
+
118
+
119
+ def cap_status() -> dict[str, int | str]:
120
+ """Snapshot for /health β€” lets the frontend show 'N questions left today'
121
+ and makes the cap observable without reading logs."""
122
+ with _lock:
123
+ today = _today_utc()
124
+ used = _day_count if today == _day else 0
125
+ cap = settings.daily_question_cap
126
+ return {
127
+ "daily_cap": cap,
128
+ "used_today": used,
129
+ "remaining_today": max(0, cap - used),
130
+ "resets_at": "00:00 UTC",
131
+ }
backend/src/finrag/ingestion/__init__.py ADDED
File without changes
backend/src/finrag/ingestion/edgar.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import time
4
+ from collections.abc import Iterator
5
+ from datetime import date
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+ from pydantic import BaseModel
10
+
11
+ # ── Paths ─────────────────────────────────────────────────────────────────
12
+ # ← why: same trick as config.py β€” resolve to repo root so the script works
13
+ # regardless of CWD. edgar.py β†’ ingestion/ β†’ finrag/ β†’ src/ β†’ backend/ β†’ ROOT
14
+ REPO_ROOT = Path(__file__).resolve().parents[4]
15
+ DATA_DIR = REPO_ROOT / "data" / "raw"
16
+
17
+ # ── HTTP client ───────────────────────────────────────────────────────────
18
+ # ← why: SEC requires a real contactable email. Fall back to yours so the
19
+ # script never accidentally runs with a placeholder; override via env in CI.
20
+ USER_AGENT = os.getenv("SEC_USER_AGENT", "Aryan Sharma aryan250403@gmail.com")
21
+ HTTP_HEADERS = {
22
+ "User-Agent": USER_AGENT,
23
+ "Accept-Encoding": "gzip, deflate",
24
+ }
25
+ client = httpx.Client(headers=HTTP_HEADERS, timeout=30.0)
26
+
27
+ # ── Corpus config ─────────────────────────────────────────────────────────
28
+ TARGET_TICKERS = ["AAPL", "TSLA", "JPM"]
29
+ TARGET_YEARS = [2022, 2023, 2024]
30
+ REQUEST_SLEEP_SECONDS = 0.2 # ← why: SEC allows 10 req/s; 5 req/s is polite.
31
+
32
+
33
+ # ── Models ────────────────────────────────────────────────────────────────
34
+ class Filing(BaseModel):
35
+ # ← why: every field here exists because some downstream stage needs it.
36
+ # ticker/cik/accession give three independent identifiers.
37
+ # filing_date vs period_of_report are *different things* and both matter.
38
+ # sec_url is for citation rendering in the UI later.
39
+ ticker: str
40
+ company_name: str
41
+ cik: str
42
+ form: str
43
+ filing_date: date
44
+ period_of_report: date
45
+ fiscal_year: int
46
+ accession_number: str
47
+ accession_clean: str
48
+ primary_document: str
49
+ sec_url: str
50
+ out_dir: str # relative to DATA_DIR
51
+
52
+ def metadata_dict(self) -> dict:
53
+ # ← why: pydantic's model_dump() emits dates as date objects; JSON
54
+ # can't serialize those, so coerce to ISO strings explicitly.
55
+ d = self.model_dump()
56
+ d["filing_date"] = self.filing_date.isoformat()
57
+ d["period_of_report"] = self.period_of_report.isoformat()
58
+ return d
59
+
60
+
61
+ # ── EDGAR API calls ───────────────────────────────────────────────────────
62
+ def resolve_cik_map() -> dict[str, tuple[str, str]]:
63
+ """Return {ticker: (cik_str, company_name)} for the whole market.
64
+
65
+ ← why: SEC publishes the entire tickerβ†’CIK map as one JSON file. Fetching
66
+ it once and resolving locally is cheaper than per-ticker lookups.
67
+ """
68
+ url = "https://www.sec.gov/files/company_tickers.json"
69
+ response = client.get(url)
70
+ response.raise_for_status()
71
+ data = response.json()
72
+ return {
73
+ entry["ticker"].upper(): (str(entry["cik_str"]), entry["title"])
74
+ for entry in data.values()
75
+ }
76
+
77
+
78
+ def _iter_submission_pages(padded_cik: str) -> Iterator[dict]:
79
+ """Yield each 'recent'-shaped page from the submissions endpoint.
80
+
81
+ First yields filings.recent, then walks filings.files for older pages.
82
+ High-volume filers (banks, frequent 8-K issuers) overflow `recent` and
83
+ require pulling the paginated files to find 10-Ks more than ~1-2 yrs old.
84
+ """
85
+ url = f"https://data.sec.gov/submissions/CIK{padded_cik}.json"
86
+ response = client.get(url)
87
+ response.raise_for_status()
88
+ data = response.json()
89
+
90
+ yield data["filings"]["recent"]
91
+
92
+ for file_entry in data["filings"].get("files", []):
93
+ time.sleep(REQUEST_SLEEP_SECONDS)
94
+ file_url = f"https://data.sec.gov/submissions/{file_entry['name']}"
95
+ resp = client.get(file_url)
96
+ resp.raise_for_status()
97
+ yield resp.json()
98
+
99
+
100
+ def list_10k_filings(
101
+ ticker: str, cik: str, company_name: str, target_years: list[int]
102
+ ) -> list[Filing]:
103
+ """Hit EDGAR's submissions endpoint and pull 10-Ks for the target years.
104
+
105
+ Walks paginated submission pages until every target year is found or
106
+ pages are exhausted.
107
+ """
108
+ padded_cik = cik.zfill(10)
109
+ remaining_years = set(target_years)
110
+ filings: list[Filing] = []
111
+
112
+ for page in _iter_submission_pages(padded_cik):
113
+ if not remaining_years:
114
+ break
115
+ for acc_num, form, filing_date_str, period_str, prim_doc in zip(
116
+ page["accessionNumber"],
117
+ page["form"],
118
+ page["filingDate"],
119
+ page["reportDate"],
120
+ page["primaryDocument"],
121
+ ):
122
+ if form != "10-K":
123
+ continue
124
+ if not period_str:
125
+ continue
126
+ period_of_report = date.fromisoformat(period_str)
127
+ fiscal_year = period_of_report.year
128
+ if fiscal_year not in remaining_years:
129
+ continue
130
+
131
+ accession_clean = acc_num.replace("-", "")
132
+ sec_url = (
133
+ f"https://www.sec.gov/Archives/edgar/data/{int(cik)}/"
134
+ f"{accession_clean}/{prim_doc}"
135
+ )
136
+ filings.append(
137
+ Filing(
138
+ ticker=ticker,
139
+ company_name=company_name,
140
+ cik=cik,
141
+ form=form,
142
+ filing_date=date.fromisoformat(filing_date_str),
143
+ period_of_report=period_of_report,
144
+ fiscal_year=fiscal_year,
145
+ accession_number=acc_num,
146
+ accession_clean=accession_clean,
147
+ primary_document=prim_doc,
148
+ sec_url=sec_url,
149
+ out_dir=f"{ticker}_{fiscal_year}",
150
+ )
151
+ )
152
+ remaining_years.discard(fiscal_year)
153
+ return filings
154
+
155
+
156
+ def download_filing(filing: Filing, base_out_dir: Path) -> None:
157
+ """Download the primary 10-K HTML and write metadata.json alongside it."""
158
+ dir_path = base_out_dir / filing.out_dir
159
+ dir_path.mkdir(parents=True, exist_ok=True)
160
+
161
+ htm_path = dir_path / "filing.htm"
162
+ meta_path = dir_path / "metadata.json"
163
+
164
+ # ← why: idempotency. Existence of *both* artifacts means a clean prior run.
165
+ # If only one exists, we redo to repair partial state.
166
+ if htm_path.exists() and meta_path.exists():
167
+ print(f" ↳ skip {filing.ticker} FY{filing.fiscal_year} (already on disk)")
168
+ return
169
+
170
+ print(f" ↳ fetch {filing.ticker} FY{filing.fiscal_year} β†’ {filing.sec_url}")
171
+ response = client.get(filing.sec_url)
172
+ response.raise_for_status()
173
+ htm_path.write_bytes(response.content)
174
+ meta_path.write_text(json.dumps(filing.metadata_dict(), indent=2))
175
+
176
+
177
+ def write_manifest(filings: list[Filing], base_out_dir: Path) -> None:
178
+ """Top-level index of everything we've downloaded.
179
+
180
+ ← why: lets later stages (parser, embedder) load the corpus by reading one
181
+ file instead of walking the tree.
182
+ """
183
+ manifest_path = base_out_dir / "manifest.json"
184
+ manifest = {
185
+ "filings": [
186
+ {
187
+ "ticker": f.ticker,
188
+ "fiscal_year": f.fiscal_year,
189
+ "period_of_report": f.period_of_report.isoformat(),
190
+ "path": f.out_dir,
191
+ }
192
+ for f in filings
193
+ ]
194
+ }
195
+ manifest_path.write_text(json.dumps(manifest, indent=2))
196
+
197
+
198
+ # ── CLI entrypoint ────────────────────────────────────────────────────────
199
+ def main() -> None:
200
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
201
+ print(f"Downloading to: {DATA_DIR}")
202
+ print(f"User-Agent: {USER_AGENT}\n")
203
+
204
+ ticker_map = resolve_cik_map()
205
+ all_filings: list[Filing] = []
206
+
207
+ for ticker in TARGET_TICKERS:
208
+ if ticker not in ticker_map:
209
+ raise ValueError(f"Ticker {ticker} not found in SEC ticker map")
210
+ cik, company_name = ticker_map[ticker]
211
+ print(f"[{ticker}] {company_name} (CIK {cik})")
212
+
213
+ filings = list_10k_filings(ticker, cik, company_name, TARGET_YEARS)
214
+ if len(filings) < len(TARGET_YEARS):
215
+ missing = set(TARGET_YEARS) - {f.fiscal_year for f in filings}
216
+ print(f" ⚠ missing fiscal years: {sorted(missing)}")
217
+
218
+ for filing in filings:
219
+ download_filing(filing, DATA_DIR)
220
+ time.sleep(REQUEST_SLEEP_SECONDS)
221
+ all_filings.extend(filings)
222
+ print()
223
+
224
+ write_manifest(all_filings, DATA_DIR)
225
+ print(f"Done. {len(all_filings)} filings on disk.")
226
+
227
+
228
+ if __name__ == "__main__":
229
+ main()
backend/src/finrag/ingestion/embed.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Embed parsed chunks with Cohere embed-v3 and upsert into Qdrant.
2
+
3
+ Pipeline:
4
+ data/processed/*.jsonl (Chunk objects)
5
+ ──▢ Cohere embed-v3 (input_type=search_document)
6
+ ──▢ Qdrant upsert into `finrag_chunks` collection
7
+
8
+ Idempotent: re-running overwrites existing points by ID (deterministic hash).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import time
15
+ from collections.abc import Iterable, Iterator
16
+ from pathlib import Path
17
+ from typing import TypeVar
18
+
19
+ import cohere
20
+ from cohere.errors import TooManyRequestsError
21
+ from qdrant_client import QdrantClient
22
+ from qdrant_client.models import Distance, PointStruct, VectorParams
23
+
24
+ from finrag.config import settings
25
+ from finrag.ingestion.parse import PROCESSED_DIR, Chunk
26
+
27
+ # ── Constants ─────────────────────────────────────────────────────────────
28
+ # Cohere v3 is the asymmetric retrieval model. 1024-dim output, English.
29
+ # Use `embed-multilingual-v3.0` if you want cross-language support; same dim.
30
+ COHERE_MODEL = "embed-english-v3.0"
31
+ EMBED_DIM = 1024
32
+ COLLECTION_NAME = "finrag_chunks"
33
+
34
+ # Cohere caps `texts=[...]` at 96 per request. Larger requests get 400'd.
35
+ COHERE_BATCH_SIZE = 96
36
+ # Qdrant upsert is fine with much larger batches; 256 keeps memory bounded
37
+ # while amortizing the HTTP overhead.
38
+ QDRANT_BATCH_SIZE = 256
39
+
40
+ # Pacing for Cohere calls. Trial keys have two limits:
41
+ # - 100 calls/min (call-based)
42
+ # - 100k tokens/min (token-based) ← this is the binding constraint at our chunk size
43
+ # At ~300 tokens/chunk Γ— 96 chunks/batch β‰ˆ 29k tokens/batch. Steady-state, that
44
+ # means we can do roughly 3 batches per rolling minute. 20s base sleep gives us
45
+ # margin; bursts above that get caught by the retry handler below.
46
+ COHERE_SLEEP_SECONDS = 20.0
47
+ COHERE_RETRY_INITIAL_BACKOFF_SECONDS = 30.0
48
+ COHERE_MAX_RETRIES = 5
49
+
50
+
51
+ # ── Helpers ───────────────────────────────────────────────────────────────
52
+ T = TypeVar("T")
53
+
54
+
55
+ def _batched(seq: Iterable[T], n: int) -> Iterator[list[T]]:
56
+ """Yield lists of size `n` from `seq`. Last batch may be shorter."""
57
+ buf: list[T] = []
58
+ for x in seq:
59
+ buf.append(x)
60
+ if len(buf) == n:
61
+ yield buf
62
+ buf = []
63
+ if buf:
64
+ yield buf
65
+
66
+
67
+ def _read_all_chunks(processed_dir: Path) -> list[Chunk]:
68
+ chunks: list[Chunk] = []
69
+ for jsonl in sorted(processed_dir.glob("*.jsonl")):
70
+ for line in jsonl.read_text(encoding="utf-8").splitlines():
71
+ if line.strip():
72
+ chunks.append(Chunk.model_validate_json(line))
73
+ return chunks
74
+
75
+
76
+ def _ensure_collection(qdrant: QdrantClient, name: str, dim: int) -> None:
77
+ """Create the collection if it doesn't exist. No-op otherwise.
78
+
79
+ Note: we *don't* recreate the collection on schema mismatch β€” that would
80
+ destroy data. If you change EMBED_DIM, delete the collection manually
81
+ via the dashboard or `qdrant.delete_collection(name)`.
82
+ """
83
+ existing = {c.name for c in qdrant.get_collections().collections}
84
+ if name in existing:
85
+ return
86
+ print(f"Creating Qdrant collection '{name}' (dim={dim}, distance=cosine)")
87
+ qdrant.create_collection(
88
+ collection_name=name,
89
+ vectors_config=VectorParams(size=dim, distance=Distance.COSINE),
90
+ )
91
+
92
+
93
+ def _embed_batch(co: cohere.ClientV2, texts: list[str]) -> list[list[float]]:
94
+ """Embed a batch of texts with the document-side encoder.
95
+
96
+ The `input_type="search_document"` here is the asymmetric-retrieval flag.
97
+ The matching `search_query` lives in the /query endpoint (Decision 7).
98
+ Mixing the two destroys retrieval quality silently.
99
+ """
100
+ response = co.embed(
101
+ texts=texts,
102
+ model=COHERE_MODEL,
103
+ input_type="search_document",
104
+ embedding_types=["float"],
105
+ )
106
+ # V2 response shape: response.embeddings.float_ is list[list[float]]
107
+ return response.embeddings.float_
108
+
109
+
110
+ def _embed_batch_with_retry(
111
+ co: cohere.ClientV2, texts: list[str]
112
+ ) -> list[list[float]]:
113
+ """Wrap _embed_batch with exponential backoff on 429 rate-limit errors.
114
+
115
+ Trial keys can hit either the call limit or the token limit; both surface
116
+ as TooManyRequestsError. We don't bother distinguishing β€” the right
117
+ response is the same: wait, then retry.
118
+ """
119
+ backoff = COHERE_RETRY_INITIAL_BACKOFF_SECONDS
120
+ for attempt in range(1, COHERE_MAX_RETRIES + 1):
121
+ try:
122
+ return _embed_batch(co, texts)
123
+ except TooManyRequestsError:
124
+ if attempt == COHERE_MAX_RETRIES:
125
+ raise
126
+ print(
127
+ f" ⚠ rate-limited; sleeping {backoff:.0f}s "
128
+ f"(retry {attempt}/{COHERE_MAX_RETRIES - 1})"
129
+ )
130
+ time.sleep(backoff)
131
+ backoff *= 2
132
+ # Unreachable β€” loop either returns or raises.
133
+ raise RuntimeError("retry loop exited without resolving")
134
+
135
+
136
+ def _chunk_to_point(chunk: Chunk, vector: list[float]) -> PointStruct:
137
+ """Convert a Chunk + its vector into a Qdrant point.
138
+
139
+ Point ID: convert our 16-hex chunk_id to uint64. Qdrant only accepts
140
+ UUID or unsigned int IDs, not arbitrary strings. The hex→int conversion
141
+ preserves determinism (same chunk β†’ same ID across runs).
142
+ """
143
+ point_id = int(chunk.chunk_id, 16)
144
+ return PointStruct(
145
+ id=point_id,
146
+ vector=vector,
147
+ payload=chunk.model_dump(),
148
+ )
149
+
150
+
151
+ # ── Core ──────────────────────────────────────────────────────────────────
152
+ def embed_and_upsert(chunks: list[Chunk]) -> int:
153
+ """Embed all chunks and upsert into Qdrant. Returns count of points written."""
154
+ co = cohere.ClientV2(api_key=settings.cohere_api_key)
155
+ qdrant = QdrantClient(url=settings.qdrant_url, api_key=settings.qdrant_api_key)
156
+ _ensure_collection(qdrant, COLLECTION_NAME, EMBED_DIM)
157
+
158
+ qdrant_buf: list[PointStruct] = []
159
+ total_written = 0
160
+
161
+ for batch_idx, batch in enumerate(_batched(chunks, COHERE_BATCH_SIZE), start=1):
162
+ texts = [c.text for c in batch]
163
+ print(f" ↳ embed batch {batch_idx} ({len(batch)} chunks)")
164
+ vectors = _embed_batch_with_retry(co, texts)
165
+
166
+ if len(vectors) != len(batch):
167
+ # Cohere should always return one vector per input; fail loud if not.
168
+ raise RuntimeError(
169
+ f"Cohere returned {len(vectors)} vectors for {len(batch)} inputs"
170
+ )
171
+
172
+ for chunk, vector in zip(batch, vectors):
173
+ qdrant_buf.append(_chunk_to_point(chunk, vector))
174
+
175
+ if len(qdrant_buf) >= QDRANT_BATCH_SIZE:
176
+ qdrant.upsert(collection_name=COLLECTION_NAME, points=qdrant_buf)
177
+ total_written += len(qdrant_buf)
178
+ print(f" ↳ upsert {len(qdrant_buf):4d} points (total {total_written})")
179
+ qdrant_buf = []
180
+
181
+ time.sleep(COHERE_SLEEP_SECONDS)
182
+
183
+ # Flush remaining points (final partial batch)
184
+ if qdrant_buf:
185
+ qdrant.upsert(collection_name=COLLECTION_NAME, points=qdrant_buf)
186
+ total_written += len(qdrant_buf)
187
+ print(f" ↳ upsert {len(qdrant_buf):4d} points (total {total_written}, tail)")
188
+
189
+ return total_written
190
+
191
+
192
+ # ── CLI ───────────────────────────────────────────────────────────────────
193
+ def main() -> None:
194
+ chunks = _read_all_chunks(PROCESSED_DIR)
195
+ print(f"Loaded {len(chunks)} chunks from {PROCESSED_DIR}\n")
196
+ if not chunks:
197
+ print("No chunks found. Run `finrag.ingestion.parse` first.")
198
+ return
199
+
200
+ written = embed_and_upsert(chunks)
201
+
202
+ # Verify final state via Qdrant's own count
203
+ qdrant = QdrantClient(url=settings.qdrant_url, api_key=settings.qdrant_api_key)
204
+ info = qdrant.get_collection(COLLECTION_NAME)
205
+ print(
206
+ f"\nDone. {written} points written this run. "
207
+ f"Collection '{COLLECTION_NAME}' contains {info.points_count} total."
208
+ )
209
+
210
+
211
+ if __name__ == "__main__":
212
+ main()
backend/src/finrag/ingestion/facts.py ADDED
@@ -0,0 +1,448 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fetch XBRL financial facts from SEC's Company Facts API and load into DuckDB.
2
+
3
+ Why XBRL and not HTML table parsing:
4
+ SEC requires every filer to tag financial facts against the GAAP taxonomy.
5
+ The Company Facts API serves these as JSON β€” already structured, already
6
+ cross-filer-comparable. Parsing HTML tables ourselves would re-invent
7
+ this work and produce worse data.
8
+
9
+ The result is a normalized `financial_facts` table that the agent (Day 3)
10
+ will query via a `sql_query` tool β€” the structured side of the
11
+ "structured + unstructured" fusion that's the project's headline
12
+ differentiator.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from datetime import date
19
+ from functools import lru_cache
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import duckdb
24
+ import httpx
25
+
26
+ from finrag.ingestion.edgar import (
27
+ HTTP_HEADERS,
28
+ REQUEST_SLEEP_SECONDS,
29
+ TARGET_TICKERS,
30
+ TARGET_YEARS,
31
+ )
32
+
33
+ # ── Paths ─────────────────────────────────────────────────────────────────
34
+ # facts.py β†’ ingestion/ β†’ finrag/ β†’ src/ β†’ backend/ β†’ ROOT
35
+ REPO_ROOT = Path(__file__).resolve().parents[4]
36
+ DUCKDB_PATH = REPO_ROOT / "data" / "duckdb" / "finrag.duckdb"
37
+
38
+ # ── HTTP ──────────────────────────────────────────────────────────────────
39
+ # Same User-Agent contract as the EDGAR scraper β€” SEC enforces it on this
40
+ # endpoint too. Reuse the headers module-level constant.
41
+ client = httpx.Client(headers=HTTP_HEADERS, timeout=30.0)
42
+
43
+
44
+ # ── Canonical line-item map ───────────────────────────────────────────────
45
+ # Each entry maps our canonical key (what the agent will query on) to one
46
+ # or more GAAP concept names. Multiple concepts per key absorb the
47
+ # inconsistency in how filers tag the same financial idea.
48
+ #
49
+ # Curated list β€” these are the high-value items for finance Q&A. Adding
50
+ # more is one line of code each; restraint is the design goal so the agent
51
+ # sees a tight, well-documented schema rather than an XBRL data dump.
52
+ CONCEPT_MAP: dict[str, list[str]] = {
53
+ # Income statement
54
+ "revenue": [
55
+ "Revenues",
56
+ "RevenueFromContractWithCustomerExcludingAssessedTax",
57
+ "SalesRevenueNet",
58
+ ],
59
+ "cost_of_revenue": [
60
+ "CostOfRevenue",
61
+ "CostOfGoodsAndServicesSold",
62
+ "CostOfGoodsSold",
63
+ ],
64
+ "gross_profit": ["GrossProfit"],
65
+ "rd_expense": ["ResearchAndDevelopmentExpense"],
66
+ "sga_expense": [
67
+ "SellingGeneralAndAdministrativeExpense",
68
+ "GeneralAndAdministrativeExpense",
69
+ ],
70
+ "operating_income": ["OperatingIncomeLoss"],
71
+ "net_income": ["NetIncomeLoss"],
72
+ # Balance sheet
73
+ "total_assets": ["Assets"],
74
+ "total_liabilities": ["Liabilities"],
75
+ "stockholders_equity": ["StockholdersEquity"],
76
+ "cash": ["CashAndCashEquivalentsAtCarryingValue", "Cash"],
77
+ "long_term_debt": ["LongTermDebt", "LongTermDebtNoncurrent"],
78
+ # Cash flow + capital
79
+ "capex": ["PaymentsToAcquirePropertyPlantAndEquipment"],
80
+ "operating_cash_flow": ["NetCashProvidedByUsedInOperatingActivities"],
81
+ # Per-share
82
+ "eps_basic": ["EarningsPerShareBasic"],
83
+ "eps_diluted": ["EarningsPerShareDiluted"],
84
+ # Banking-specific (for JPM)
85
+ "net_interest_income": ["InterestIncomeOperating", "InterestAndDividendIncomeOperating"],
86
+ }
87
+
88
+ # Reverse lookup: gaap concept β†’ canonical key
89
+ GAAP_TO_LINE_ITEM: dict[str, str] = {
90
+ concept: key for key, concepts in CONCEPT_MAP.items() for concept in concepts
91
+ }
92
+
93
+
94
+ # ── Schema ────────────────────────────────────────────────────────────────
95
+ CREATE_TABLE_SQL = """
96
+ CREATE TABLE IF NOT EXISTS financial_facts (
97
+ ticker TEXT NOT NULL,
98
+ company_name TEXT NOT NULL,
99
+ cik TEXT NOT NULL,
100
+ fiscal_year INTEGER NOT NULL,
101
+ fiscal_period TEXT NOT NULL,
102
+ period_end_date DATE NOT NULL,
103
+ line_item TEXT NOT NULL,
104
+ gaap_concept TEXT NOT NULL,
105
+ value DOUBLE NOT NULL,
106
+ unit TEXT NOT NULL,
107
+ accession_number TEXT,
108
+ form TEXT,
109
+ filed_date DATE,
110
+ PRIMARY KEY (ticker, fiscal_year, fiscal_period, line_item, gaap_concept)
111
+ );
112
+ """
113
+
114
+
115
+ # ── SEC ticker β†’ (cik, company_name) ──────────────────────────────────────
116
+ def _resolve_ticker_map() -> dict[str, tuple[str, str]]:
117
+ """Same lookup as edgar.py β€” duplicated here so this module stands alone."""
118
+ url = "https://www.sec.gov/files/company_tickers.json"
119
+ response = client.get(url)
120
+ response.raise_for_status()
121
+ data = response.json()
122
+ return {
123
+ entry["ticker"].upper(): (str(entry["cik_str"]), entry["title"])
124
+ for entry in data.values()
125
+ }
126
+
127
+
128
+ # ── XBRL fetch ────────────────────────────────────────────────────────────
129
+ def fetch_company_facts(cik: str) -> dict[str, Any]:
130
+ """Hit SEC's Company Facts API. One call returns everything XBRL-tagged
131
+ for that filer across their entire filing history."""
132
+ padded_cik = cik.zfill(10)
133
+ url = f"https://data.sec.gov/api/xbrl/companyfacts/CIK{padded_cik}.json"
134
+ response = client.get(url)
135
+ response.raise_for_status()
136
+ return response.json()
137
+
138
+
139
+ # ── Extract β†’ flat rows ──────────────────────────────────────────────────
140
+ def extract_facts(
141
+ company_data: dict[str, Any],
142
+ ticker: str,
143
+ target_years: list[int],
144
+ ) -> list[dict[str, Any]]:
145
+ """Walk the XBRL JSON, keep annual (fp='FY') facts for concepts in
146
+ CONCEPT_MAP whose period falls in target_years, return flat row dicts.
147
+
148
+ Period identity is the XBRL `end` date β€” see the long comment below for why
149
+ the `fy`/`fp` fields are NOT a reliable period key (they describe the filing,
150
+ not the value, and conflate a filing's 3 comparative years).
151
+
152
+ Deduplication: SEC's XBRL feed contains every restatement and amendment, so
153
+ the same (period, concept, unit) appears across successive filings. We keep
154
+ the *most recently filed* value per logical fact (later restatements
155
+ supersede earlier ones), then collapse the unit dimension to match the
156
+ `financial_facts` PK (which has no unit column).
157
+ """
158
+ cik = str(company_data.get("cik", ""))
159
+ company_name = company_data.get("entityName", "")
160
+ facts_root = company_data.get("facts", {}).get("us-gaap", {})
161
+
162
+ # Period identity comes from the XBRL `end` date, NOT the `fy`/`fp` fields.
163
+ # `fy`/`fp` denote the fiscal year/period of the *filing* a datapoint was
164
+ # reported in; a single 10-K carries 3 comparative years that all share its
165
+ # `fy`. Keying on `fy` (as this code used to) collapsed those three periods
166
+ # into one PK and stored the wrong year's value β€” every annual figure ended
167
+ # up off by ~2 years. The `end` date is the true period.
168
+ #
169
+ # We keep only annual facts (fp == 'FY'): for all three target filers the
170
+ # fiscal year equals the calendar year of the period-end date (AAPL ends in
171
+ # late September, TSLA/JPM on Dec 31), so fiscal_year = period_end.year is
172
+ # exact. Quarterly facts are intentionally dropped β€” Apple's fiscal quarters
173
+ # straddle calendar years (Q1 FY2023 ends Dec 2022), so end.year would not
174
+ # equal fiscal_year for them. The dict key keeps `unit` (e.g. EPS in
175
+ # 'USD/shares' vs 'USD'); the unit dimension is collapsed below.
176
+ best: dict[tuple[str, date, str, str, str], dict[str, Any]] = {}
177
+
178
+ for gaap_concept, fact_block in facts_root.items():
179
+ line_item = GAAP_TO_LINE_ITEM.get(gaap_concept)
180
+ if line_item is None:
181
+ continue
182
+
183
+ for unit, datapoints in fact_block.get("units", {}).items():
184
+ for dp in datapoints:
185
+ if dp.get("fp") != "FY": # annual figures only
186
+ continue
187
+
188
+ end_str = dp.get("end")
189
+ if not end_str:
190
+ continue
191
+ period_end = date.fromisoformat(end_str)
192
+ fiscal_year = period_end.year
193
+ if fiscal_year not in target_years:
194
+ continue
195
+
196
+ filed_str = dp.get("filed")
197
+ filed_date_val = date.fromisoformat(filed_str) if filed_str else None
198
+
199
+ # Dedup on the true period; keep the most-recently-filed value
200
+ # (a later filing's restatement supersedes the original).
201
+ key = (ticker, period_end, line_item, gaap_concept, unit)
202
+ existing = best.get(key)
203
+ if existing is not None:
204
+ existing_filed = existing["filed_date"]
205
+ if existing_filed and filed_date_val and filed_date_val <= existing_filed:
206
+ continue
207
+ if existing_filed and not filed_date_val:
208
+ continue
209
+
210
+ best[key] = {
211
+ "ticker": ticker,
212
+ "company_name": company_name,
213
+ "cik": cik,
214
+ "fiscal_year": fiscal_year,
215
+ "fiscal_period": "FY",
216
+ "period_end_date": period_end,
217
+ "line_item": line_item,
218
+ "gaap_concept": gaap_concept,
219
+ "value": float(dp["val"]),
220
+ "unit": unit,
221
+ "accession_number": dp.get("accn"),
222
+ "form": dp.get("form"),
223
+ "filed_date": filed_date_val,
224
+ }
225
+
226
+ # Now collapse the unit dimension. The PK in financial_facts is
227
+ # (ticker, fiscal_year, fiscal_period, line_item, gaap_concept) β€” no unit.
228
+ # Pick the most recently filed unit; ties broken by lexicographic unit name
229
+ # (stable). fiscal_period is always 'FY' here.
230
+ by_pk: dict[tuple[str, int, str, str, str], dict[str, Any]] = {}
231
+ for row in best.values():
232
+ pk = (row["ticker"], row["fiscal_year"], "FY", row["line_item"], row["gaap_concept"])
233
+ existing = by_pk.get(pk)
234
+ if existing is None:
235
+ by_pk[pk] = row
236
+ continue
237
+ ex_filed = existing["filed_date"]
238
+ new_filed = row["filed_date"]
239
+ if new_filed and (not ex_filed or new_filed > ex_filed):
240
+ by_pk[pk] = row
241
+ elif new_filed == ex_filed and row["unit"] < existing["unit"]:
242
+ by_pk[pk] = row
243
+
244
+ return list(by_pk.values())
245
+
246
+
247
+ # ── DuckDB write ──────────────────────────────────────────────────────────
248
+ def _ensure_db() -> duckdb.DuckDBPyConnection:
249
+ DUCKDB_PATH.parent.mkdir(parents=True, exist_ok=True)
250
+ con = duckdb.connect(str(DUCKDB_PATH))
251
+ con.execute(CREATE_TABLE_SQL)
252
+ return con
253
+
254
+
255
+ def upsert_facts(con: duckdb.DuckDBPyConnection, rows: list[dict[str, Any]]) -> int:
256
+ """Idempotent insert: rows with matching primary key get replaced.
257
+
258
+ DuckDB doesn't have native INSERT ON CONFLICT REPLACE for all cases, so
259
+ we DELETE-then-INSERT inside a transaction. At our scale (~hundreds of
260
+ rows per company) this is fast and bulletproof.
261
+ """
262
+ if not rows:
263
+ return 0
264
+ con.begin()
265
+ try:
266
+ for r in rows:
267
+ con.execute(
268
+ """
269
+ DELETE FROM financial_facts
270
+ WHERE ticker = ?
271
+ AND fiscal_year = ?
272
+ AND fiscal_period = ?
273
+ AND line_item = ?
274
+ AND gaap_concept = ?
275
+ """,
276
+ [
277
+ r["ticker"],
278
+ r["fiscal_year"],
279
+ r["fiscal_period"],
280
+ r["line_item"],
281
+ r["gaap_concept"],
282
+ ],
283
+ )
284
+ con.executemany(
285
+ """
286
+ INSERT INTO financial_facts (
287
+ ticker, company_name, cik, fiscal_year, fiscal_period,
288
+ period_end_date, line_item, gaap_concept, value, unit,
289
+ accession_number, form, filed_date
290
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
291
+ """,
292
+ [
293
+ (
294
+ r["ticker"],
295
+ r["company_name"],
296
+ r["cik"],
297
+ r["fiscal_year"],
298
+ r["fiscal_period"],
299
+ r["period_end_date"],
300
+ r["line_item"],
301
+ r["gaap_concept"],
302
+ r["value"],
303
+ r["unit"],
304
+ r["accession_number"],
305
+ r["form"],
306
+ r["filed_date"],
307
+ )
308
+ for r in rows
309
+ ],
310
+ )
311
+ con.commit()
312
+ except Exception:
313
+ con.rollback()
314
+ raise
315
+ return len(rows)
316
+
317
+
318
+ # ── Query interface (for sanity + future agent tool) ─────────────────────
319
+ def query(sql: str, params: list[Any] | None = None) -> list[dict[str, Any]]:
320
+ """Read-only DuckDB query helper. Returns rows as list of dicts.
321
+
322
+ Day 3's agent tool will be a thin wrapper around this with safety
323
+ guards (READ ONLY connection, LIMIT enforcement, query timeout).
324
+ """
325
+ con = duckdb.connect(str(DUCKDB_PATH), read_only=True)
326
+ try:
327
+ result = con.execute(sql, params or []).fetchall()
328
+ cols = [d[0] for d in con.description]
329
+ return [dict(zip(cols, row)) for row in result]
330
+ finally:
331
+ con.close()
332
+
333
+
334
+ # ── Corpus introspection (for grounding the agent) ────────────────────────
335
+ # The agent must never invent companies. These read the *actual* loaded data so
336
+ # the known-universe it's told about can't drift from what's queryable. Cached β€”
337
+ # the corpus is static within a process.
338
+ @lru_cache(maxsize=1)
339
+ def corpus_companies() -> list[tuple[str, str]]:
340
+ """Distinct (ticker, company_name) present in financial_facts, ticker-sorted."""
341
+ rows = query(
342
+ "SELECT DISTINCT ticker, company_name FROM financial_facts ORDER BY ticker"
343
+ )
344
+ return [(r["ticker"], r["company_name"]) for r in rows]
345
+
346
+
347
+ @lru_cache(maxsize=1)
348
+ def corpus_years() -> tuple[int | None, int | None]:
349
+ """(min, max) fiscal_year in the corpus, or (None, None) if empty."""
350
+ rows = query("SELECT MIN(fiscal_year) AS lo, MAX(fiscal_year) AS hi FROM financial_facts")
351
+ if rows and rows[0]["lo"] is not None:
352
+ return int(rows[0]["lo"]), int(rows[0]["hi"])
353
+ return None, None
354
+
355
+
356
+ # ── CLI ───────────────────────────────────────────────────────────────────
357
+ def main() -> None:
358
+ print(f"DuckDB at: {DUCKDB_PATH}")
359
+ con = _ensure_db()
360
+
361
+ import time
362
+
363
+ ticker_map = _resolve_ticker_map()
364
+ total_rows = 0
365
+
366
+ for ticker in TARGET_TICKERS:
367
+ cik, _ = ticker_map[ticker]
368
+ print(f"\n[{ticker}] fetching XBRL company facts (CIK {cik})…")
369
+ try:
370
+ data = fetch_company_facts(cik)
371
+ except httpx.HTTPStatusError as e:
372
+ print(f" βœ— {e}")
373
+ continue
374
+
375
+ rows = extract_facts(data, ticker, TARGET_YEARS)
376
+ written = upsert_facts(con, rows)
377
+ total_rows += written
378
+ print(f" ↳ {written} fact rows written")
379
+ time.sleep(REQUEST_SLEEP_SECONDS)
380
+
381
+ con.close()
382
+ print(f"\nDone. {total_rows} total fact rows.\n")
383
+
384
+ # Sanity-check queries β€” actual demonstrations of the modal-split value.
385
+ print("=" * 60)
386
+ print("Sample queries")
387
+ print("=" * 60)
388
+
389
+ examples = [
390
+ (
391
+ "Apple's revenue, FY 2022–2024",
392
+ """
393
+ SELECT fiscal_year, fiscal_period, value/1e9 AS billions_usd, gaap_concept
394
+ FROM financial_facts
395
+ WHERE ticker = 'AAPL'
396
+ AND line_item = 'revenue'
397
+ AND fiscal_period = 'FY'
398
+ AND unit = 'USD'
399
+ ORDER BY fiscal_year;
400
+ """,
401
+ ),
402
+ (
403
+ "Tesla R&D spend, FY 2022–2024",
404
+ """
405
+ SELECT fiscal_year, value/1e9 AS billions_usd
406
+ FROM financial_facts
407
+ WHERE ticker = 'TSLA'
408
+ AND line_item = 'rd_expense'
409
+ AND fiscal_period = 'FY'
410
+ ORDER BY fiscal_year;
411
+ """,
412
+ ),
413
+ (
414
+ "Operating margin by company, FY 2023",
415
+ """
416
+ WITH p AS (
417
+ SELECT ticker, line_item, SUM(value) AS v
418
+ FROM financial_facts
419
+ WHERE fiscal_year = 2023 AND fiscal_period = 'FY'
420
+ AND line_item IN ('revenue', 'operating_income')
421
+ AND unit = 'USD'
422
+ GROUP BY ticker, line_item
423
+ )
424
+ SELECT
425
+ ticker,
426
+ MAX(CASE WHEN line_item='revenue' THEN v END)/1e9 AS revenue_b,
427
+ MAX(CASE WHEN line_item='operating_income' THEN v END)/1e9 AS op_inc_b,
428
+ MAX(CASE WHEN line_item='operating_income' THEN v END) * 1.0
429
+ / NULLIF(MAX(CASE WHEN line_item='revenue' THEN v END), 0) AS op_margin
430
+ FROM p
431
+ GROUP BY ticker
432
+ ORDER BY op_margin DESC NULLS LAST;
433
+ """,
434
+ ),
435
+ ]
436
+
437
+ for title, sql in examples:
438
+ print(f"\n β–Έ {title}")
439
+ rows = query(sql)
440
+ if not rows:
441
+ print(" (no rows)")
442
+ continue
443
+ for r in rows:
444
+ print(" ", {k: (round(v, 3) if isinstance(v, float) else v) for k, v in r.items()})
445
+
446
+
447
+ if __name__ == "__main__":
448
+ main()
backend/src/finrag/ingestion/parse.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Parse downloaded 10-K HTML files into chunks ready for embedding.
2
+
3
+ Pipeline:
4
+ data/raw/{TICKER}_{FY}/filing.htm + metadata.json
5
+ ──▢ partition_html ──▢ list[Element]
6
+ ──▢ segment by Title, split tables off
7
+ ──▢ chunk_by_title within each section (narrative)
8
+ ──▢ emit table elements as their own chunks
9
+ data/processed/{TICKER}_{FY}.jsonl (one Chunk per line)
10
+ data/processed/manifest.json
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from pydantic import BaseModel
21
+ # NOTE: `unstructured` is imported lazily inside parse_filing() (see below), not
22
+ # at module top. It's a heavy, ingestion-only dependency (torch/transformers via
23
+ # unstructured[pdf]) kept OUT of the runtime image β€” but this module is imported
24
+ # transitively at runtime for its constants (PROCESSED_DIR, Chunk), so a top-level
25
+ # import would crash the server. Install it for ingestion with `uv sync --group ingestion`.
26
+
27
+ # ── Paths ─────────────────────────────────────────────────────────────────
28
+ # parse.py β†’ ingestion/ β†’ finrag/ β†’ src/ β†’ backend/ β†’ ROOT
29
+ REPO_ROOT = Path(__file__).resolve().parents[4]
30
+ RAW_DIR = REPO_ROOT / "data" / "raw"
31
+ PROCESSED_DIR = REPO_ROOT / "data" / "processed"
32
+
33
+ # ── Chunking knobs (see Decision 5 discussion for reasoning) ──────────────
34
+ # max: hard ceiling. Stays comfortably under Cohere embed-v3's ~512 token limit.
35
+ # new_after: soft target β€” lets natural breaks happen before reaching max.
36
+ # combine_under: absorbs tiny stub chunks (lone section titles, footers).
37
+ # overlap: only kicks in when a section is split mid-section due to length.
38
+ MAX_CHARACTERS = 1500
39
+ NEW_AFTER_N_CHARS = 1200
40
+ COMBINE_TEXT_UNDER_N_CHARS = 200
41
+ OVERLAP = 150
42
+
43
+
44
+ # ── Models ────────────────────────────────────────────────────────────────
45
+ class Chunk(BaseModel):
46
+ """One retrievable unit: either a narrative passage or a single table.
47
+
48
+ Every field after `chunk_type` is provenance β€” copied from the filing's
49
+ metadata.json so the chunk is self-contained when it lands in Qdrant.
50
+ The retriever can filter on any of these fields without joining back.
51
+ """
52
+
53
+ chunk_id: str # deterministic SHA-256 hash, 16 hex chars
54
+ text: str # the actual content to embed
55
+ chunk_type: str # "narrative" | "table"
56
+ section_title: str | None
57
+ section_path: list[str]
58
+ # Filing provenance
59
+ ticker: str
60
+ company_name: str
61
+ fiscal_year: int
62
+ period_of_report: str # ISO date string
63
+ accession_number: str
64
+ sec_url: str
65
+ # Position within document β€” currently a monotonic ordinal per filing.
66
+ # On Day 2+ this becomes the anchor for citation-viewer highlighting.
67
+ element_index: int
68
+
69
+
70
+ # ── Helpers ───────────────────────────────────────────────────────────────
71
+ def _hash_chunk(ticker: str, fiscal_year: int, position: int, text: str) -> str:
72
+ """Stable ID. Including `text` means changing chunking params produces
73
+ new IDs rather than silently overwriting old vectors with new content."""
74
+ h = hashlib.sha256()
75
+ h.update(f"{ticker}|{fiscal_year}|{position}|".encode())
76
+ h.update(text.encode())
77
+ return h.hexdigest()[:16]
78
+
79
+
80
+ def _load_filing_metadata(filing_dir: Path) -> dict[str, Any]:
81
+ return json.loads((filing_dir / "metadata.json").read_text())
82
+
83
+
84
+ def _element_category(el: Any) -> str:
85
+ # Unstructured elements expose `.category`; fall back to class name.
86
+ return getattr(el, "category", type(el).__name__)
87
+
88
+
89
+ def _table_text(el: Any) -> str:
90
+ """Prefer the HTML representation β€” preserves rows/columns for the
91
+ embedder. Falls back to flattened text if HTML isn't available."""
92
+ md = getattr(el, "metadata", None)
93
+ if md is not None:
94
+ html = getattr(md, "text_as_html", None)
95
+ if html:
96
+ return html
97
+ return el.text
98
+
99
+
100
+ # ── Core ──────────────────────────────────────────────────────────────────
101
+ def parse_filing(filing_dir: Path) -> list[Chunk]:
102
+ """Read a filing directory, return chunks ready for embedding."""
103
+ # Lazy import: keeps `unstructured` out of the runtime import path (it's only
104
+ # needed here, during offline ingestion). Clear hint if the group is missing.
105
+ try:
106
+ from unstructured.chunking.title import chunk_by_title
107
+ from unstructured.partition.html import partition_html
108
+ except ModuleNotFoundError as e: # pragma: no cover
109
+ raise ModuleNotFoundError(
110
+ "Ingestion requires the 'unstructured' extra. Install it with "
111
+ "`uv sync --group ingestion` (it's excluded from the runtime image)."
112
+ ) from e
113
+
114
+ meta = _load_filing_metadata(filing_dir)
115
+ htm_path = filing_dir / "filing.htm"
116
+
117
+ # Stage 1 β€” atomize the HTML into typed elements.
118
+ # `partition_html` is slow on first run (downloads NLTK data); fast after.
119
+ elements = partition_html(filename=str(htm_path))
120
+
121
+ # Stage 2a β€” walk elements once, building two parallel structures:
122
+ # - sections: a list of section buckets, each holding narrative elements
123
+ # - tables: pulled out into their own stream with section context attached
124
+ #
125
+ # Why bucket by section ourselves rather than relying on chunk_by_title's
126
+ # implicit handling? Because we need clean `section_title` attribution per
127
+ # chunk, and Unstructured's CompositeElement doesn't always expose the
128
+ # underlying Title element reliably across versions.
129
+ sections: list[dict[str, Any]] = []
130
+ tables: list[dict[str, Any]] = []
131
+ current_section: dict[str, Any] | None = None
132
+
133
+ for idx, el in enumerate(elements):
134
+ category = _element_category(el)
135
+
136
+ if category == "Title":
137
+ # Start a new section bucket. The Title element itself goes in
138
+ # so chunk_by_title sees it as the leading boundary.
139
+ current_section = {
140
+ "title": el.text,
141
+ "elements": [el],
142
+ }
143
+ sections.append(current_section)
144
+ elif category == "Table":
145
+ tables.append(
146
+ {
147
+ "idx": idx,
148
+ "element": el,
149
+ "section_title": current_section["title"] if current_section else None,
150
+ }
151
+ )
152
+ else:
153
+ # Anything else: NarrativeText, ListItem, Header, etc.
154
+ if current_section is None:
155
+ # Content before the first Title (cover page, etc.)
156
+ current_section = {"title": None, "elements": []}
157
+ sections.append(current_section)
158
+ current_section["elements"].append(el)
159
+
160
+ chunks: list[Chunk] = []
161
+ position = 0 # monotonic counter, used as element_index for stable IDs
162
+
163
+ # Stage 2b β€” chunk narrative *within* each section.
164
+ # By calling chunk_by_title per section, chunks never cross section
165
+ # boundaries β€” a guarantee we couldn't make with a single document-wide call.
166
+ for section in sections:
167
+ if not section["elements"]:
168
+ continue
169
+
170
+ composite_chunks = chunk_by_title(
171
+ section["elements"],
172
+ max_characters=MAX_CHARACTERS,
173
+ new_after_n_chars=NEW_AFTER_N_CHARS,
174
+ combine_text_under_n_chars=COMBINE_TEXT_UNDER_N_CHARS,
175
+ overlap=OVERLAP,
176
+ )
177
+
178
+ for cc in composite_chunks:
179
+ text = cc.text.strip()
180
+ if not text:
181
+ continue # skip empty composite results
182
+ chunks.append(
183
+ Chunk(
184
+ chunk_id=_hash_chunk(
185
+ meta["ticker"], meta["fiscal_year"], position, text
186
+ ),
187
+ text=text,
188
+ chunk_type="narrative",
189
+ section_title=section["title"],
190
+ section_path=[section["title"]] if section["title"] else [],
191
+ ticker=meta["ticker"],
192
+ company_name=meta["company_name"],
193
+ fiscal_year=meta["fiscal_year"],
194
+ period_of_report=meta["period_of_report"],
195
+ accession_number=meta["accession_number"],
196
+ sec_url=meta["sec_url"],
197
+ element_index=position,
198
+ )
199
+ )
200
+ position += 1
201
+
202
+ # Stage 2c β€” emit tables as their own chunks.
203
+ # The text is the table's HTML (when available), which keeps cell/column
204
+ # structure visible to the embedder. Tables that are very large will get
205
+ # truncated by Cohere's 512-token limit β€” accepted, because Day 2's
206
+ # DuckDB extractor will handle these structurally anyway.
207
+ for tbl in tables:
208
+ text = _table_text(tbl["element"]).strip()
209
+ if not text:
210
+ continue
211
+ chunks.append(
212
+ Chunk(
213
+ chunk_id=_hash_chunk(
214
+ meta["ticker"], meta["fiscal_year"], position, text
215
+ ),
216
+ text=text,
217
+ chunk_type="table",
218
+ section_title=tbl["section_title"],
219
+ section_path=[tbl["section_title"]] if tbl["section_title"] else [],
220
+ ticker=meta["ticker"],
221
+ company_name=meta["company_name"],
222
+ fiscal_year=meta["fiscal_year"],
223
+ period_of_report=meta["period_of_report"],
224
+ accession_number=meta["accession_number"],
225
+ sec_url=meta["sec_url"],
226
+ element_index=position,
227
+ )
228
+ )
229
+ position += 1
230
+
231
+ return chunks
232
+
233
+
234
+ # ── CLI ───────────────────────────────────────────────────────────────────
235
+ def _write_chunks(chunks: list[Chunk], out_file: Path) -> None:
236
+ with out_file.open("w", encoding="utf-8") as f:
237
+ for c in chunks:
238
+ f.write(c.model_dump_json() + "\n")
239
+
240
+
241
+ def _read_chunks(out_file: Path) -> list[Chunk]:
242
+ return [
243
+ Chunk.model_validate_json(line)
244
+ for line in out_file.read_text(encoding="utf-8").splitlines()
245
+ if line.strip()
246
+ ]
247
+
248
+
249
+ def main() -> None:
250
+ PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
251
+ filing_dirs = sorted(
252
+ d for d in RAW_DIR.iterdir() if d.is_dir() and (d / "metadata.json").exists()
253
+ )
254
+ print(f"Found {len(filing_dirs)} filings to parse.\n")
255
+
256
+ manifest_entries: list[dict[str, Any]] = []
257
+
258
+ for filing_dir in filing_dirs:
259
+ meta = _load_filing_metadata(filing_dir)
260
+ ticker = meta["ticker"]
261
+ fy = meta["fiscal_year"]
262
+ out_file = PROCESSED_DIR / f"{ticker}_{fy}.jsonl"
263
+
264
+ if out_file.exists():
265
+ chunks = _read_chunks(out_file)
266
+ print(f" ↳ skip {ticker} FY{fy} ({len(chunks)} chunks on disk)")
267
+ else:
268
+ print(f" ↳ parse {ticker} FY{fy}…", end="", flush=True)
269
+ chunks = parse_filing(filing_dir)
270
+ _write_chunks(chunks, out_file)
271
+ print(f" β†’ {len(chunks)} chunks")
272
+
273
+ manifest_entries.append(
274
+ {
275
+ "ticker": ticker,
276
+ "fiscal_year": fy,
277
+ "chunks_total": len(chunks),
278
+ "chunks_narrative": sum(1 for c in chunks if c.chunk_type == "narrative"),
279
+ "chunks_table": sum(1 for c in chunks if c.chunk_type == "table"),
280
+ "path": out_file.relative_to(REPO_ROOT).as_posix(),
281
+ }
282
+ )
283
+
284
+ manifest_path = PROCESSED_DIR / "manifest.json"
285
+ manifest_path.write_text(json.dumps({"filings": manifest_entries}, indent=2))
286
+ total = sum(e["chunks_total"] for e in manifest_entries)
287
+ print(f"\nDone. {total} chunks across {len(manifest_entries)} filings.")
288
+ print(f"Manifest: {manifest_path}")
289
+
290
+
291
+ if __name__ == "__main__":
292
+ main()
backend/src/finrag/llm/__init__.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM layer β€” provider-agnostic entry point.
2
+
3
+ `main.py`, the tools, and the LangGraph nodes import only these dispatchers and
4
+ the neutral result types; they never name a provider. Which backend runs is
5
+ decided by `settings.llm_provider` ("anthropic" default | "gemini" | "local").
6
+
7
+ Provider modules are imported lazily inside each dispatcher so a deploy with
8
+ only one provider's SDK/key still works.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable
14
+
15
+ from finrag.config import settings
16
+ from finrag.llm.base import SynthesisResult, ToolCall, ToolLoopResult
17
+ from finrag.retrieval.vector import RetrievedChunk
18
+
19
+ __all__ = [
20
+ "synthesize",
21
+ "generate_text",
22
+ "run_tool_loop",
23
+ "run_tool_loop_stream",
24
+ "SynthesisResult",
25
+ "ToolLoopResult",
26
+ "ToolCall",
27
+ ]
28
+
29
+
30
+ def _provider() -> str:
31
+ return (settings.llm_provider or "anthropic").lower()
32
+
33
+
34
+ def synthesize(question: str, chunks: list[RetrievedChunk]) -> SynthesisResult:
35
+ if _provider() == "gemini":
36
+ from finrag.llm.gemini import synthesize_gemini
37
+
38
+ return synthesize_gemini(question, chunks)
39
+ if _provider() == "local":
40
+ from finrag.llm.local import synthesize_local
41
+
42
+ return synthesize_local(question, chunks)
43
+ from finrag.llm.claude import synthesize_claude
44
+
45
+ return synthesize_claude(question, chunks)
46
+
47
+
48
+ def generate_text(system_instruction: str, user_text: str, **kwargs) -> str:
49
+ """Single-shot text completion (planning, NL→SQL)."""
50
+ if _provider() == "gemini":
51
+ from finrag.llm.gemini import generate_text as _gt
52
+
53
+ return _gt(system_instruction, user_text, **kwargs)
54
+ if _provider() == "local":
55
+ from finrag.llm.local import generate_text as _gt
56
+
57
+ return _gt(system_instruction, user_text, **kwargs)
58
+ from finrag.llm.claude import generate_text as _gt
59
+
60
+ return _gt(system_instruction, user_text, **kwargs)
61
+
62
+
63
+ def run_tool_loop(system: str, user_text: str, **kwargs) -> ToolLoopResult:
64
+ """Agentic tool-calling loop β€” Gemini function-calling or Claude tool_use."""
65
+ if _provider() == "gemini":
66
+ from finrag.llm.gemini import tool_loop
67
+
68
+ return tool_loop(system, user_text, **kwargs)
69
+ if _provider() == "local":
70
+ from finrag.llm.local import tool_loop
71
+
72
+ return tool_loop(system, user_text, **kwargs)
73
+ from finrag.llm.claude import tool_loop
74
+
75
+ return tool_loop(system, user_text, **kwargs)
76
+
77
+
78
+ def run_tool_loop_stream(
79
+ system: str,
80
+ user_text: str,
81
+ *,
82
+ on_text: Callable[[str], None] = lambda _t: None,
83
+ on_tool_call: Callable[[ToolCall], None] = lambda _c: None,
84
+ **kwargs,
85
+ ) -> ToolLoopResult:
86
+ """Streaming agentic loop: emits text deltas (`on_text`) and live tool calls
87
+ (`on_tool_call`) as they happen, returning the same ToolLoopResult.
88
+
89
+ Only Claude implements true streaming. Gemini and the local backend stay
90
+ non-streaming alternates, so we run their plain loop and replay the result
91
+ through the callbacks once β€” the seam stays intact, the live demo just isn't
92
+ granular."""
93
+ if _provider() in ("gemini", "local"):
94
+ if _provider() == "local":
95
+ from finrag.llm.local import tool_loop
96
+ else:
97
+ from finrag.llm.gemini import tool_loop
98
+
99
+ result = tool_loop(system, user_text, **kwargs)
100
+ for tc in result.tool_calls:
101
+ on_tool_call(tc)
102
+ if result.answer:
103
+ on_text(result.answer)
104
+ return result
105
+ from finrag.llm.claude import tool_loop_stream
106
+
107
+ return tool_loop_stream(
108
+ system, user_text, on_text=on_text, on_tool_call=on_tool_call, **kwargs
109
+ )
backend/src/finrag/llm/base.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Provider-agnostic pieces shared by every synthesis backend.
2
+
3
+ Lives in its own module (not __init__) so provider modules can import the
4
+ shared types without a circular import through the dispatcher.
5
+
6
+ `SynthesisResult` is the contract `main.py` depends on. Every provider maps
7
+ its native response/usage onto this shape, so the HTTP layer and the future
8
+ LangGraph nodes never learn which model actually answered.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import datetime
14
+ import decimal
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+ from finrag.retrieval.vector import RetrievedChunk
19
+
20
+ # The grounding contract. Every rule exists because LLMs violate it by default:
21
+ # - "Use ONLY the context" β†’ without this, the model fills gaps from memory
22
+ # - "[N] citations" β†’ without this, citations come back as prose mentions
23
+ # - "Don't round numbers" β†’ without this, $394,328M becomes "about $400 billion"
24
+ # - "Fiscal vs calendar year" β†’ without this, Apple FY2024 (ended Sep 2024)
25
+ # gets conflated with calendar 2024
26
+ # Provider-neutral: passed as Anthropic `system=` or Gemini `system_instruction`.
27
+ SYSTEM_PROMPT = """You are a financial analyst assistant answering questions about SEC 10-K filings.
28
+
29
+ Rules you must follow:
30
+
31
+ 1. **Grounding**: Answer ONLY using the provided context chunks. If the context does not contain the answer, say so explicitly β€” do not fill gaps from prior knowledge.
32
+
33
+ 2. **Citations**: Every factual claim must be followed by a citation in the form [N] where N is the chunk index from the provided context. Multiple supporting chunks: [1][3]. Cite even when paraphrasing.
34
+
35
+ 3. **Numbers**: Quote exact figures from the source. Do not round unless explicitly asked. If a chunk says "$394,328 million", write "$394,328 million" β€” not "$394 billion".
36
+
37
+ 4. **Fiscal year**: Be careful with fiscal vs calendar year. Apple's fiscal year ends in late September; Tesla and JPMorgan use calendar years. If the user says "2023", confirm which sense from context.
38
+
39
+ 5. **Brevity**: Match the question's scope. A "what was X" question gets one number with a citation. A "how did X change" question gets a comparison sentence. Do not over-explain.
40
+
41
+ 6. **Honest absence**: If the context doesn't answer the question, write "The provided context does not contain this information." Do not speculate.
42
+ """
43
+
44
+ MAX_TOKENS = 1024
45
+
46
+
47
+ @dataclass
48
+ class SynthesisResult:
49
+ """What synthesis returns, regardless of provider.
50
+
51
+ The cache token fields stay in the contract even for providers that
52
+ don't surface caching at this scale (both Anthropic <1024-token prompts
53
+ and Gemini's <~1024-token prefixes report 0) β€” keeping them lets the
54
+ frontend and eval harness read one stable shape.
55
+ """
56
+
57
+ answer: str
58
+ model: str
59
+ input_tokens: int
60
+ output_tokens: int
61
+ cache_creation_input_tokens: int # tokens billed at cache-write rate
62
+ cache_read_input_tokens: int # tokens served from cache (~10% cost)
63
+ stop_reason: str
64
+
65
+
66
+ def format_chunks_for_prompt(chunks: list[RetrievedChunk]) -> str:
67
+ """Format retrieved chunks with [N] anchors and provenance headers.
68
+
69
+ The [N] anchor at the start of each chunk is what the model references in
70
+ its citations. Position is 1-based to match human-reading convention.
71
+
72
+ The id= in the header is the real chunk_id β€” the only handle the model can
73
+ pass to lookup_citation. Without it the model has nothing real to deref and
74
+ fabricates ids like 'chunk_5' (which used to crash the hex→uint64 lookup).
75
+ """
76
+ blocks: list[str] = []
77
+ for i, c in enumerate(chunks, start=1):
78
+ section = c.section_title or "unknown section"
79
+ # Mark table chunks so the model treats them as structured data and
80
+ # doesn't hallucinate cell positions.
81
+ kind_marker = " [table]" if c.chunk_type == "table" else ""
82
+ header = f"[{i}] {c.ticker} Β· FY{c.fiscal_year} Β· {section}{kind_marker} (id={c.chunk_id})"
83
+ blocks.append(f"{header}\n{c.text}")
84
+ return "\n\n---\n\n".join(blocks)
85
+
86
+
87
+ @dataclass
88
+ class ToolCall:
89
+ """One tool invocation inside the agent loop, captured for the trace."""
90
+
91
+ tool: str
92
+ args: dict[str, Any]
93
+ result: Any
94
+
95
+
96
+ @dataclass
97
+ class ToolLoopResult:
98
+ """Provider-neutral result of an agentic tool-calling loop.
99
+
100
+ Both the Gemini (native function-calling) and Anthropic (tool_use)
101
+ implementations return this shape, so the agent node never learns which
102
+ backend ran the loop β€” the same seam idea as SynthesisResult.
103
+ """
104
+
105
+ answer: str
106
+ input_tokens: int
107
+ output_tokens: int
108
+ tool_calls: list[ToolCall] = field(default_factory=list)
109
+
110
+
111
+ def json_safe(v: Any) -> Any:
112
+ """Coerce a tool result into JSON-serializable form for tool responses.
113
+
114
+ DuckDB rows can carry date/Decimal values; both providers' tool-result
115
+ channels want JSON scalars, so we stringify dates and float-ify Decimals.
116
+ """
117
+ if isinstance(v, dict):
118
+ return {k: json_safe(x) for k, x in v.items()}
119
+ if isinstance(v, (list, tuple)):
120
+ return [json_safe(x) for x in v]
121
+ if isinstance(v, (datetime.date, datetime.datetime)):
122
+ return v.isoformat()
123
+ if isinstance(v, decimal.Decimal):
124
+ return float(v)
125
+ return v
126
+
127
+
128
+ def build_user_message(question: str, chunks: list[RetrievedChunk]) -> str:
129
+ """The per-query content: question + formatted context. Never cached."""
130
+ return f"Question: {question}\n\nContext:\n\n{format_chunks_for_prompt(chunks)}"
131
+
132
+
133
+ def empty_result(model: str) -> SynthesisResult:
134
+ """Returned when retrieval found nothing β€” skip the paid API call entirely."""
135
+ return SynthesisResult(
136
+ answer="No relevant context retrieved. Try rephrasing your question.",
137
+ model=model,
138
+ input_tokens=0,
139
+ output_tokens=0,
140
+ cache_creation_input_tokens=0,
141
+ cache_read_input_tokens=0,
142
+ stop_reason="empty_context",
143
+ )
backend/src/finrag/llm/claude.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Anthropic (Claude) backend β€” synthesis, text completion, and the agent
2
+ tool-loop via native `tool_use`.
3
+
4
+ This is the default provider (see config.llm_provider). Claude's tool-calling
5
+ is more reliable than flash-lite's (no malformed-call flakiness), which is why
6
+ the agent runs on it. Gemini stays fully wired as the alternate (see
7
+ [[gemini]]) so the eval harness can A/B either backend on identical retrieval.
8
+
9
+ Prompt caching earns its keep here: with tool schemas in the request the
10
+ cached prefix (tools + system) clears Anthropic's 1024-token floor, so the
11
+ repeated calls in a tool-loop hit cache β€” unlike the tiny /answer prompt.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import time
18
+ from collections.abc import Callable
19
+ from functools import lru_cache
20
+
21
+ import anthropic
22
+
23
+ from finrag.config import settings
24
+ from finrag.llm.base import (
25
+ MAX_TOKENS,
26
+ SYSTEM_PROMPT,
27
+ SynthesisResult,
28
+ ToolCall,
29
+ ToolLoopResult,
30
+ build_user_message,
31
+ empty_result,
32
+ json_safe,
33
+ )
34
+ from finrag.retrieval.vector import RetrievedChunk
35
+
36
+ # Default (eval baseline) is Sonnet; the public deploy sets CLAUDE_MODEL=
37
+ # claude-haiku-4-5-20251001 via env. Read through a helper so every call site
38
+ # (synthesis, generate_text, tool-loop, stream) picks up the configured model
39
+ # live β€” same pattern as the provider seam.
40
+ def _claude_model() -> str:
41
+ return settings.claude_model or "claude-sonnet-4-6"
42
+
43
+
44
+ # Back-compat alias for any module that imported the constant. Note: this binds
45
+ # once at import; the live value is _claude_model(). Internal call sites use the
46
+ # helper so an env override (prod Haiku) always takes effect.
47
+ CLAUDE_MODEL = settings.claude_model or "claude-sonnet-4-6"
48
+
49
+ # Statuses worth retrying: rate limit, transient server errors, overloaded.
50
+ _RETRYABLE_STATUS = {429, 500, 503, 529}
51
+
52
+
53
+ @lru_cache(maxsize=1)
54
+ def get_anthropic_client() -> anthropic.Anthropic:
55
+ if not settings.anthropic_api_key:
56
+ raise RuntimeError(
57
+ "ANTHROPIC_API_KEY is not set. Add it to .env, or set "
58
+ "LLM_PROVIDER=gemini to use Gemini instead."
59
+ )
60
+ return anthropic.Anthropic(api_key=settings.anthropic_api_key)
61
+
62
+
63
+ def _messages_create_with_retry(*, retries: int = 5, **kwargs):
64
+ """Single choke-point for Claude calls, with backoff on rate-limit /
65
+ overloaded / transient 5xx, honoring Retry-After when present."""
66
+ last: Exception | None = None
67
+ for i in range(retries):
68
+ try:
69
+ return get_anthropic_client().messages.create(**kwargs)
70
+ except anthropic.APIStatusError as e:
71
+ status = getattr(e, "status_code", None)
72
+ if status in _RETRYABLE_STATUS and i < retries - 1:
73
+ last = e
74
+ delay = 2.0 * (i + 1)
75
+ try:
76
+ ra = e.response.headers.get("retry-after")
77
+ if ra:
78
+ delay = min(float(ra) + 1.0, 35.0)
79
+ except Exception:
80
+ pass
81
+ time.sleep(delay)
82
+ continue
83
+ raise
84
+ raise last # type: ignore[misc]
85
+
86
+
87
+ def _cached_system(text: str) -> list[dict]:
88
+ """System block marked for prompt caching (ephemeral, 5-min TTL)."""
89
+ return [{"type": "text", "text": text, "cache_control": {"type": "ephemeral"}}]
90
+
91
+
92
+ def generate_text(
93
+ system_instruction: str,
94
+ user_text: str,
95
+ *,
96
+ max_output_tokens: int = 512,
97
+ temperature: float = 0.0,
98
+ ) -> str:
99
+ """Single-shot text completion (planning, NL→SQL). Mirrors the Gemini
100
+ backend's generate_text so the dispatcher can pick either."""
101
+ resp = _messages_create_with_retry(
102
+ model=_claude_model(),
103
+ max_tokens=max_output_tokens,
104
+ system=system_instruction,
105
+ messages=[{"role": "user", "content": user_text}],
106
+ temperature=temperature,
107
+ )
108
+ return "".join(b.text for b in resp.content if b.type == "text")
109
+
110
+
111
+ def synthesize_claude(question: str, chunks: list[RetrievedChunk]) -> SynthesisResult:
112
+ if not chunks:
113
+ return empty_result(CLAUDE_MODEL)
114
+
115
+ response = _messages_create_with_retry(
116
+ model=_claude_model(),
117
+ max_tokens=MAX_TOKENS,
118
+ system=_cached_system(SYSTEM_PROMPT),
119
+ messages=[{"role": "user", "content": build_user_message(question, chunks)}],
120
+ )
121
+ answer_text = "".join(b.text for b in response.content if b.type == "text")
122
+ usage = response.usage
123
+ return SynthesisResult(
124
+ answer=answer_text,
125
+ model=_claude_model(),
126
+ input_tokens=usage.input_tokens,
127
+ output_tokens=usage.output_tokens,
128
+ cache_creation_input_tokens=getattr(usage, "cache_creation_input_tokens", 0) or 0,
129
+ cache_read_input_tokens=getattr(usage, "cache_read_input_tokens", 0) or 0,
130
+ stop_reason=response.stop_reason or "unknown",
131
+ )
132
+
133
+
134
+ def _anthropic_tools() -> list[dict]:
135
+ """ToolSpec registry β†’ Anthropic tool schema. The ToolSpec.parameters are
136
+ already JSON-schema, which is exactly Anthropic's `input_schema` shape."""
137
+ from finrag.tools import TOOL_SPECS # lazy: avoid llm↔tools import cycle
138
+
139
+ return [
140
+ {"name": s.name, "description": s.description, "input_schema": s.parameters}
141
+ for s in TOOL_SPECS
142
+ ]
143
+
144
+
145
+ def tool_loop(
146
+ system: str,
147
+ user_text: str,
148
+ *,
149
+ max_tokens: int = 1024,
150
+ max_iters: int = 5,
151
+ ) -> ToolLoopResult:
152
+ """Run Claude with tools until it stops requesting them (or max_iters).
153
+
154
+ The tools+system prefix is cache_control'd, so each loop turn re-reads the
155
+ big prefix from cache instead of re-billing it at full rate.
156
+ """
157
+ from finrag.tools import dispatch # lazy: avoid llm↔tools import cycle
158
+
159
+ tools = _anthropic_tools()
160
+ messages: list[dict] = [{"role": "user", "content": user_text}]
161
+ in_tok = out_tok = 0
162
+ calls: list[ToolCall] = []
163
+ answer = ""
164
+
165
+ for _ in range(max_iters):
166
+ resp = _messages_create_with_retry(
167
+ model=_claude_model(),
168
+ max_tokens=max_tokens,
169
+ system=_cached_system(system),
170
+ tools=tools,
171
+ messages=messages,
172
+ temperature=0.0,
173
+ )
174
+ in_tok += resp.usage.input_tokens
175
+ out_tok += resp.usage.output_tokens
176
+
177
+ if resp.stop_reason == "tool_use":
178
+ messages.append({"role": "assistant", "content": resp.content})
179
+ tool_results: list[dict] = []
180
+ for block in resp.content:
181
+ if block.type == "tool_use":
182
+ result = json_safe(dispatch(block.name, dict(block.input)))
183
+ calls.append(ToolCall(block.name, dict(block.input), result))
184
+ tool_results.append(
185
+ {
186
+ "type": "tool_result",
187
+ "tool_use_id": block.id,
188
+ "content": json.dumps(result),
189
+ }
190
+ )
191
+ messages.append({"role": "user", "content": tool_results})
192
+ continue
193
+
194
+ answer = "".join(b.text for b in resp.content if b.type == "text")
195
+ break
196
+
197
+ return ToolLoopResult(answer=answer, input_tokens=in_tok, output_tokens=out_tok, tool_calls=calls)
198
+
199
+
200
+ def _stream_one_turn(*, tools, messages, system, max_tokens, on_text, retries: int = 5):
201
+ """Open one streaming Claude turn: forward text deltas to `on_text` as they
202
+ arrive, then return the fully-assembled Message (content blocks + usage).
203
+
204
+ Retries only on a retryable status raised *before* any text was emitted β€” a
205
+ mid-stream restart would re-send tokens the client already saw."""
206
+ last: Exception | None = None
207
+ for i in range(retries):
208
+ emitted = False
209
+ try:
210
+ with get_anthropic_client().messages.stream(
211
+ model=_claude_model(),
212
+ max_tokens=max_tokens,
213
+ system=system,
214
+ tools=tools,
215
+ messages=messages,
216
+ temperature=0.0,
217
+ ) as stream:
218
+ for text in stream.text_stream:
219
+ emitted = True
220
+ on_text(text)
221
+ return stream.get_final_message()
222
+ except anthropic.APIStatusError as e:
223
+ status = getattr(e, "status_code", None)
224
+ if status in _RETRYABLE_STATUS and i < retries - 1 and not emitted:
225
+ last = e
226
+ delay = 2.0 * (i + 1)
227
+ try:
228
+ ra = e.response.headers.get("retry-after")
229
+ if ra:
230
+ delay = min(float(ra) + 1.0, 35.0)
231
+ except Exception:
232
+ pass
233
+ time.sleep(delay)
234
+ continue
235
+ raise
236
+ raise last # type: ignore[misc]
237
+
238
+
239
+ def tool_loop_stream(
240
+ system: str,
241
+ user_text: str,
242
+ *,
243
+ max_tokens: int = 1024,
244
+ max_iters: int = 5,
245
+ on_text: Callable[[str], None] = lambda _t: None,
246
+ on_tool_call: Callable[[ToolCall], None] = lambda _c: None,
247
+ ) -> ToolLoopResult:
248
+ """Streaming twin of `tool_loop`: identical control flow, but each turn is
249
+ consumed via the streaming API so the final answer's text reaches `on_text`
250
+ delta-by-delta, and each dispatched tool hits `on_tool_call` the moment it
251
+ runs (not just at the end). Returns the same ToolLoopResult, so the caller's
252
+ trace/usage handling is unchanged whether it streamed or not.
253
+
254
+ Note: `on_text` fires for any text a turn emits. With this agent's prompt at
255
+ temperature 0 the tool_use turns carry no preamble, so in practice on_text
256
+ only sees the final answer; the authoritative answer is still the returned
257
+ ToolLoopResult.answer (the last turn's text), not the streamed concatenation."""
258
+ from finrag.tools import dispatch # lazy: avoid llm↔tools import cycle
259
+
260
+ tools = _anthropic_tools()
261
+ cached_system = _cached_system(system)
262
+ messages: list[dict] = [{"role": "user", "content": user_text}]
263
+ in_tok = out_tok = 0
264
+ calls: list[ToolCall] = []
265
+ answer = ""
266
+
267
+ for _ in range(max_iters):
268
+ final = _stream_one_turn(
269
+ tools=tools,
270
+ messages=messages,
271
+ system=cached_system,
272
+ max_tokens=max_tokens,
273
+ on_text=on_text,
274
+ )
275
+ in_tok += final.usage.input_tokens
276
+ out_tok += final.usage.output_tokens
277
+
278
+ if final.stop_reason == "tool_use":
279
+ messages.append({"role": "assistant", "content": final.content})
280
+ tool_results: list[dict] = []
281
+ for block in final.content:
282
+ if block.type == "tool_use":
283
+ result = json_safe(dispatch(block.name, dict(block.input)))
284
+ tc = ToolCall(block.name, dict(block.input), result)
285
+ calls.append(tc)
286
+ on_tool_call(tc) # surface live, before the next turn runs
287
+ tool_results.append(
288
+ {
289
+ "type": "tool_result",
290
+ "tool_use_id": block.id,
291
+ "content": json.dumps(result),
292
+ }
293
+ )
294
+ messages.append({"role": "user", "content": tool_results})
295
+ continue
296
+
297
+ answer = "".join(b.text for b in final.content if b.type == "text")
298
+ break
299
+
300
+ return ToolLoopResult(answer=answer, input_tokens=in_tok, output_tokens=out_tok, tool_calls=calls)
backend/src/finrag/llm/gemini.py ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gemini synthesis backend β€” the default provider.
2
+
3
+ Uses the unified `google-genai` SDK (NOT the legacy `google-generativeai`).
4
+ Same job as claude.py: retrieve β†’ synthesize a citation-grounded answer.
5
+ Maps Gemini's `usage_metadata` onto the shared `SynthesisResult`.
6
+
7
+ Why Gemini 2.5 Flash: the free tier zeroes out dev cost, and plain grounded
8
+ synthesis (read chunks, cite [N], don't hallucinate) is an easy workload for
9
+ it β€” this isn't reasoning-heavy. We disable "thinking" (budget=0) because
10
+ synthesis needs determinism and speed, not a scratchpad; thinking would just
11
+ burn output tokens and latency here.
12
+
13
+ Caching note: Gemini's implicit context cache only kicks in above a ~1k-token
14
+ prefix, and our system prompt is ~450 tokens, so cached_content_token_count
15
+ stays 0. That's expected, not a bug β€” see [[base]] SynthesisResult docstring.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ import time
22
+ from functools import lru_cache
23
+
24
+ from google import genai
25
+ from google.genai import types
26
+
27
+ from finrag.config import settings
28
+ from finrag.llm.base import (
29
+ MAX_TOKENS,
30
+ SYSTEM_PROMPT,
31
+ SynthesisResult,
32
+ ToolCall,
33
+ ToolLoopResult,
34
+ build_user_message,
35
+ empty_result,
36
+ json_safe,
37
+ )
38
+ from finrag.retrieval.vector import RetrievedChunk
39
+
40
+ # JSON-schema lowercase types β†’ Gemini's uppercase Type enum values.
41
+ _TYPE_MAP = {
42
+ "object": "OBJECT", "string": "STRING", "number": "NUMBER",
43
+ "integer": "INTEGER", "boolean": "BOOLEAN", "array": "ARRAY",
44
+ }
45
+
46
+ # flash-lite is the default: the agent makes ~5 calls/question and 2.5-flash's
47
+ # free tier caps at only 20 requests/DAY, which an agentic workload exhausts in
48
+ # ~4 questions. flash-lite has a far larger free daily quota (~1000/day) and
49
+ # 15 req/min β€” enough to actually run and demo the agent for free. Quality is
50
+ # marginally lower but fine for grounded synthesis + mechanical sub-tasks.
51
+ # (3.5-flash resolves but 503s constantly on free tier; 2.5-flash is selectable
52
+ # by editing this line if billing is enabled.)
53
+ GEMINI_MODEL = "gemini-2.5-flash-lite"
54
+
55
+
56
+ @lru_cache(maxsize=1)
57
+ def get_gemini_client() -> genai.Client:
58
+ if not settings.gemini_api_key:
59
+ raise RuntimeError(
60
+ "GEMINI_API_KEY is not set. Add it to .env, or set "
61
+ "LLM_PROVIDER=anthropic to use Claude instead."
62
+ )
63
+ return genai.Client(api_key=settings.gemini_api_key)
64
+
65
+
66
+ def _retry_delay(exc: Exception, attempt: int) -> float | None:
67
+ """Seconds to wait before retrying `exc`, or None if it's not retryable.
68
+
69
+ Two transient free-tier failures:
70
+ - 503 UNAVAILABLE ("high demand") β†’ linear backoff 2s/4s/6s.
71
+ - 429 RESOURCE_EXHAUSTED (5 req/min cap) β†’ honor the API's suggested
72
+ retryDelay (it tells us exactly when the per-minute window resets),
73
+ with a small buffer and a sane ceiling.
74
+ """
75
+ s = str(exc)
76
+ if "429" in s or "RESOURCE_EXHAUSTED" in s:
77
+ # Per-DAY quota won't reset within any sane wait β€” fail fast so the
78
+ # caller gets a clear error instead of blocking ~60s for nothing.
79
+ # Only the per-minute cap is worth waiting out.
80
+ if "PerDay" in s or "RequestsPerDay" in s:
81
+ return None
82
+ m = re.search(r"retry in ([0-9.]+)s", s) or re.search(
83
+ r"retryDelay['\"]?:?\s*['\"]?([0-9.]+)s", s
84
+ )
85
+ return min((float(m.group(1)) if m else 20.0) + 1.0, 35.0)
86
+ if "503" in s or "UNAVAILABLE" in s:
87
+ return 2.0 * (attempt + 1)
88
+ return None
89
+
90
+
91
+ def _has_content(response: object) -> bool:
92
+ """True if the response carries at least one usable text/function_call part.
93
+
94
+ flash-lite intermittently returns a candidate with no parts (empty
95
+ response), especially on larger tool-laden prompts. Such a response isn't
96
+ an exception, so we detect it explicitly and retry.
97
+ """
98
+ cands = getattr(response, "candidates", None)
99
+ if not cands:
100
+ return False
101
+ cand = cands[0]
102
+ if not cand.content or not cand.content.parts:
103
+ return False
104
+ return any(
105
+ getattr(p, "text", None) or getattr(p, "function_call", None)
106
+ for p in cand.content.parts
107
+ )
108
+
109
+
110
+ def generate_content_with_retry(
111
+ contents: object,
112
+ config: types.GenerateContentConfig,
113
+ *,
114
+ retries: int = 5,
115
+ ):
116
+ """Single choke-point for Gemini calls, with retry on 503, per-minute 429,
117
+ and empty (zero-part) responses.
118
+
119
+ The agent (Decision 16) makes several calls per question; on the free tier
120
+ any one can hit a transient 503, the per-minute 429, or a flash-lite empty
121
+ candidate. Centralizing retry here means synthesis, NL→SQL, planning, and
122
+ the tool-loop all inherit it, so a single blip doesn't abort the graph.
123
+ """
124
+ last: Exception | None = None
125
+ for i in range(retries):
126
+ try:
127
+ response = get_gemini_client().models.generate_content(
128
+ model=GEMINI_MODEL, contents=contents, config=config
129
+ )
130
+ except Exception as e: # noqa: BLE001 β€” re-raised unless _retry_delay matches
131
+ delay = _retry_delay(e, i)
132
+ if delay is not None and i < retries - 1:
133
+ last = e
134
+ time.sleep(delay)
135
+ continue
136
+ raise
137
+ # Empty candidate β†’ transient; retry a couple times before giving up.
138
+ if not _has_content(response) and i < retries - 1:
139
+ time.sleep(1.0)
140
+ continue
141
+ return response
142
+ raise last # type: ignore[misc]
143
+
144
+
145
+ def _config(
146
+ system_instruction: str,
147
+ *,
148
+ tools: list[types.Tool] | None = None,
149
+ max_output_tokens: int = MAX_TOKENS,
150
+ temperature: float = 0.0,
151
+ ) -> types.GenerateContentConfig:
152
+ """Shared config: thinking disabled (synthesis/routing want determinism)."""
153
+ return types.GenerateContentConfig(
154
+ system_instruction=system_instruction,
155
+ tools=tools,
156
+ max_output_tokens=max_output_tokens,
157
+ temperature=temperature,
158
+ thinking_config=types.ThinkingConfig(thinking_budget=0),
159
+ )
160
+
161
+
162
+ def _extract_text(response: object) -> tuple[str, str]:
163
+ """Pull (text, finish_reason) out of a Gemini response defensively.
164
+
165
+ If the candidate was blocked (safety) or truncated, `.parts` may be empty;
166
+ `response.text` would warn/raise in that case, so we walk the parts.
167
+ """
168
+ text = ""
169
+ finish_reason = "unknown"
170
+ candidates = getattr(response, "candidates", None)
171
+ if candidates:
172
+ cand = candidates[0]
173
+ finish_reason = str(getattr(cand, "finish_reason", "unknown"))
174
+ if cand.content and cand.content.parts:
175
+ text = "".join(
176
+ p.text for p in cand.content.parts if getattr(p, "text", None)
177
+ )
178
+ return text, finish_reason
179
+
180
+
181
+ def generate_text(
182
+ system_instruction: str,
183
+ user_text: str,
184
+ *,
185
+ max_output_tokens: int = 512,
186
+ temperature: float = 0.0,
187
+ ) -> str:
188
+ """Single-shot text completion β€” the building block for sub-LLM tasks
189
+ like NL→SQL, where we want raw text out, not a SynthesisResult.
190
+
191
+ (Lives on the Gemini backend for now; if LLM_PROVIDER swaps to Anthropic,
192
+ this is the one helper sql_query would need mirrored in claude.py.)
193
+ """
194
+ response = generate_content_with_retry(
195
+ user_text,
196
+ _config(
197
+ system_instruction,
198
+ max_output_tokens=max_output_tokens,
199
+ temperature=temperature,
200
+ ),
201
+ )
202
+ text, _ = _extract_text(response)
203
+ return text
204
+
205
+
206
+ def synthesize_gemini(question: str, chunks: list[RetrievedChunk]) -> SynthesisResult:
207
+ if not chunks:
208
+ return empty_result(GEMINI_MODEL)
209
+
210
+ # system_instruction plays the role Anthropic's `system=` does β€” keeps
211
+ # grounding rules out of the user turn. temperature 0 β†’ deterministic
212
+ # grounded extraction, not creativity.
213
+ response = generate_content_with_retry(
214
+ build_user_message(question, chunks),
215
+ _config(SYSTEM_PROMPT),
216
+ )
217
+
218
+ answer_text, finish_reason = _extract_text(response)
219
+
220
+ usage = response.usage_metadata
221
+ return SynthesisResult(
222
+ answer=answer_text,
223
+ model=GEMINI_MODEL,
224
+ input_tokens=getattr(usage, "prompt_token_count", 0) or 0,
225
+ output_tokens=getattr(usage, "candidates_token_count", 0) or 0,
226
+ # Gemini doesn't bill a separate cache-write tier the way Anthropic
227
+ # does; implicit caching just reports read tokens. Keep write at 0.
228
+ cache_creation_input_tokens=0,
229
+ cache_read_input_tokens=getattr(usage, "cached_content_token_count", 0) or 0,
230
+ stop_reason=finish_reason,
231
+ )
232
+
233
+
234
+ # ── Agent tool-loop (native function calling) ─────────────────────────────
235
+ def _to_schema(js: dict) -> types.Schema:
236
+ """One JSON-schema fragment β†’ a genai Schema (recursively)."""
237
+ schema = types.Schema(type=_TYPE_MAP.get(js.get("type", "object"), "STRING"))
238
+ if "description" in js:
239
+ schema.description = js["description"]
240
+ if js.get("type") == "object":
241
+ schema.properties = {k: _to_schema(v) for k, v in js.get("properties", {}).items()}
242
+ if js.get("required"):
243
+ schema.required = list(js["required"])
244
+ if js.get("type") == "array" and "items" in js:
245
+ schema.items = _to_schema(js["items"])
246
+ return schema
247
+
248
+
249
+ def _gemini_tool() -> types.Tool:
250
+ from finrag.tools import TOOL_SPECS # lazy: avoid llm↔tools import cycle
251
+
252
+ return types.Tool(
253
+ function_declarations=[
254
+ types.FunctionDeclaration(
255
+ name=s.name, description=s.description, parameters=_to_schema(s.parameters)
256
+ )
257
+ for s in TOOL_SPECS
258
+ ]
259
+ )
260
+
261
+
262
+ def _args_to_dict(args: object) -> dict:
263
+ """Convert a Gemini function_call.args (proto Map) to a plain dict."""
264
+ def conv(v):
265
+ if hasattr(v, "items"):
266
+ return {k: conv(x) for k, x in v.items()}
267
+ if isinstance(v, (list, tuple)):
268
+ return [conv(x) for x in v]
269
+ return v
270
+
271
+ return conv(args) if args else {}
272
+
273
+
274
+ def tool_loop(
275
+ system: str,
276
+ user_text: str,
277
+ *,
278
+ max_tokens: int = 1024,
279
+ max_iters: int = 5,
280
+ ) -> ToolLoopResult:
281
+ """Run Gemini with tools until it stops requesting them (or max_iters).
282
+ Mirrors claude.tool_loop's signature/return so the dispatcher can pick."""
283
+ from finrag.tools import dispatch # lazy: avoid llm↔tools import cycle
284
+
285
+ config = types.GenerateContentConfig(
286
+ system_instruction=system,
287
+ tools=[_gemini_tool()],
288
+ max_output_tokens=max_tokens,
289
+ temperature=0.0,
290
+ thinking_config=types.ThinkingConfig(thinking_budget=0),
291
+ )
292
+ contents: list[types.Content] = [
293
+ types.Content(role="user", parts=[types.Part(text=user_text)])
294
+ ]
295
+ in_tok = out_tok = 0
296
+ calls: list[ToolCall] = []
297
+ answer = ""
298
+
299
+ for _ in range(max_iters):
300
+ resp = generate_content_with_retry(contents, config)
301
+ um = resp.usage_metadata
302
+ in_tok += getattr(um, "prompt_token_count", 0) or 0
303
+ out_tok += getattr(um, "candidates_token_count", 0) or 0
304
+
305
+ cand = resp.candidates[0] if resp.candidates else None
306
+ if cand is None or not cand.content or not cand.content.parts:
307
+ break
308
+ parts = cand.content.parts
309
+ contents.append(cand.content)
310
+
311
+ fcs = [p.function_call for p in parts if getattr(p, "function_call", None)]
312
+ if not fcs:
313
+ answer = "".join(p.text for p in parts if getattr(p, "text", None))
314
+ break
315
+
316
+ response_parts: list[types.Part] = []
317
+ for fc in fcs:
318
+ args = _args_to_dict(fc.args)
319
+ result = json_safe(dispatch(fc.name, args))
320
+ calls.append(ToolCall(fc.name, args, result))
321
+ response_parts.append(
322
+ types.Part.from_function_response(name=fc.name, response=result)
323
+ )
324
+ contents.append(types.Content(role="user", parts=response_parts))
325
+
326
+ return ToolLoopResult(answer=answer, input_tokens=in_tok, output_tokens=out_tok, tool_calls=calls)
backend/src/finrag/llm/local.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local / edge backend β€” Llama 3.2 3B via Ollama's OpenAI-compatible API.
2
+
3
+ This is the third provider behind the seam (see [[gemini]], [[claude]]). It maps
4
+ Ollama's responses onto the same `SynthesisResult`/`ToolLoopResult`, so no node,
5
+ tool, or graph code learns a local model is answering β€” `llm_provider="local"`
6
+ is the only switch.
7
+
8
+ Why the OpenAI client (not Ollama's native API): Ollama exposes an
9
+ OpenAI-compatible endpoint on :11434/v1, so the *same* code targets vLLM,
10
+ llama.cpp, or LM Studio by changing `local_base_url`. That portability is the
11
+ point of the edge story β€” the seam isn't Ollama-specific, it's "any
12
+ OpenAI-compatible local server."
13
+
14
+ The edge reality (the finding this variant exists to produce): a 3B model
15
+ retrieves + synthesizes grounded answers fine, but its tool-calling is weak β€” it
16
+ mis-forms or skips function calls that Claude handles reliably. So `tool_loop`
17
+ honors `settings.local_use_tools`: True runs the real agentic loop (and we report
18
+ how often it misfires); False degrades to synthesis-only over the provided
19
+ context, which is what small local models can actually do dependably.
20
+
21
+ No prompt caching here β€” local inference has no per-token cost or cache tier, so
22
+ the cache fields on SynthesisResult stay 0 (same as Gemini, see [[base]]).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ from functools import lru_cache
29
+
30
+ from openai import OpenAI
31
+
32
+ from finrag.config import settings
33
+ from finrag.llm.base import (
34
+ MAX_TOKENS,
35
+ SYSTEM_PROMPT,
36
+ SynthesisResult,
37
+ ToolCall,
38
+ ToolLoopResult,
39
+ build_user_message,
40
+ empty_result,
41
+ json_safe,
42
+ )
43
+ from finrag.retrieval.vector import RetrievedChunk
44
+
45
+
46
+ @lru_cache(maxsize=1)
47
+ def get_local_client() -> OpenAI:
48
+ """OpenAI client pointed at the local Ollama server. The api_key is a
49
+ required-but-ignored placeholder (Ollama doesn't auth). A clear error if the
50
+ daemon isn't up mirrors the missing-key errors on the cloud backends."""
51
+ return OpenAI(base_url=settings.local_base_url, api_key="ollama")
52
+
53
+
54
+ def _model() -> str:
55
+ return settings.local_model
56
+
57
+
58
+ def generate_text(
59
+ system_instruction: str,
60
+ user_text: str,
61
+ *,
62
+ max_output_tokens: int = 512,
63
+ temperature: float = 0.0,
64
+ ) -> str:
65
+ """Single-shot text completion (planning, NL→SQL). Mirrors the claude/gemini
66
+ backends so the dispatcher can pick any provider."""
67
+ resp = get_local_client().chat.completions.create(
68
+ model=_model(),
69
+ messages=[
70
+ {"role": "system", "content": system_instruction},
71
+ {"role": "user", "content": user_text},
72
+ ],
73
+ max_tokens=max_output_tokens,
74
+ temperature=temperature,
75
+ )
76
+ return resp.choices[0].message.content or ""
77
+
78
+
79
+ def synthesize_local(question: str, chunks: list[RetrievedChunk]) -> SynthesisResult:
80
+ if not chunks:
81
+ return empty_result(_model())
82
+
83
+ resp = get_local_client().chat.completions.create(
84
+ model=_model(),
85
+ messages=[
86
+ {"role": "system", "content": SYSTEM_PROMPT},
87
+ {"role": "user", "content": build_user_message(question, chunks)},
88
+ ],
89
+ max_tokens=MAX_TOKENS,
90
+ temperature=0.0,
91
+ )
92
+ choice = resp.choices[0]
93
+ usage = resp.usage
94
+ return SynthesisResult(
95
+ answer=choice.message.content or "",
96
+ model=_model(),
97
+ input_tokens=getattr(usage, "prompt_tokens", 0) or 0,
98
+ output_tokens=getattr(usage, "completion_tokens", 0) or 0,
99
+ # Local inference has no cache-billing tier; keep both at 0.
100
+ cache_creation_input_tokens=0,
101
+ cache_read_input_tokens=0,
102
+ stop_reason=choice.finish_reason or "unknown",
103
+ )
104
+
105
+
106
+ # ── Agent tool-loop (OpenAI-style function calling) ──────────────────────────
107
+ def _openai_tools() -> list[dict]:
108
+ """ToolSpec registry β†’ OpenAI tool schema. ToolSpec.parameters are already
109
+ JSON-schema, which is exactly the `function.parameters` shape."""
110
+ from finrag.tools import TOOL_SPECS # lazy: avoid llm↔tools import cycle
111
+
112
+ return [
113
+ {
114
+ "type": "function",
115
+ "function": {
116
+ "name": s.name,
117
+ "description": s.description,
118
+ "parameters": s.parameters,
119
+ },
120
+ }
121
+ for s in TOOL_SPECS
122
+ ]
123
+
124
+
125
+ def _synthesis_only(system: str, user_text: str, max_tokens: int) -> ToolLoopResult:
126
+ """Degraded path: no tools offered, just grounded synthesis over the context
127
+ already embedded in `user_text`. This is what a 3B model does reliably, so we
128
+ benchmark it as the honest edge finding when tool-calling is off."""
129
+ resp = get_local_client().chat.completions.create(
130
+ model=_model(),
131
+ messages=[
132
+ {"role": "system", "content": system},
133
+ {"role": "user", "content": user_text},
134
+ ],
135
+ max_tokens=max_tokens,
136
+ temperature=0.0,
137
+ )
138
+ usage = resp.usage
139
+ return ToolLoopResult(
140
+ answer=resp.choices[0].message.content or "",
141
+ input_tokens=getattr(usage, "prompt_tokens", 0) or 0,
142
+ output_tokens=getattr(usage, "completion_tokens", 0) or 0,
143
+ tool_calls=[],
144
+ )
145
+
146
+
147
+ def tool_loop(
148
+ system: str,
149
+ user_text: str,
150
+ *,
151
+ max_tokens: int = 1024,
152
+ max_iters: int = 5,
153
+ ) -> ToolLoopResult:
154
+ """Run the local model with tools until it stops requesting them (or
155
+ max_iters). Mirrors claude/gemini tool_loop's signature/return.
156
+
157
+ When settings.local_use_tools is False, skip tool-calling entirely and run
158
+ synthesis-only β€” the documented degraded mode for small models."""
159
+ if not settings.local_use_tools:
160
+ return _synthesis_only(system, user_text, max_tokens)
161
+
162
+ from finrag.tools import dispatch # lazy: avoid llm↔tools import cycle
163
+
164
+ tools = _openai_tools()
165
+ messages: list[dict] = [
166
+ {"role": "system", "content": system},
167
+ {"role": "user", "content": user_text},
168
+ ]
169
+ in_tok = out_tok = 0
170
+ calls: list[ToolCall] = []
171
+ answer = ""
172
+
173
+ for _ in range(max_iters):
174
+ resp = get_local_client().chat.completions.create(
175
+ model=_model(),
176
+ messages=messages,
177
+ tools=tools,
178
+ max_tokens=max_tokens,
179
+ temperature=0.0,
180
+ )
181
+ msg = resp.choices[0].message
182
+ usage = resp.usage
183
+ in_tok += getattr(usage, "prompt_tokens", 0) or 0
184
+ out_tok += getattr(usage, "completion_tokens", 0) or 0
185
+
186
+ if msg.tool_calls:
187
+ # Re-send the assistant turn verbatim (content + the tool_calls it
188
+ # requested), then one tool message per call, keyed by tool_call_id.
189
+ messages.append(
190
+ {
191
+ "role": "assistant",
192
+ "content": msg.content or "",
193
+ "tool_calls": [
194
+ {
195
+ "id": tc.id,
196
+ "type": "function",
197
+ "function": {
198
+ "name": tc.function.name,
199
+ "arguments": tc.function.arguments,
200
+ },
201
+ }
202
+ for tc in msg.tool_calls
203
+ ],
204
+ }
205
+ )
206
+ for tc in msg.tool_calls:
207
+ # 3B models sometimes emit malformed JSON args β€” treat as empty
208
+ # rather than crashing the loop (part of the weak-tool-calling story).
209
+ try:
210
+ args = json.loads(tc.function.arguments or "{}")
211
+ except json.JSONDecodeError:
212
+ args = {}
213
+ result = json_safe(dispatch(tc.function.name, args))
214
+ calls.append(ToolCall(tc.function.name, args, result))
215
+ messages.append(
216
+ {
217
+ "role": "tool",
218
+ "tool_call_id": tc.id,
219
+ "content": json.dumps(result),
220
+ }
221
+ )
222
+ continue
223
+
224
+ answer = msg.content or ""
225
+ break
226
+
227
+ return ToolLoopResult(
228
+ answer=answer, input_tokens=in_tok, output_tokens=out_tok, tool_calls=calls
229
+ )
backend/src/finrag/main.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from typing import Any
3
+
4
+ from fastapi import Depends, FastAPI
5
+ from fastapi.encoders import jsonable_encoder
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from fastapi.responses import StreamingResponse
8
+ from pydantic import BaseModel, Field
9
+
10
+ from finrag.agent import get_agent, run_agent
11
+ from finrag.config import settings
12
+ from finrag.guardrails import cap_status, enforce
13
+ from finrag.llm import synthesize
14
+ from finrag.retrieval.rerank import rerank_search
15
+ from finrag.retrieval.vector import RetrievedChunk
16
+
17
+ app = FastAPI(title="FinRAG", version="0.1.0")
18
+
19
+ # CORS allow-list comes from settings: dev defaults to the Next.js dev server;
20
+ # the prod deploy sets ALLOWED_ORIGINS to the exact Vercel origin (docs/deploy.md).
21
+ app.add_middleware(
22
+ CORSMiddleware,
23
+ allow_origins=settings.allowed_origins_list,
24
+ allow_credentials=True,
25
+ allow_methods=["GET", "POST"],
26
+ allow_headers=["*"],
27
+ )
28
+
29
+
30
+ # ── Request / response models ─────────────────────────────────────────────
31
+ class QueryRequest(BaseModel):
32
+ question: str = Field(..., min_length=1, max_length=1000)
33
+ top_k: int = Field(default=5, ge=1, le=50)
34
+ # Optional payload filters β€” agent (Day 3) will populate these dynamically
35
+ ticker: str | None = None
36
+ fiscal_year: int | None = None
37
+ chunk_type: str | None = Field(
38
+ default=None,
39
+ description="Filter to 'narrative' or 'table' chunks only.",
40
+ )
41
+
42
+
43
+ class QueryResponse(BaseModel):
44
+ question: str
45
+ chunks: list[RetrievedChunk]
46
+
47
+
48
+ class AnswerRequest(QueryRequest):
49
+ """Same filter shape as QueryRequest; answer endpoint just adds synthesis."""
50
+
51
+ pass
52
+
53
+
54
+ class AnswerUsage(BaseModel):
55
+ """Surfaced so the frontend (and curious humans) can see prompt-caching
56
+ is working: cache_read_input_tokens should be > 0 on the second+ call."""
57
+
58
+ model: str
59
+ input_tokens: int
60
+ output_tokens: int
61
+ cache_creation_input_tokens: int
62
+ cache_read_input_tokens: int
63
+ stop_reason: str
64
+
65
+
66
+ class AnswerResponse(BaseModel):
67
+ question: str
68
+ answer: str
69
+ chunks: list[RetrievedChunk]
70
+ usage: AnswerUsage
71
+
72
+
73
+ class AgentResponse(BaseModel):
74
+ question: str
75
+ answer: str
76
+ route: str
77
+ chunks: list[RetrievedChunk]
78
+ # Ordered node/tool steps the agent took β€” drives the frontend trace UI.
79
+ trace: list[dict[str, Any]]
80
+ usage: dict[str, int]
81
+
82
+
83
+ # ── Routes ────────────────────────────────────────────────────────────────
84
+ @app.get("/health")
85
+ def health() -> dict[str, Any]:
86
+ """Liveness + observable guardrail state (so the cap is visible without
87
+ grepping logs, and the frontend can show 'N questions left today')."""
88
+ return {
89
+ "status": "ok",
90
+ "llm_mode": settings.llm_mode,
91
+ "provider": settings.llm_provider,
92
+ "rate_limit_per_min": settings.rate_limit_per_min,
93
+ **cap_status(),
94
+ }
95
+
96
+
97
+ @app.post("/query", response_model=QueryResponse)
98
+ def query(req: QueryRequest) -> QueryResponse:
99
+ """Three-stage retrieval: BM25 + dense (RRF-fused) β†’ Cohere Rerank v3.
100
+
101
+ Returns the raw retrieved chunks. Used by the eval harness and for
102
+ inspecting retrieval quality in isolation.
103
+ """
104
+ chunks = rerank_search(
105
+ question=req.question,
106
+ top_k=req.top_k,
107
+ ticker=req.ticker,
108
+ fiscal_year=req.fiscal_year,
109
+ chunk_type=req.chunk_type,
110
+ )
111
+ return QueryResponse(question=req.question, chunks=chunks)
112
+
113
+
114
+ @app.post("/answer", response_model=AnswerResponse, dependencies=[Depends(enforce)])
115
+ def answer(req: AnswerRequest) -> AnswerResponse:
116
+ """Retrieve top-K with the Day-2 funnel, then synthesize a grounded
117
+ answer with Claude. Citations are returned as [N] inline references
118
+ pointing into the `chunks` array (1-based).
119
+
120
+ Day-3 agent + tool use (sql_query, calculator) lands in the next
121
+ decision. This endpoint will remain available as the "retrieval-only
122
+ synthesis" baseline so we can measure agent value-add against it.
123
+ """
124
+ chunks = rerank_search(
125
+ question=req.question,
126
+ top_k=req.top_k,
127
+ ticker=req.ticker,
128
+ fiscal_year=req.fiscal_year,
129
+ chunk_type=req.chunk_type,
130
+ )
131
+ result = synthesize(req.question, chunks)
132
+ return AnswerResponse(
133
+ question=req.question,
134
+ answer=result.answer,
135
+ chunks=chunks,
136
+ usage=AnswerUsage(
137
+ model=result.model,
138
+ input_tokens=result.input_tokens,
139
+ output_tokens=result.output_tokens,
140
+ cache_creation_input_tokens=result.cache_creation_input_tokens,
141
+ cache_read_input_tokens=result.cache_read_input_tokens,
142
+ stop_reason=result.stop_reason,
143
+ ),
144
+ )
145
+
146
+
147
+ @app.post("/agent", response_model=AgentResponse, dependencies=[Depends(enforce)])
148
+ def agent_endpoint(req: AnswerRequest) -> AgentResponse:
149
+ """Run the LangGraph agent: plan β†’ (retrieve) β†’ tool-loop β†’ synthesize.
150
+
151
+ Unlike /answer (retrieval + plain synthesis), this routes the question,
152
+ optionally pulls vector context, and lets the model call tools
153
+ (sql_query, calculator, lookup_citation). Returns the answer plus the full
154
+ node/tool `trace` for the frontend to render. /answer stays as the baseline.
155
+ """
156
+ final = run_agent(req.question)
157
+ return AgentResponse(
158
+ question=req.question,
159
+ answer=final.get("answer", ""),
160
+ route=final.get("route", ""),
161
+ chunks=final.get("chunks", []),
162
+ trace=final.get("trace", []),
163
+ usage=final.get("usage", {}),
164
+ )
165
+
166
+
167
+ def _sse(event: str, data: Any) -> str:
168
+ """One Server-Sent-Events frame. jsonable_encoder handles RetrievedChunk
169
+ (pydantic) and any dates/Decimals in tool results."""
170
+ return f"event: {event}\ndata: {json.dumps(jsonable_encoder(data))}\n\n"
171
+
172
+
173
+ @app.post("/agent/stream", dependencies=[Depends(enforce)])
174
+ def agent_stream(req: AnswerRequest) -> StreamingResponse:
175
+ """Streaming twin of /agent (Server-Sent Events). The client watches the
176
+ agent reason in real time:
177
+
178
+ event: rewrite | route | retrieve planning milestones (per graph node)
179
+ event: tool_call each tool the moment it executes
180
+ event: token final-answer text deltas as generated
181
+ event: done full answer + route + chunks + usage + trace
182
+ event: error message, if the run raises mid-stream
183
+
184
+ Milestones come from LangGraph 'updates' (state deltas as each node finishes);
185
+ tokens and tool_calls come from the agent node's custom stream writer. Both
186
+ are pulled from one `graph.stream(stream_mode=["updates","custom"])` so they
187
+ arrive interleaved in true execution order. tool_call/synthesize trace items
188
+ are skipped in the 'updates' pass β€” they're already streamed live β€” but the
189
+ 'done' frame still carries the complete trace for the final render."""
190
+
191
+ def event_gen():
192
+ graph = get_agent()
193
+ final: dict[str, Any] = {
194
+ "question": req.question,
195
+ "answer": "",
196
+ "route": "",
197
+ "chunks": [],
198
+ "usage": {},
199
+ "trace": [],
200
+ }
201
+ try:
202
+ for mode, chunk in graph.stream(
203
+ {"question": req.question, "trace": []},
204
+ stream_mode=["updates", "custom"],
205
+ ):
206
+ if mode == "custom":
207
+ yield _sse(chunk.get("type", "custom"), chunk)
208
+ continue
209
+ # mode == "updates": chunk is {node_name: state_delta}
210
+ for _node, delta in chunk.items():
211
+ for ev in delta.get("trace", []):
212
+ if ev.get("type") in ("rewrite", "route", "retrieve", "fallback"):
213
+ yield _sse(ev["type"], ev)
214
+ for key in ("answer", "route", "chunks", "usage"):
215
+ if key in delta:
216
+ final[key] = delta[key]
217
+ final["trace"].extend(delta.get("trace", []))
218
+ yield _sse("done", final)
219
+ except Exception as e: # don't 500 mid-stream β€” report and close cleanly
220
+ yield _sse("error", {"message": str(e)})
221
+
222
+ return StreamingResponse(
223
+ event_gen(),
224
+ media_type="text/event-stream",
225
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
226
+ )
backend/src/finrag/retrieval/__init__.py ADDED
File without changes
backend/src/finrag/retrieval/hybrid.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hybrid retrieval: dense (Qdrant) + lexical (BM25) fused via RRF.
2
+
3
+ Why RRF rather than score normalization: BM25 scores and cosine similarities
4
+ are on different scales with different distributions. Normalizing them risks
5
+ arbitrary calibration choices. RRF discards raw scores and uses *ranks*,
6
+ which makes the fusion calibration-free and notably robust.
7
+
8
+ Formula (Cormack, Clarke, Buettcher 2009):
9
+ RRF_score(d) = Ξ£ over rankers r: 1 / (k + rank_r(d))
10
+ rank is 1-based; k=60 is the paper's value and works in practice.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from finrag.retrieval import lexical
16
+ from finrag.retrieval.vector import (
17
+ RetrievedChunk,
18
+ payload_to_chunk,
19
+ retrieve_by_chunk_ids,
20
+ search as dense_search,
21
+ )
22
+
23
+ # Constants
24
+ RRF_K = 60 # smoothing β€” paper default, do not tune without eval
25
+ DEFAULT_K_EACH = 50 # candidates per retriever before fusion
26
+
27
+
28
+ def _rrf_fuse(
29
+ ranked_lists: list[list[str]], k: int = RRF_K
30
+ ) -> dict[str, float]:
31
+ """Compute RRF scores given multiple ranked lists of chunk_ids.
32
+
33
+ Each list should be in retrieval-order (best first). A chunk_id absent
34
+ from a list contributes 0 from that ranker.
35
+ """
36
+ scores: dict[str, float] = {}
37
+ for ranks in ranked_lists:
38
+ for position, chunk_id in enumerate(ranks, start=1):
39
+ scores[chunk_id] = scores.get(chunk_id, 0.0) + 1.0 / (k + position)
40
+ return scores
41
+
42
+
43
+ def hybrid_search(
44
+ question: str,
45
+ top_k: int = 5,
46
+ ticker: str | None = None,
47
+ fiscal_year: int | None = None,
48
+ chunk_type: str | None = None,
49
+ k_each: int = DEFAULT_K_EACH,
50
+ ) -> list[RetrievedChunk]:
51
+ """End-to-end hybrid retrieval.
52
+
53
+ Steps:
54
+ 1. Run dense and BM25 in parallel-ish (sequential here; both fast).
55
+ 2. RRF-fuse the two rank orderings into a single score per chunk_id.
56
+ 3. Take top_k by fused score.
57
+ 4. Hydrate any chunk_ids that came only from BM25 by batch-fetching
58
+ their payloads from Qdrant.
59
+ 5. Return RetrievedChunk objects with `score` = the RRF fused score.
60
+
61
+ The score field is now the RRF score, not raw cosine or BM25. RRF scores
62
+ are small (typically 0.01-0.05 for top results) β€” don't compare them to
63
+ Day-1 cosine scores; they're on different scales.
64
+ """
65
+ # 1. Candidates from each retriever, both filter-aware so the candidate
66
+ # pool already respects the user's scoping.
67
+ dense_chunks = dense_search(
68
+ question=question,
69
+ top_k=k_each,
70
+ ticker=ticker,
71
+ fiscal_year=fiscal_year,
72
+ chunk_type=chunk_type,
73
+ )
74
+ bm25_results = lexical.search(
75
+ query=question,
76
+ top_k=k_each,
77
+ ticker=ticker,
78
+ fiscal_year=fiscal_year,
79
+ chunk_type=chunk_type,
80
+ )
81
+
82
+ dense_ids = [c.chunk_id for c in dense_chunks]
83
+ bm25_ids = [cid for cid, _ in bm25_results]
84
+
85
+ # 2. RRF fuse
86
+ rrf_scores = _rrf_fuse([dense_ids, bm25_ids])
87
+
88
+ # 3. Top-K by fused score
89
+ top_ids = sorted(rrf_scores, key=rrf_scores.get, reverse=True)[:top_k]
90
+
91
+ # 4. Hydrate. Dense gave us full payloads; for BM25-only chunks, batch
92
+ # fetch from Qdrant.
93
+ dense_map = {c.chunk_id: c for c in dense_chunks}
94
+ missing_ids = [cid for cid in top_ids if cid not in dense_map]
95
+ extra_payloads = retrieve_by_chunk_ids(missing_ids)
96
+
97
+ # 5. Build final list, score = RRF score
98
+ results: list[RetrievedChunk] = []
99
+ for cid in top_ids:
100
+ rrf_score = rrf_scores[cid]
101
+ if cid in dense_map:
102
+ # Reuse the dense RetrievedChunk; just swap the score for the
103
+ # fused one. model_copy keeps the object immutable-ish.
104
+ results.append(dense_map[cid].model_copy(update={"score": rrf_score}))
105
+ elif cid in extra_payloads:
106
+ results.append(payload_to_chunk(extra_payloads[cid], rrf_score))
107
+ else:
108
+ # Should not happen β€” a fused id with no source. Defensive skip.
109
+ continue
110
+ return results
111
+
112
+
113
+ # ── CLI ───────────────────────────────────────────────────────────────────
114
+ def main() -> None:
115
+ """Compare dense-only, BM25-only, and hybrid on a few canary queries."""
116
+ queries = [
117
+ "How did Apple's services revenue change in 2023?",
118
+ "Tesla R&D expense fiscal 2023",
119
+ "SG&A expense",
120
+ "supply chain risk",
121
+ ]
122
+ for q in queries:
123
+ print(f"\n=== {q!r} ===")
124
+ print(" Dense top-3:")
125
+ for c in dense_search(q, top_k=3):
126
+ print(f" {c.ticker} FY{c.fiscal_year} score={c.score:.3f} | {c.text[:60]}")
127
+ print(" BM25 top-3:")
128
+ for cid, score in lexical.search(q, top_k=3):
129
+ print(f" {cid} score={score:.3f}")
130
+ print(" Hybrid top-3:")
131
+ for c in hybrid_search(q, top_k=3):
132
+ print(f" {c.ticker} FY{c.fiscal_year} rrf={c.score:.4f} | {c.text[:60]}")
133
+
134
+
135
+ if __name__ == "__main__":
136
+ main()
backend/src/finrag/retrieval/lexical.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """BM25 lexical retrieval over the same chunks indexed in Qdrant.
2
+
3
+ Why this exists: dense embeddings (Cohere v3) lose exact-match signal on
4
+ years, tickers, GAAP terminology, and dollar amounts β€” exactly the tokens
5
+ that matter most in financial documents. BM25 catches these.
6
+
7
+ This module is *not* a full retriever. It returns (chunk_id, score) pairs.
8
+ The fusion + hydration into full RetrievedChunk objects happens in
9
+ retrieval.hybrid (Decision 10).
10
+
11
+ Pipeline:
12
+ build: data/processed/*.jsonl ──▢ tokenize ──▢ BM25Okapi
13
+ β”‚
14
+ β–Ό
15
+ pickled to data/bm25_index.pkl
16
+ load: pickle load β†’ ready to search
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import pickle
22
+ import re
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+ import numpy as np
27
+ from rank_bm25 import BM25Okapi
28
+
29
+ from finrag.ingestion.parse import PROCESSED_DIR, Chunk
30
+
31
+ # parse.py β†’ ingestion/ β†’ finrag/ β†’ src/ β†’ backend/ β†’ ROOT
32
+ REPO_ROOT = Path(__file__).resolve().parents[4]
33
+ INDEX_PATH = REPO_ROOT / "data" / "bm25_index.pkl"
34
+
35
+ # Token regex: any run of alphanumeric chars including underscores. Drops
36
+ # punctuation, splits on whitespace + symbols. Lowercased before splitting.
37
+ # Critical: this exact function is also called on queries β€” the same vocab
38
+ # must be used on both sides or no terms will match.
39
+ _TOKEN_RE = re.compile(r"\w+")
40
+
41
+
42
+ def tokenize(text: str) -> list[str]:
43
+ """Lowercase + simple word tokenization.
44
+
45
+ The same function runs on chunk text at index time and on user queries
46
+ at search time. Don't tweak one side without the other.
47
+ """
48
+ return _TOKEN_RE.findall(text.lower())
49
+
50
+
51
+ # ── On-disk format ────────────────────────────────────────────────────────
52
+ @dataclass
53
+ class _BM25Bundle:
54
+ """What we pickle. Separated so we can version the schema later.
55
+
56
+ `chunks` is a list of (id, ticker, fiscal_year, chunk_type) tuples β€” the
57
+ minimal payload we need for post-search filtering and lookups. The full
58
+ chunk text/metadata lives in Qdrant; storing it twice would double disk
59
+ use and risk drift between stores.
60
+ """
61
+
62
+ bm25: BM25Okapi
63
+ # Parallel arrays β€” index `i` in `bm25` corresponds to chunks[i].
64
+ # We use a tuple-list rather than a dict because BM25Okapi indexes by
65
+ # position, not by chunk_id.
66
+ chunk_ids: list[str]
67
+ tickers: list[str]
68
+ fiscal_years: list[int]
69
+ chunk_types: list[str]
70
+
71
+
72
+ # Pin __module__ so pickle records the dotted path "finrag.retrieval.lexical"
73
+ # instead of "__main__" when this file is run via `python -m`. Without this,
74
+ # the pickle is only loadable from the same entrypoint that built it.
75
+ _BM25Bundle.__module__ = "finrag.retrieval.lexical"
76
+
77
+
78
+ # ── Build ─────────────────────────────────────────────────────────────────
79
+ def _load_all_chunks(processed_dir: Path) -> list[Chunk]:
80
+ chunks: list[Chunk] = []
81
+ for jsonl in sorted(processed_dir.glob("*.jsonl")):
82
+ for line in jsonl.read_text(encoding="utf-8").splitlines():
83
+ if line.strip():
84
+ chunks.append(Chunk.model_validate_json(line))
85
+ return chunks
86
+
87
+
88
+ def build_index(processed_dir: Path = PROCESSED_DIR) -> _BM25Bundle:
89
+ """Build a BM25 index from all chunks in processed_dir and persist it."""
90
+ chunks = _load_all_chunks(processed_dir)
91
+ if not chunks:
92
+ raise RuntimeError(f"No chunks found in {processed_dir}")
93
+
94
+ print(f"Tokenizing {len(chunks)} chunks…")
95
+ tokenized = [tokenize(c.text) for c in chunks]
96
+
97
+ print("Building BM25Okapi index…")
98
+ # k1=1.5, b=0.75 are BM25's standard defaults. The rank_bm25 library
99
+ # exposes these as kwargs; leave them at defaults unless we have a
100
+ # specific reason β€” these are well-calibrated for English text and
101
+ # any tuning we'd do should be eval-driven, not guess-driven.
102
+ bm25 = BM25Okapi(tokenized)
103
+
104
+ bundle = _BM25Bundle(
105
+ bm25=bm25,
106
+ chunk_ids=[c.chunk_id for c in chunks],
107
+ tickers=[c.ticker for c in chunks],
108
+ fiscal_years=[c.fiscal_year for c in chunks],
109
+ chunk_types=[c.chunk_type for c in chunks],
110
+ )
111
+
112
+ INDEX_PATH.parent.mkdir(parents=True, exist_ok=True)
113
+ with INDEX_PATH.open("wb") as f:
114
+ pickle.dump(bundle, f, protocol=pickle.HIGHEST_PROTOCOL)
115
+ print(f"Wrote {INDEX_PATH} ({INDEX_PATH.stat().st_size / 1024:.0f} KB)")
116
+
117
+ return bundle
118
+
119
+
120
+ # ── Load + search ────────────────────────────────────────────────────────
121
+ _cached_bundle: _BM25Bundle | None = None
122
+
123
+
124
+ def load_index() -> _BM25Bundle:
125
+ """Load the pickled index from disk, caching in-process.
126
+
127
+ Module-level cache (not lru_cache) because the underlying BM25Okapi
128
+ object is heavyweight (~30 MB at our scale) β€” we want exactly one in
129
+ memory regardless of how many callers ask for it.
130
+ """
131
+ global _cached_bundle
132
+ if _cached_bundle is None:
133
+ if not INDEX_PATH.exists():
134
+ raise FileNotFoundError(
135
+ f"BM25 index not found at {INDEX_PATH}. "
136
+ "Run `uv run python -m finrag.retrieval.lexical` to build it."
137
+ )
138
+ with INDEX_PATH.open("rb") as f:
139
+ _cached_bundle = pickle.load(f)
140
+ return _cached_bundle
141
+
142
+
143
+ def search(
144
+ query: str,
145
+ top_k: int = 50,
146
+ ticker: str | None = None,
147
+ fiscal_year: int | None = None,
148
+ chunk_type: str | None = None,
149
+ ) -> list[tuple[str, float]]:
150
+ """Return ranked (chunk_id, score) tuples for a query.
151
+
152
+ Filtering is post-ranking: we ask BM25 for top-N (where N > top_k to
153
+ leave headroom after filtering), then drop chunks that don't match.
154
+ This is fine at our scale (~4k chunks); at 1M+ you'd want a filter-
155
+ aware index structure or pre-shard by ticker.
156
+ """
157
+ bundle = load_index()
158
+ tokens = tokenize(query)
159
+ if not tokens:
160
+ return []
161
+
162
+ # get_scores returns one score per indexed document, in index order
163
+ scores = bundle.bm25.get_scores(tokens)
164
+
165
+ # Build candidate list β€” over-fetch to allow for filter attrition.
166
+ # 4x is a heuristic; if filters are tight (e.g. one ticker Γ— one year),
167
+ # we may want more β€” but unbounded over-fetch defeats the purpose.
168
+ candidate_count = top_k * 4 if (ticker or fiscal_year or chunk_type) else top_k
169
+ candidate_count = min(candidate_count, len(scores))
170
+
171
+ # argpartition is O(n) vs argsort's O(n log n) β€” meaningful at scale.
172
+ # We get the top-K unordered, then sort just those K.
173
+ top_indices = np.argpartition(-scores, candidate_count - 1)[:candidate_count]
174
+ # Sort the candidates by descending score
175
+ top_indices = top_indices[np.argsort(-scores[top_indices])]
176
+
177
+ results: list[tuple[str, float]] = []
178
+ for i in top_indices:
179
+ if ticker and bundle.tickers[i] != ticker:
180
+ continue
181
+ if fiscal_year and bundle.fiscal_years[i] != fiscal_year:
182
+ continue
183
+ if chunk_type and bundle.chunk_types[i] != chunk_type:
184
+ continue
185
+ results.append((bundle.chunk_ids[i], float(scores[i])))
186
+ if len(results) >= top_k:
187
+ break
188
+
189
+ return results
190
+
191
+
192
+ # ── CLI ───────────────────────────────────────────────────────────────────
193
+ def main() -> None:
194
+ build_index()
195
+
196
+ # Sanity check: run a couple of test queries
197
+ print("\nSanity-check queries:")
198
+ for q in [
199
+ "services revenue 2023",
200
+ "iPhone net sales",
201
+ "SG&A expense",
202
+ "Tesla R&D",
203
+ ]:
204
+ results = search(q, top_k=3)
205
+ print(f"\n Q: {q!r}")
206
+ for chunk_id, score in results:
207
+ print(f" {chunk_id} score={score:.3f}")
208
+
209
+
210
+ if __name__ == "__main__":
211
+ main()
backend/src/finrag/retrieval/rerank.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Final-stage reranking via Cohere Rerank v3 (cross-encoder).
2
+
3
+ The reranker fixes the failure modes hybrid retrieval can't:
4
+ - Lexical accidents (BM25 dragging an entity-mismatched chunk to the top
5
+ because keyword co-occurrence happens to be high)
6
+ - Query-document interaction blindness (bi-encoders can't see "Apple" in
7
+ the query and "Tesla" in the candidate at the same time)
8
+
9
+ Flow:
10
+ hybrid_search(top_k=N_CANDIDATES) ──▢ list of RetrievedChunk
11
+ β”‚
12
+ β–Ό
13
+ Cohere Rerank v3
14
+ (rerank-english-v3.0)
15
+ β”‚
16
+ β–Ό
17
+ Reordered, top_k chosen
18
+ score = relevance_score (0–1)
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import time
24
+
25
+ from cohere.errors import TooManyRequestsError
26
+
27
+ from finrag.retrieval.hybrid import hybrid_search
28
+ from finrag.retrieval.vector import RetrievedChunk, get_cohere_client
29
+
30
+ RERANK_MODEL = "rerank-english-v3.0"
31
+
32
+ # Default candidate-pool size fed to the reranker. 50 is the sweet spot:
33
+ # wide enough that the reranker can rescue chunks ranked 20+ by hybrid,
34
+ # narrow enough to stay under Rerank v3's pricing tier (1 search unit per
35
+ # 100 docs) and latency budget (~200–400ms at 50 docs).
36
+ DEFAULT_CANDIDATES = 50
37
+
38
+ # Trial-key safety net β€” same pattern as ingestion/embed.py
39
+ RERANK_RETRY_INITIAL_BACKOFF_SECONDS = 5.0
40
+ RERANK_MAX_RETRIES = 4
41
+
42
+
43
+ def _rerank_with_retry(
44
+ query: str, documents: list[str], top_n: int
45
+ ) -> list[tuple[int, float]]:
46
+ """Call Cohere Rerank v3 with exponential-backoff retry on 429s.
47
+
48
+ Returns list of (original_index, relevance_score) in reranker order.
49
+ """
50
+ co = get_cohere_client()
51
+ backoff = RERANK_RETRY_INITIAL_BACKOFF_SECONDS
52
+
53
+ for attempt in range(1, RERANK_MAX_RETRIES + 1):
54
+ try:
55
+ response = co.rerank(
56
+ model=RERANK_MODEL,
57
+ query=query,
58
+ documents=documents,
59
+ top_n=top_n,
60
+ )
61
+ return [(r.index, r.relevance_score) for r in response.results]
62
+ except TooManyRequestsError:
63
+ if attempt == RERANK_MAX_RETRIES:
64
+ raise
65
+ print(
66
+ f" ⚠ rerank rate-limited; sleeping {backoff:.0f}s "
67
+ f"(retry {attempt}/{RERANK_MAX_RETRIES - 1})"
68
+ )
69
+ time.sleep(backoff)
70
+ backoff *= 2
71
+ raise RuntimeError("rerank retry loop exited without resolving")
72
+
73
+
74
+ def rerank_search(
75
+ question: str,
76
+ top_k: int = 5,
77
+ ticker: str | None = None,
78
+ fiscal_year: int | None = None,
79
+ chunk_type: str | None = None,
80
+ candidates: int = DEFAULT_CANDIDATES,
81
+ ) -> list[RetrievedChunk]:
82
+ """Hybrid retrieval + cross-encoder rerank. The user-facing default.
83
+
84
+ The `score` field on returned chunks is the rerank relevance_score
85
+ (0–1, semantically meaningful) β€” *not* the RRF score from the hybrid
86
+ stage. You can compare these across queries: 0.85 means "strongly
87
+ relevant" no matter what was asked.
88
+ """
89
+ # 1. Get a wide candidate pool from hybrid fusion. Pass the same filters
90
+ # through β€” scoping should happen before rerank, not after.
91
+ pool = hybrid_search(
92
+ question=question,
93
+ top_k=candidates,
94
+ ticker=ticker,
95
+ fiscal_year=fiscal_year,
96
+ chunk_type=chunk_type,
97
+ )
98
+ if not pool:
99
+ return []
100
+
101
+ # If we have fewer candidates than top_k, no reranking is meaningful β€”
102
+ # just return what we have.
103
+ if len(pool) <= top_k:
104
+ return pool
105
+
106
+ # 2. Cross-encoder rerank
107
+ documents = [c.text for c in pool]
108
+ ranked = _rerank_with_retry(
109
+ query=question,
110
+ documents=documents,
111
+ top_n=top_k,
112
+ )
113
+
114
+ # 3. Reassemble in rerank order with the rerank score replacing RRF
115
+ return [
116
+ pool[orig_idx].model_copy(update={"score": rel_score})
117
+ for orig_idx, rel_score in ranked
118
+ ]
119
+
120
+
121
+ # ── CLI ───────────────────────────────────────────────────────────────────
122
+ def main() -> None:
123
+ """Side-by-side: hybrid (RRF only) vs hybrid+rerank, same queries."""
124
+ queries = [
125
+ "How did Apple's services revenue change in 2023?",
126
+ "Tesla R&D expense fiscal 2023",
127
+ "SG&A expense",
128
+ "JPMorgan net interest income",
129
+ "supply chain risk",
130
+ ]
131
+ for q in queries:
132
+ print(f"\n=== {q!r} ===")
133
+ print(" Hybrid (RRF only) top-3:")
134
+ for c in hybrid_search(q, top_k=3):
135
+ print(
136
+ f" {c.ticker} FY{c.fiscal_year} rrf={c.score:.4f} | {c.text[:60]}"
137
+ )
138
+ print(" Hybrid + Rerank v3 top-3:")
139
+ for c in rerank_search(q, top_k=3):
140
+ print(
141
+ f" {c.ticker} FY{c.fiscal_year} rel={c.score:.3f} | {c.text[:60]}"
142
+ )
143
+
144
+
145
+ if __name__ == "__main__":
146
+ main()
backend/src/finrag/retrieval/vector.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dense vector retrieval over the Qdrant `finrag_chunks` collection.
2
+
3
+ This is the minimal Day-1 retriever: embed the query with Cohere v3
4
+ (`search_query` side of the asymmetric pair) and run a single nearest-
5
+ neighbor search with optional payload filtering. Hybrid (BM25 + dense)
6
+ and reranking come on Day 2.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from functools import lru_cache
12
+
13
+ import cohere
14
+ from pydantic import BaseModel
15
+ from qdrant_client import QdrantClient
16
+ from qdrant_client.models import (
17
+ FieldCondition,
18
+ Filter,
19
+ MatchValue,
20
+ )
21
+
22
+ from finrag.config import settings
23
+ from finrag.ingestion.embed import COHERE_MODEL, COLLECTION_NAME
24
+
25
+
26
+ # ── Public response model ─────────────────────────────────────────────────
27
+ class RetrievedChunk(BaseModel):
28
+ """One chunk surfaced by the retriever, with its similarity score.
29
+
30
+ All payload fields from the Chunk we indexed are copied through β€” so the
31
+ caller (or eventually, the agent/frontend) has everything it needs to
32
+ render a citation without joining back against another store.
33
+ """
34
+
35
+ chunk_id: str
36
+ score: float
37
+ text: str
38
+ chunk_type: str
39
+ section_title: str | None
40
+ ticker: str
41
+ company_name: str
42
+ fiscal_year: int
43
+ period_of_report: str
44
+ accession_number: str
45
+ sec_url: str
46
+
47
+
48
+ # ── Clients (one per process, cached) ─────────────────────────────────────
49
+ # lru_cache on a no-arg function is the canonical "singleton per process"
50
+ # pattern for FastAPI. Avoids re-creating TLS connections on every request.
51
+ @lru_cache(maxsize=1)
52
+ def get_cohere_client() -> cohere.ClientV2:
53
+ return cohere.ClientV2(api_key=settings.cohere_api_key)
54
+
55
+
56
+ @lru_cache(maxsize=1)
57
+ def get_qdrant_client() -> QdrantClient:
58
+ return QdrantClient(
59
+ url=settings.qdrant_url,
60
+ api_key=settings.qdrant_api_key,
61
+ )
62
+
63
+
64
+ # ── Query embedding ───────────────────────────────────────────────────────
65
+ def embed_query(text: str) -> list[float]:
66
+ """Embed a user query using the query-side encoder.
67
+
68
+ The matching `search_document` lives in ingestion/embed.py. Mismatching
69
+ these two silently degrades retrieval quality β€” there's no error, just
70
+ worse results. See Decision 6's notes on asymmetric retrieval.
71
+ """
72
+ co = get_cohere_client()
73
+ response = co.embed(
74
+ texts=[text],
75
+ model=COHERE_MODEL,
76
+ input_type="search_query",
77
+ embedding_types=["float"],
78
+ )
79
+ return response.embeddings.float_[0]
80
+
81
+
82
+ # ── Filter builder ────────────────────────────────────────────────────────
83
+ def _build_filter(
84
+ ticker: str | None = None,
85
+ fiscal_year: int | None = None,
86
+ chunk_type: str | None = None,
87
+ ) -> Filter | None:
88
+ """Translate simple kwarg filters into Qdrant's filter grammar.
89
+
90
+ Treats falsy values (None, "", 0) as "not provided" β€” important because
91
+ JSON clients (notably Swagger UI) often send "" for unset string fields
92
+ instead of omitting them, and we don't want to filter for ticker == "".
93
+ """
94
+ conditions: list[FieldCondition] = []
95
+ if ticker:
96
+ conditions.append(
97
+ FieldCondition(key="ticker", match=MatchValue(value=ticker))
98
+ )
99
+ if fiscal_year:
100
+ conditions.append(
101
+ FieldCondition(key="fiscal_year", match=MatchValue(value=fiscal_year))
102
+ )
103
+ if chunk_type:
104
+ conditions.append(
105
+ FieldCondition(key="chunk_type", match=MatchValue(value=chunk_type))
106
+ )
107
+ return Filter(must=conditions) if conditions else None
108
+
109
+
110
+ # ── Payload β†’ RetrievedChunk ──────────────────────────────────────────────
111
+ def payload_to_chunk(payload: dict, score: float) -> RetrievedChunk:
112
+ """Convert a Qdrant payload + score into a RetrievedChunk.
113
+
114
+ Shared by dense search and the hybrid retriever's hydration step β€” keeps
115
+ the mapping in one place so adding a field to the model means editing
116
+ one function, not three.
117
+ """
118
+ return RetrievedChunk(
119
+ chunk_id=payload["chunk_id"],
120
+ score=score,
121
+ text=payload["text"],
122
+ chunk_type=payload["chunk_type"],
123
+ section_title=payload.get("section_title"),
124
+ ticker=payload["ticker"],
125
+ company_name=payload["company_name"],
126
+ fiscal_year=payload["fiscal_year"],
127
+ period_of_report=payload["period_of_report"],
128
+ accession_number=payload["accession_number"],
129
+ sec_url=payload["sec_url"],
130
+ )
131
+
132
+
133
+ # ── Retrieval ─────────────────────────────────────────────────────────────
134
+ def search(
135
+ question: str,
136
+ top_k: int = 5,
137
+ ticker: str | None = None,
138
+ fiscal_year: int | None = None,
139
+ chunk_type: str | None = None,
140
+ ) -> list[RetrievedChunk]:
141
+ """Dense-only retrieval: embed the question, search Qdrant, return chunks.
142
+
143
+ Kept available for direct testing and the eval harness's comparison runs.
144
+ The user-facing /query endpoint uses hybrid_search instead.
145
+ """
146
+ qdrant = get_qdrant_client()
147
+ query_vector = embed_query(question)
148
+ query_filter = _build_filter(ticker, fiscal_year, chunk_type)
149
+
150
+ response = qdrant.query_points(
151
+ collection_name=COLLECTION_NAME,
152
+ query=query_vector,
153
+ query_filter=query_filter,
154
+ limit=top_k,
155
+ with_payload=True,
156
+ )
157
+ return [payload_to_chunk(p.payload, p.score) for p in response.points]
158
+
159
+
160
+ def retrieve_by_chunk_ids(chunk_ids: list[str]) -> dict[str, dict]:
161
+ """Batch-fetch payloads by chunk_id (used by hybrid hydration).
162
+
163
+ Returns a dict {chunk_id: payload}. Qdrant stores point IDs as uint64
164
+ (the hex chunk_id converted), so we convert on the way in and dereference
165
+ via the payload's own chunk_id field on the way out.
166
+
167
+ Ids that aren't valid 16-hex chunk_ids (e.g. a value the agent hallucinated
168
+ like 'chunk_5') are silently dropped rather than raising β€” a malformed id is
169
+ just a miss, so callers see it as not-found, not a crash.
170
+ """
171
+ if not chunk_ids:
172
+ return {}
173
+ qdrant = get_qdrant_client()
174
+ point_ids: list[int] = []
175
+ for cid in chunk_ids:
176
+ try:
177
+ point_ids.append(int(cid, 16))
178
+ except ValueError:
179
+ continue # not a hex chunk_id β†’ treat as not-found
180
+ if not point_ids:
181
+ return {}
182
+ points = qdrant.retrieve(
183
+ collection_name=COLLECTION_NAME,
184
+ ids=point_ids,
185
+ with_payload=True,
186
+ )
187
+ return {p.payload["chunk_id"]: p.payload for p in points}
backend/src/finrag/tools/__init__.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Agent tools β€” provider-neutral registry.
2
+
3
+ Each tool is a plain Python function (independently testable) plus a `ToolSpec`
4
+ describing its name, when-to-use text, and JSON-schema parameters. The spec is
5
+ deliberately NOT in any vendor's tool format: Decision 16 adapts these into
6
+ LangChain/LangGraph tools (which bind to Gemini or Claude alike), keeping the
7
+ provider seam from Decision 14 intact. Hardwiring Anthropic's tool_use shape
8
+ here β€” as the original handoff assumed β€” would have undone that.
9
+
10
+ `dispatch(name, args)` is the single call site the agent loop uses to run a
11
+ tool by name; it returns the tool's dict result unchanged.
12
+
13
+ Three tools, by design β€” see [[project_finrag_overview]]:
14
+ calculator β€” arithmetic, safe-eval (pure fn)
15
+ lookup_citation β€” re-fetch a chunk from Qdrant (read-only)
16
+ sql_query β€” NL β†’ SQL over DuckDB facts (sub-LLM; added next)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+ from typing import Any, Callable
23
+
24
+ from finrag.tools.calculator import calculator
25
+ from finrag.tools.citation import lookup_citation
26
+ from finrag.tools.sql import sql_query
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ToolSpec:
31
+ name: str
32
+ description: str
33
+ # JSON-schema "object" describing the args. Both Gemini and Anthropic
34
+ # accept this shape (modulo a thin adapter), as does LangChain.
35
+ parameters: dict[str, Any]
36
+ fn: Callable[..., dict[str, Any]]
37
+
38
+
39
+ TOOL_SPECS: list[ToolSpec] = [
40
+ ToolSpec(
41
+ name="calculator",
42
+ description=(
43
+ "Evaluate an arithmetic expression over numeric literals "
44
+ "(+ - * / // % ** and parentheses). Use for growth rates, margins, "
45
+ "sums, and ratios instead of doing mental math. Extract the numbers "
46
+ "from context first, then pass an expression like "
47
+ "'(383285 - 394328) / 394328 * 100'."
48
+ ),
49
+ parameters={
50
+ "type": "object",
51
+ "properties": {
52
+ "expression": {
53
+ "type": "string",
54
+ "description": "Arithmetic expression over numeric literals only.",
55
+ }
56
+ },
57
+ "required": ["expression"],
58
+ },
59
+ fn=calculator,
60
+ ),
61
+ ToolSpec(
62
+ name="lookup_citation",
63
+ description=(
64
+ "Re-fetch the full text and provenance of a single retrieved chunk "
65
+ "by its chunk_id. Use when you need to quote an exact figure or "
66
+ "re-read a chunk you cited earlier."
67
+ ),
68
+ parameters={
69
+ "type": "object",
70
+ "properties": {
71
+ "chunk_id": {
72
+ "type": "string",
73
+ "description": (
74
+ "The chunk_id of a previously retrieved chunk β€” the exact "
75
+ "value shown as (id=...) in that chunk's header. Do not "
76
+ "use the [N] anchor or invent an id."
77
+ ),
78
+ }
79
+ },
80
+ "required": ["chunk_id"],
81
+ },
82
+ fn=lookup_citation,
83
+ ),
84
+ ToolSpec(
85
+ name="sql_query",
86
+ description=(
87
+ "Query exact financial figures (revenue, net income, R&D, margins, "
88
+ "multi-year or cross-company comparisons) from the structured "
89
+ "financial_facts database. Pass a natural-language description of "
90
+ "the numbers you need; SQL is generated and run for you. Prefer this "
91
+ "over reading figures out of text chunks when precision matters."
92
+ ),
93
+ parameters={
94
+ "type": "object",
95
+ "properties": {
96
+ "natural_language": {
97
+ "type": "string",
98
+ "description": "Plain-language description of the figures to fetch.",
99
+ }
100
+ },
101
+ "required": ["natural_language"],
102
+ },
103
+ fn=sql_query,
104
+ ),
105
+ ]
106
+
107
+ # name β†’ spec, for O(1) dispatch.
108
+ TOOL_REGISTRY: dict[str, ToolSpec] = {spec.name: spec for spec in TOOL_SPECS}
109
+
110
+
111
+ def dispatch(name: str, args: dict[str, Any]) -> dict[str, Any]:
112
+ """Run tool `name` with keyword `args`. Returns the tool's dict result.
113
+
114
+ Unknown tool names return an error dict (not a raise) so a hallucinated
115
+ tool call degrades gracefully inside the agent loop.
116
+ """
117
+ spec = TOOL_REGISTRY.get(name)
118
+ if spec is None:
119
+ return {"error": f"Unknown tool {name!r}. Available: {list(TOOL_REGISTRY)}"}
120
+ try:
121
+ return spec.fn(**args)
122
+ except TypeError as e:
123
+ # Wrong/missing args from the model β€” surface as data, not a crash.
124
+ return {"error": f"Bad arguments for {name!r}: {e}"}
125
+
126
+
127
+ __all__ = ["ToolSpec", "TOOL_SPECS", "TOOL_REGISTRY", "dispatch"]
backend/src/finrag/tools/calculator.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """calculator(expression) β€” arithmetic on figures the model pulled from context.
2
+
3
+ Why this exists: LLMs are unreliable at multi-digit arithmetic (growth rates,
4
+ margins, sums across years). Far better to have the model *extract* the numbers
5
+ and delegate the math to real code.
6
+
7
+ Why not eval(): `eval("__import__('os').system('...')")` is remote code
8
+ execution. A figure could even arrive via a prompt-injected chunk. So we parse
9
+ to an AST and walk it, permitting ONLY numeric literals and arithmetic
10
+ operators β€” every other node type (names, calls, attributes, subscripts)
11
+ raises. This is an allowlist, not a blocklist: anything we didn't explicitly
12
+ permit is rejected by default.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import ast
18
+ import operator
19
+ from typing import Any
20
+
21
+ # Allowlisted operators β†’ their implementing functions. Anything not here
22
+ # (e.g. bitwise, matmul) is rejected.
23
+ _BIN_OPS: dict[type[ast.operator], Any] = {
24
+ ast.Add: operator.add,
25
+ ast.Sub: operator.sub,
26
+ ast.Mult: operator.mul,
27
+ ast.Div: operator.truediv,
28
+ ast.FloorDiv: operator.floordiv,
29
+ ast.Mod: operator.mod,
30
+ ast.Pow: operator.pow,
31
+ }
32
+ _UNARY_OPS: dict[type[ast.unaryop], Any] = {
33
+ ast.UAdd: operator.pos,
34
+ ast.USub: operator.neg,
35
+ }
36
+
37
+ # Guardrail: cap exponent magnitude so `10 ** 10**9` can't pin a CPU / OOM.
38
+ _MAX_EXPONENT = 1000
39
+
40
+
41
+ def _eval_node(node: ast.AST) -> float:
42
+ if isinstance(node, ast.Expression):
43
+ return _eval_node(node.body)
44
+ if isinstance(node, ast.Constant):
45
+ if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
46
+ raise ValueError(f"Only numeric literals allowed, got {node.value!r}")
47
+ return float(node.value)
48
+ if isinstance(node, ast.UnaryOp):
49
+ op = _UNARY_OPS.get(type(node.op))
50
+ if op is None:
51
+ raise ValueError(f"Operator {type(node.op).__name__} not allowed")
52
+ return op(_eval_node(node.operand))
53
+ if isinstance(node, ast.BinOp):
54
+ op = _BIN_OPS.get(type(node.op))
55
+ if op is None:
56
+ raise ValueError(f"Operator {type(node.op).__name__} not allowed")
57
+ left, right = _eval_node(node.left), _eval_node(node.right)
58
+ if isinstance(node.op, ast.Pow) and abs(right) > _MAX_EXPONENT:
59
+ raise ValueError(f"Exponent {right} exceeds limit {_MAX_EXPONENT}")
60
+ return op(left, right)
61
+ # Any other node β€” Name, Call, Attribute, Subscript, etc. β€” is rejected.
62
+ raise ValueError(f"Expression element {type(node).__name__} not allowed")
63
+
64
+
65
+ def calculator(expression: str) -> dict[str, Any]:
66
+ """Evaluate an arithmetic `expression` and return the numeric result.
67
+
68
+ Supports + - * / // % ** and parentheses over numeric literals only.
69
+ Returns {"result": <float>} on success or {"error": <message>} on failure
70
+ β€” tools return errors as data (not exceptions) so the agent can read the
71
+ message and retry rather than crashing the graph.
72
+ """
73
+ try:
74
+ tree = ast.parse(expression, mode="eval")
75
+ result = _eval_node(tree)
76
+ return {"result": result}
77
+ except ZeroDivisionError:
78
+ return {"error": "division by zero"}
79
+ except (ValueError, SyntaxError) as e:
80
+ return {"error": str(e)}
81
+
82
+
83
+ if __name__ == "__main__":
84
+ # Sanity: valid arithmetic, plus rejection of an injection attempt.
85
+ for expr in [
86
+ "(383285 - 394328) / 394328 * 100", # YoY % change
87
+ "200583 + 85200", # iPhone + Services
88
+ "2 ** 4000", # exponent guard
89
+ "__import__('os').system('echo pwned')", # must be rejected
90
+ ]:
91
+ print(f"{expr!r:50s} -> {calculator(expr)}")
backend/src/finrag/tools/citation.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """lookup_citation(chunk_id) β€” re-fetch one full chunk from Qdrant by id.
2
+
3
+ Why the agent needs this: retrieval hands the model a working set, but during
4
+ reasoning it may want to pull a specific chunk back in full β€” to quote an exact
5
+ figure, or to re-read a chunk it cited earlier in a longer tool loop. This is
6
+ the read-only "dereference a citation" primitive.
7
+
8
+ Thin wrapper over retrieval.vector.retrieve_by_chunk_ids β€” no new Qdrant
9
+ plumbing, just the single-id ergonomics and a not-found path that returns data
10
+ rather than raising (so the agent can recover).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from finrag.retrieval.vector import payload_to_chunk, retrieve_by_chunk_ids
18
+
19
+
20
+ def lookup_citation(chunk_id: str) -> dict[str, Any]:
21
+ """Return the full chunk for `chunk_id`, or an error dict if absent.
22
+
23
+ The score is 1.0 β€” it's an exact id fetch, not a similarity match; the
24
+ field exists only to reuse the RetrievedChunk shape the rest of the
25
+ system already speaks.
26
+ """
27
+ payloads = retrieve_by_chunk_ids([chunk_id])
28
+ payload = payloads.get(chunk_id)
29
+ if payload is None:
30
+ return {"error": f"No chunk found with id {chunk_id!r}"}
31
+ return {"chunk": payload_to_chunk(payload, score=1.0).model_dump()}
32
+
33
+
34
+ if __name__ == "__main__":
35
+ # A real id from the AAPL FY2023 net-sales table chunk (seen in /answer).
36
+ print(lookup_citation("d899f2e938bec647"))
37
+ print(lookup_citation("deadbeefdeadbeef")) # not-found path
backend/src/finrag/tools/sql.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """sql_query(natural_language) β€” NL β†’ SQL over the DuckDB financial_facts table.
2
+
3
+ This is the structured half of the project's "structured + unstructured"
4
+ thesis: precise numbers (revenue, margins, multi-year trends) come from XBRL
5
+ facts in DuckDB, not from prose chunks. The agent calls this when a question
6
+ wants exact figures or cross-year/cross-company comparison.
7
+
8
+ Pipeline: question β†’ sub-LLM writes SQL (schema in its prompt) β†’ safety guard β†’
9
+ read-only execute β†’ rows. The generated SQL is returned alongside the rows so
10
+ the frontend can show it verbatim (Decision 18 β€” half the demo's wow factor).
11
+
12
+ Security: a model writing SQL is an injection surface. Two layers of defense:
13
+ 1. read-only DuckDB connection (finrag.ingestion.facts.query) β€” blocks writes.
14
+ 2. statement guard below β€” single statement, must start SELECT/WITH, and a
15
+ blocklist rejects DDL/DML and DuckDB's file-reading table functions
16
+ (read_csv etc.) that a read-only conn would otherwise still allow.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from typing import Any
23
+
24
+ from finrag.ingestion.facts import CONCEPT_MAP, query
25
+ from finrag.llm import generate_text # provider-neutral dispatcher
26
+
27
+ MAX_ROWS = 100 # cap returned rows so a broad query can't flood the context
28
+
29
+ _LINE_ITEMS = ", ".join(sorted(CONCEPT_MAP))
30
+
31
+ # The sub-LLM's contract. It sees the exact schema + the canonical line_item
32
+ # vocabulary (pulled live from CONCEPT_MAP so it can never drift from the
33
+ # loader) + the conventions that make queries correct against this data.
34
+ _SQL_SYSTEM_PROMPT = f"""You translate questions about company financials into a single DuckDB SQL SELECT.
35
+
36
+ Table: financial_facts
37
+ Columns:
38
+ ticker TEXT -- e.g. 'AAPL', 'TSLA', 'JPM'
39
+ company_name TEXT
40
+ fiscal_year INTEGER -- e.g. 2023
41
+ fiscal_period TEXT -- 'FY' for full year; quarters are 'Q1'..'Q4'
42
+ period_end_date DATE
43
+ line_item TEXT -- canonical metric; one of: {_LINE_ITEMS}
44
+ gaap_concept TEXT -- raw XBRL concept
45
+ value DOUBLE -- the figure
46
+ unit TEXT -- 'USD' for money, 'USD/shares' for EPS, 'shares', etc.
47
+
48
+ Rules:
49
+ - Output ONLY the SQL. No prose, no markdown fences, no trailing semicolon.
50
+ - SELECT only. Never write/modify data.
51
+ - For money metrics filter unit = 'USD'. For annual figures filter fiscal_period = 'FY'.
52
+ - Use the canonical line_item values above β€” not raw GAAP concepts.
53
+ - Prefer explicit columns; add ORDER BY for multi-row/trend results.
54
+ - This table holds only TOP-LEVEL figures. If the question asks for a metric
55
+ that is NOT in the line_item list above β€” e.g. a segment/product/regional
56
+ figure such as services revenue, iPhone revenue, or Americas sales β€” do NOT
57
+ substitute a different metric. Output exactly: NO_QUERY
58
+
59
+ Examples:
60
+ Q: Apple's revenue in fiscal 2023
61
+ SELECT fiscal_year, value FROM financial_facts
62
+ WHERE ticker = 'AAPL' AND line_item = 'revenue' AND fiscal_period = 'FY' AND unit = 'USD' AND fiscal_year = 2023
63
+
64
+ Q: Tesla R&D spend over the last three years
65
+ SELECT fiscal_year, value FROM financial_facts
66
+ WHERE ticker = 'TSLA' AND line_item = 'rd_expense' AND fiscal_period = 'FY' ORDER BY fiscal_year
67
+ """
68
+
69
+ # Tokens that must never appear in a generated query. Word-boundary matched so
70
+ # they catch statements/functions but not substrings of column names.
71
+ _FORBIDDEN = re.compile(
72
+ r"\b(insert|update|delete|drop|alter|create|attach|detach|copy|install|"
73
+ r"load|pragma|set|call|export|read_csv|read_parquet|read_json|read_text|"
74
+ r"read_blob|glob|system)\b",
75
+ re.IGNORECASE,
76
+ )
77
+
78
+
79
+ def _clean_sql(raw: str) -> str:
80
+ """Strip markdown fences / stray prose the model may wrap around the SQL."""
81
+ s = raw.strip()
82
+ if s.startswith("```"):
83
+ # remove ```sql ... ``` fencing
84
+ s = re.sub(r"^```[a-zA-Z]*\n?", "", s)
85
+ s = re.sub(r"\n?```$", "", s).strip()
86
+ return s.rstrip(";").strip()
87
+
88
+
89
+ def _guard(sql: str) -> str | None:
90
+ """Return an error string if `sql` is unsafe, else None."""
91
+ if not sql:
92
+ return "empty query"
93
+ # Reject multiple statements (only one trailing-stripped statement allowed).
94
+ if ";" in sql.rstrip(";"):
95
+ return "multiple statements are not allowed"
96
+ head = sql.lstrip("(").lstrip().lower()
97
+ if not (head.startswith("select") or head.startswith("with")):
98
+ return "only SELECT/WITH queries are allowed"
99
+ if _FORBIDDEN.search(sql):
100
+ return "query contains a disallowed keyword or function"
101
+ return None
102
+
103
+
104
+ def sql_query(natural_language: str) -> dict[str, Any]:
105
+ """Answer a structured-data question by generating and running SQL.
106
+
107
+ Returns {"sql": <str>, "rows": [...], "row_count": n, "truncated": bool}
108
+ on success, or {"sql": <str?>, "error": <msg>} on failure β€” the SQL is
109
+ included even on error so the agent/UI can show what was attempted.
110
+ """
111
+ sql = _clean_sql(generate_text(_SQL_SYSTEM_PROMPT, natural_language))
112
+
113
+ # The sub-LLM signals "this metric isn't in the structured table" rather
114
+ # than silently substituting a different line_item (which previously made
115
+ # the agent report total revenue as "services revenue").
116
+ if sql.upper().startswith("NO_QUERY"):
117
+ return {
118
+ "sql": None,
119
+ "error": (
120
+ "requested metric is not in the financial_facts table "
121
+ "(likely a segment/product-level figure) β€” use narrative context instead"
122
+ ),
123
+ }
124
+
125
+ violation = _guard(sql)
126
+ if violation:
127
+ return {"sql": sql, "error": f"unsafe query rejected: {violation}"}
128
+
129
+ try:
130
+ rows = query(sql)
131
+ except Exception as e:
132
+ # DuckDB syntax/semantic errors β€” surface as data so the agent can
133
+ # re-ask rather than crashing the graph.
134
+ return {"sql": sql, "error": f"execution failed: {e}"}
135
+
136
+ truncated = len(rows) > MAX_ROWS
137
+ return {
138
+ "sql": sql,
139
+ "rows": rows[:MAX_ROWS],
140
+ "row_count": len(rows),
141
+ "truncated": truncated,
142
+ }
143
+
144
+
145
+ if __name__ == "__main__":
146
+ for q in [
147
+ "What was Apple's revenue in fiscal 2023?",
148
+ "Compare net income for Apple, Tesla, and JPMorgan in 2023",
149
+ "delete all the rows", # the model shouldn't, but guard is the backstop
150
+ ]:
151
+ print(f"\nQ: {q}")
152
+ print(sql_query(q))
backend/uv.lock ADDED
The diff for this file is too large to render. See raw diff
 
data/bm25_index.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b79cae1a8ce86873375bf1526e0d57e8bb93b7b97e7aaab9842a7e7e621535e5
3
+ size 3446024
data/duckdb/.gitkeep ADDED
File without changes
data/duckdb/finrag.duckdb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8f5cdf52732e332a0e4a400b5542323b15f8c2cc9bd5866ac98c26e92d688f31
3
+ size 1323008