Spaces:
Sleeping
Sleeping
Commit Β·
b2931f4
1
Parent(s): 1a88339
FinRAG backend
Browse files- .dockerignore +36 -0
- .gitattributes +1 -0
- Dockerfile +49 -0
- README.md +22 -5
- backend/.python-version +1 -0
- backend/README.md +0 -0
- backend/pyproject.toml +54 -0
- backend/src/finrag/__init__.py +0 -0
- backend/src/finrag/agent/__init__.py +13 -0
- backend/src/finrag/agent/graph.py +62 -0
- backend/src/finrag/agent/nodes.py +227 -0
- backend/src/finrag/agent/state.py +25 -0
- backend/src/finrag/config.py +53 -0
- backend/src/finrag/eval/__init__.py +0 -0
- backend/src/finrag/eval/dataset.py +180 -0
- backend/src/finrag/eval/harness.py +283 -0
- backend/src/finrag/eval/metrics.py +183 -0
- backend/src/finrag/eval/smoke.py +232 -0
- backend/src/finrag/guardrails.py +131 -0
- backend/src/finrag/ingestion/__init__.py +0 -0
- backend/src/finrag/ingestion/edgar.py +229 -0
- backend/src/finrag/ingestion/embed.py +212 -0
- backend/src/finrag/ingestion/facts.py +448 -0
- backend/src/finrag/ingestion/parse.py +292 -0
- backend/src/finrag/llm/__init__.py +109 -0
- backend/src/finrag/llm/base.py +143 -0
- backend/src/finrag/llm/claude.py +300 -0
- backend/src/finrag/llm/gemini.py +326 -0
- backend/src/finrag/llm/local.py +229 -0
- backend/src/finrag/main.py +226 -0
- backend/src/finrag/retrieval/__init__.py +0 -0
- backend/src/finrag/retrieval/hybrid.py +136 -0
- backend/src/finrag/retrieval/lexical.py +211 -0
- backend/src/finrag/retrieval/rerank.py +146 -0
- backend/src/finrag/retrieval/vector.py +187 -0
- backend/src/finrag/tools/__init__.py +127 -0
- backend/src/finrag/tools/calculator.py +91 -0
- backend/src/finrag/tools/citation.py +37 -0
- backend/src/finrag/tools/sql.py +152 -0
- backend/uv.lock +0 -0
- data/bm25_index.pkl +3 -0
- data/duckdb/.gitkeep +0 -0
- 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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|