From 1826c77775e6e250ac396b77d9f1ad046aa94849 Mon Sep 17 00:00:00 2001 From: orin Date: Fri, 22 May 2026 11:18:59 +0800 Subject: [PATCH] add financial fact foundation and roadmap --- docs/financial_fact_platform_roadmap.md | 586 ++++++++++++++++++++++++ scripts/eval.py | 219 ++++++++- src/agent/nodes.py | 16 + src/agent/state.py | 6 + src/api/routes.py | 10 + src/finance/facts.py | 142 ++++++ src/schemas/models.py | 62 +++ src/utils/metrics.py | 77 +++- tests/test_eval_script.py | 33 ++ tests/test_finance_facts.py | 88 ++++ tests/test_metrics.py | 37 ++ tests/test_routes_web.py | 35 ++ web/src/api/docs.ts | 9 + web/src/api/types.ts | 27 ++ 14 files changed, 1333 insertions(+), 14 deletions(-) create mode 100644 docs/financial_fact_platform_roadmap.md create mode 100644 src/finance/facts.py create mode 100644 tests/test_eval_script.py create mode 100644 tests/test_finance_facts.py diff --git a/docs/financial_fact_platform_roadmap.md b/docs/financial_fact_platform_roadmap.md new file mode 100644 index 0000000..2da881a --- /dev/null +++ b/docs/financial_fact_platform_roadmap.md @@ -0,0 +1,586 @@ +# Jetbot Financial Fact Platform 技术路线图 + +## 1. 背景与结论 + +Jetbot 当前已经具备较完整的财报 PDF Agent MVP 能力:PDF 上传、文本与表格抽取、三大表结构化、基础勾稽校验、关键注释、风险信号、深度分析、前端查看、Docker 化部署和黄金用例测试。下一阶段的主要瓶颈不是继续增加泛化 Agent 功能,而是把系统做成可被分析师、审计/咨询、投研数据团队信任的事实抽取平台。 + +推荐方向是 **Filing-to-Model Copilot**:从财报、10-K、10-Q、PDF、HTML、XBRL 中抽取可审计、可复核、可导出的 canonical financial facts,并用页码、表格、单元格、bbox、原文 quote 和引擎 trace 形成完整证据链。 + +核心路线: + +1. 建立真实 benchmark 和可量化质量门槛。 +2. 建立 canonical financial fact schema 和证据模型。 +3. 把当前 `FinancialStatement` 输出转换并持久化为事实层。 +4. 增加人工复核与修正闭环。 +5. 扩展 PDF/HTML/XBRL 多源 ingestion 和表格多引擎 router。 +6. 输出 Excel、CSV、JSON 和 API,服务 analyst workflow。 + +## 2. 产品定位 + +### 2.1 推荐首个产品楔子 + +首个商业化/试点方向建议锁定为: + +**美股 10-K / 10-Q Filing-to-Model Copilot** + +目标用户: + +- 买方/卖方分析师:需要快速把 filing 转成模型输入。 +- 审计、咨询、投行助理:需要可追溯的事实抽取和复核。 +- 数据/量化团队:需要标准化事实 API 和可校验证据。 + +首屏价值: + +- 上传或输入 filing。 +- 自动提取三大表和关键事实。 +- 每个事实可跳转到原始证据。 +- 支持人工修正。 +- 导出 evidence-linked Excel / CSV / JSON。 + +### 2.2 暂不优先做的功能 + +以下能力可以作为 P1/P2,但不应阻塞 P0: + +- 泛聊天式财报问答。 +- 自动投资建议或交易结论。 +- 多 Agent 辩论。 +- PPT 自动生成。 +- 大规模同业对比。 +- 租户计费、CRM、watchlist 等 SaaS 外围能力。 + +原因:当前阶段最重要的是事实准确率、证据链、人工复核和 benchmark。没有这些底座,更多 Agent 输出会放大不可信问题。 + +## 3. 当前能力基线 + +### 3.1 已具备能力 + +- API/CLI:`src/api/`、`src/cli.py`。 +- LangGraph pipeline:`src/agent/graph.py`、`src/agent/nodes.py`。 +- PDF 引擎:PyMuPDF / PDFium 抽象位于 `src/pdf/engine.py`。 +- 表格抽取:pdfplumber 位于 `src/pdf/tables.py`。 +- OCR:PaddleOCR/Tesseract abstraction 位于 `src/pdf/ocr.py`。 +- LLM provider routing:OpenAI、Anthropic、DeepSeek、Ollama、mock 位于 `src/llm/`。 +- 财务校验:`src/finance/validators.py`。 +- 风险信号:`src/finance/signals.py`。 +- 前端工作台:`web/src/views/DocumentDashboard.vue`。 +- PDFium 页面预览:`web/src/components/PdfViewer.vue`。 +- Golden tests 和 metrics:`tests/golden/`、`src/utils/metrics.py`、`scripts/eval.py`。 + +### 3.2 主要缺口 + +- `SourceRef` 原本只有 page/table/quote/confidence,不能精确到 row/col/bbox/engine/artifact。 +- 表格单元格缺少 bbox、rowspan、colspan、engine 和 confidence。 +- 没有一等公民 `FinancialFact`,三大表输出无法直接支撑 Excel/API 和人工修正。 +- 没有 correction audit log。 +- OCR 已能产生 bbox,但 pipeline 中被压平成文本,证据粒度丢失。 +- `scripts/eval.py` 原本只是跑 pytest,没有机器可读评测报告和 fact-level metrics。 +- 前端 evidence click 只能跳页,不能高亮具体 bbox 或表格单元格。 +- 还没有 SEC/XBRL/HTML ingestion,也没有表格多引擎 router。 + +## 4. 已完成的第一实现切片 + +本路线图的第一切片已经在当前分支 `feat/financial-fact-foundation` 中实现,目标是为后续人工复核、导出和 benchmark 建立事实层底座。 + +### 4.1 Schema 与证据模型 + +已在 `src/schemas/models.py` 中完成: + +- 扩展 `SourceRef`:`row`、`col`、`bbox`、`engine`、`artifact_path`。 +- 扩展 `TableCell`:`rowspan`、`colspan`、`bbox`、`confidence`、`engine`。 +- 新增 `FinancialFact`:承载 canonical concept、raw label、value、unit、scale、currency、period、source_refs、confidence、extraction_engine、metadata。 +- 新增 `ExtractionTrace`:记录引擎、stage、status、耗时、metrics、source_refs 和错误。 +- 新增 `Correction`:记录 fact 字段修正、old/new value、actor、reason、timestamp 和证据。 + +兼容策略:新增字段均为 optional 或有默认值,避免破坏旧 API 和旧 JSON。 + +### 4.2 Canonical facts 转换层 + +已新增 `src/finance/facts.py`: + +- `facts_from_statements(doc_id, statements)`:把当前 `FinancialStatement` 转换为 `FinancialFact`。 +- `apply_corrections(facts, corrections)`:根据 correction 生成 effective facts。 +- 自动保留 line item evidence。 +- 自动推断 period type:balance 为 `instant`,income/cashflow 为 `duration`。 +- 自动从 unit 推断 scale,例如 USD millions -> 1,000,000。 +- 使用稳定 hash 生成 `fact_id`,便于后续 correction 绑定。 + +### 4.3 Pipeline 持久化 + +已更新: + +- `src/agent/state.py`:新增 `facts`、`corrections`、`extraction_traces`。 +- `src/agent/nodes.py`:`finalize()` 阶段自动生成并保存 `extracted/facts.json`。 +- `_save_partial_results()`:失败时如已有 facts 也会保存 partial facts。 + +### 4.4 API 与前端类型 + +已更新: + +- `src/api/routes.py`:新增 `GET /v1/documents/{doc_id}/facts`。 +- `web/src/api/types.ts`:新增 `FinancialFact` 类型,扩展 `SourceRef` 和 `TableCell`。 +- `web/src/api/docs.ts`:新增 `docsApi.facts(docId)`,并规范化 richer source refs。 + +### 4.5 Eval 与指标 + +已更新: + +- `src/utils/metrics.py`:新增 `fact_value_accuracy()`、`fact_source_ref_completeness()`,并接入 `compute_golden_metrics()`。 +- `scripts/eval.py`:升级为评测 runner,可执行 golden cases,输出 `eval_report.json` 和 `eval_report.md`。 +- 默认强制 mock LLM,避免评测误打真实 API。 +- 支持 `--skip-pytest` 和 `--output-dir`。 + +### 4.6 测试覆盖 + +已新增/更新: + +- `tests/test_finance_facts.py`:覆盖 fact 转换、证据保留、total fallback、correction 应用。 +- `tests/test_eval_script.py`:覆盖 eval report 写入与失败状态。 +- `tests/test_metrics.py`:覆盖 fact-level metrics。 +- `tests/test_routes_web.py`:覆盖 facts endpoint。 + +## 5. 90 天技术路线 + +### Phase 0:产品聚焦与质量门槛,Week 0-1 + +目标:明确只为可信 fact extraction 服务,不让泛 Agent 功能分散优先级。 + +交付项: + +1. 固化首个 ICP:US 10-K / 10-Q analyst workflow。 +2. 明确 P0 输出:facts、evidence、review、Excel/API。 +3. 定义质量门槛: + - 关键 facts 数值准确率。 + - line-item precision/recall/F1。 + - source-ref completeness。 + - bbox/cell evidence coverage。 + - balance equation pass rate。 + - 人工复核平均耗时。 + - 每文档成本与耗时。 +4. 建立 benchmark 数据政策:真实 PDF 与人工标签默认不入 git,只提交 manifest、匿名标签、合成 fixture 和指标结果。 + +验收标准: + +- 文档和 README 中明确 Jetbot 的下一阶段定位。 +- 每个 P0 feature 都能映射到质量指标。 +- 不把真实敏感 PDF 提交到仓库。 + +### Phase 1:Benchmark 与 Eval CI,Week 1-2 + +目标:先能度量,再谈优化。 + +交付项: + +1. 扩展 `tests/golden/`:保留合成 deterministic cases。 +2. 新增 benchmark manifest schema: + - document id。 + - source type。 + - expected facts。 + - expected evidence。 + - expected notes。 + - expected risk labels。 +3. 扩展 `scripts/eval.py`: + - 支持本地 benchmark dataset。 + - 输出 JSON/Markdown。 + - 支持阈值 gate。 + - 支持 CI-friendly exit code。 +4. 首批 benchmark: + - 20 个 US 10-K/10-Q。 + - 10 个 HK/A-share/Japan PDF 作为 stretch。 + - 10 个扫描件/复杂表格 PDF 作为 stress cases。 +5. 先围绕 10 个关键 facts 建立准确率:revenue、gross profit、operating income、net income、total assets、total liabilities、total equity、operating cash flow、capex、cash and equivalents。 + +验收标准: + +- `python scripts/eval.py --output-dir data/eval-dev` 可生成报告。 +- 报告包含 document-level 与 aggregate metrics。 +- synthetic golden gate 可稳定在 CI 中运行。 +- real PDF benchmark 可本地运行,且不会把敏感样本提交到 git。 + +### Phase 2:Canonical Fact Schema 与证据模型,Week 2-4 + +目标:把 Jetbot 的主输出从 report 转成 facts + evidence。 + +交付项: + +1. 完善 `FinancialFact`: + - company、ticker、CIK、filing type。 + - statement type。 + - canonical concept。 + - raw label。 + - value、unit、scale、currency。 + - period start/end、period type。 + - confidence。 + - extraction engine。 + - source refs。 +2. 完善 `SourceRef`: + - page。 + - table_id。 + - row/col。 + - bbox。 + - quote。 + - artifact path。 + - engine。 + - confidence。 +3. 增加 fact validation: + - missing critical facts。 + - duplicate concepts。 + - period consistency。 + - scale/currency consistency。 + - balance equation。 + - cashflow reconciliation。 +4. 将 facts 作为 API 和导出的主数据结构。 + +验收标准: + +- 所有关键 facts 都能带至少一个 source ref。 +- evidence fields 在 API 和前端类型中一致。 +- 旧 `FinancialStatement` API 继续兼容。 +- facts 能在 pipeline 成功和 partial failure 时保存。 + +### Phase 3:Evidence Review 与人工修正,Week 3-5 + +目标:让用户能复核、修正、沉淀 ground truth。 + +交付项: + +1. 后端 correction endpoints: + - `GET /v1/documents/{doc_id}/corrections`。 + - `POST /v1/documents/{doc_id}/facts/{fact_id}/corrections`。 + - `GET /v1/documents/{doc_id}/facts/effective`。 +2. 前端 review workspace: + - 左侧 PDFium page image。 + - 右侧 facts/table/statements。 + - 点击 fact 跳页并高亮 bbox/cell。 +3. `PdfViewer` overlay: + - bbox 坐标归一化。 + - DPI 改变时仍能正确映射。 + - 支持多个高亮。 +4. `EvidenceLink` 扩展: + - 显示 page/table/row/col。 + - tooltip 展示 quote 和 engine。 +5. Correction audit: + - actor。 + - timestamp。 + - old/new value。 + - reason。 + - source_refs。 +6. 修正结果回流 eval dataset。 + +验收标准: + +- 用户能修正 value、concept、unit、period、evidence。 +- 修正后 facts/export/API 一致。 +- correction history 不丢失。 +- 点击证据不会触发浏览器下载或新窗口弹窗。 + +### Phase 4:表格多引擎 Router,Week 5-7 + +目标:从单一 pdfplumber 升级为可评测、可回退的多引擎表格抽取层。 + +交付项: + +1. 新增 `TableExtractor` protocol。 +2. 将 pdfplumber 包成默认 extractor。 +3. 增加可选 extractor: + - Camelot:适合 ruled tables。 + - Docling/Marker:适合 layout-heavy PDFs。 + - OCR layout:适合扫描件。 + - LLM vision fallback:仅用于低置信度关键表。 +4. 新增 router: + - 根据 text density、page image、table confidence、OCR need、page rotation、statement section 选择引擎。 +5. 扩展 `Table`/`TableCell`: + - bbox。 + - rowspan/colspan。 + - engine。 + - confidence。 + - extraction settings。 +6. 新增 table-level metrics: + - table recall。 + - cell exact match。 + - numeric match。 + - critical fact recovery rate。 + +验收标准: + +- 每个 engine adapter 输出统一 `Table` schema。 +- router 选择路径可解释并记录 trace。 +- 低置信度表格有 fallback 机制。 +- eval report 能按 engine 统计表现。 + +### Phase 5:SEC/XBRL/HTML Ingestion 与 Taxonomy,Week 7-9 + +目标:优先使用结构化来源,降低 PDF 抽取不确定性。 + +交付项: + +1. 新增 `src/filings/`: + - SEC ticker/CIK lookup。 + - filing manifest download。 + - HTML tables parsing。 + - XBRL facts extraction。 +2. 定义 source priority: + - XBRL/HTML。 + - PDF text layer。 + - PDF table extraction。 + - OCR/layout。 + - LLM fallback。 +3. 新增 `src/finance/taxonomy.py`: + - revenue。 + - cost_of_revenue。 + - gross_profit。 + - operating_expense。 + - operating_income。 + - interest_expense。 + - pretax_income。 + - income_tax。 + - net_income。 + - EPS。 + - cash。 + - receivables。 + - inventory。 + - total_assets。 + - debt。 + - total_liabilities。 + - total_equity。 + - CFO。 + - capex。 + - FCF。 +4. Raw label mapping: + - deterministic aliases first。 + - LLM-assisted mapping only for low confidence。 + - preserve raw label and mapping evidence。 +5. Period/scale/currency normalization: + - US GAAP。 + - IFRS。 + - Chinese labels。 + +验收标准: + +- SEC/XBRL fixtures 全部 network-free。 +- 同一 document 的多来源 fact 能 dedupe。 +- source priority 和 fallback trace 可审计。 +- Taxonomy mapping 有单元测试和 benchmark 指标。 + +### Phase 6:Analyst 输出与集成,Week 9-10 + +目标:输出可直接进入分析师模型和数据管道的文件/API。 + +交付项: + +1. Facts JSON endpoint。 +2. CSV export。 +3. Excel export: + - income statement。 + - balance sheet。 + - cashflow。 + - canonical facts。 + - validation issues。 + - risk signals。 + - source links。 +4. 前端下载入口: + - Dashboard actions。 + - Report panel actions。 +5. API docs: + - facts response sample。 + - correction response sample。 + - export columns。 + +验收标准: + +- Excel/CSV/JSON 字段稳定。 +- Export 结果包含 evidence link 或 source metadata。 +- 修正后的 effective facts 能反映到 export。 +- Markdown report 不再是唯一主输出。 + +### Phase 7:生产化、任务治理与观测,Week 10-12 + +目标:支撑 pilot 和稳定部署。 + +交付项: + +1. 收敛全局 in-memory state cache: + - 多进程 API/Celery 不应丢状态。 + - partial artifacts 显式持久化。 +2. 任务治理: + - cancel。 + - timeout cleanup。 + - retry policy visibility。 + - orphan recovery。 +3. 文档级 metrics: + - extraction engine mix。 + - pages processed。 + - tables found。 + - facts extracted。 + - correction count。 + - validation pass/fail。 + - LLM calls/tokens/cost。 + - node latency。 + - final confidence。 +4. Pilot security: + - file isolation。 + - audit log。 + - export access control。 + - secret handling。 + +验收标准: + +- 任务失败后可定位失败阶段和 partial output。 +- 页面能展示任务进度、失败原因和可恢复动作。 +- Prometheus/OpenTelemetry 指标覆盖核心 pipeline。 +- Pilot 用户可安全上传和导出。 + +### Phase 8:Pilot 闭环,Week 11-12 + +目标:用真实用户验证产品价值,并把失败样本变成 benchmark。 + +试点对象: + +- 1-2 名买方/卖方分析师。 +- 1 名审计/咨询用户。 +- 1 名财务/数据运营用户。 +- 可选 1 名量化/数据工程用户。 + +需要衡量: + +- 每份报告节省多少建模时间。 +- 哪些 facts 修正率最高。 +- 哪些错误不可接受。 +- 用户是否信任 evidence UI。 +- 用户实际需要 Excel、CSV、JSON 还是 API。 +- 部署约束:本地、私有云、Docker、API。 +- 付费意愿与最小可售包装。 + +验收标准: + +- 每个 pilot failure 都能进入 issue 或 benchmark case。 +- 至少形成一份产品方向复盘。 +- 决定继续深挖 Filing-to-Model,还是切向 Disclosure Review Assistant / standardized facts API。 + +## 6. P0 / P1 / P2 优先级 + +### P0:必须优先完成 + +- Real benchmark + eval thresholds。 +- `FinancialFact` 一等公民。 +- Evidence schema:page/table/row/col/bbox/quote/engine/confidence。 +- Facts API。 +- Human correction API 和 audit log。 +- Evidence review UI。 +- Excel/CSV/JSON export。 +- Table extraction router 的最小版本。 + +### P1:有 P0 后再做 + +- SEC/XBRL/HTML ingestion。 +- Taxonomy mapping 扩展。 +- 多引擎表格 benchmark。 +- Task cancellation 和 orphan recovery。 +- Observability dashboard。 +- Pilot security hardening。 + +### P2:后续增强 + +- 多市场财报标准化。 +- Watchlist/batch processing。 +- Peer comparison。 +- PPT/briefing generation。 +- 多 Agent critique。 +- SaaS tenant、billing、quota。 + +## 7. 文件级实施矩阵 + +| 范围 | 文件 | 动作 | +| --- | --- | --- | +| Schema | `src/schemas/models.py` | 扩展 `SourceRef`、`TableCell`,新增 `FinancialFact`、`ExtractionTrace`、`Correction` | +| Fact layer | `src/finance/facts.py` | 从 statements 转 facts,应用 corrections,后续接 taxonomy | +| State | `src/agent/state.py` | 增加 facts/corrections/extraction_traces | +| Pipeline | `src/agent/nodes.py` | finalize 保存 facts,失败时保存 partial facts | +| Validation | `src/finance/validators.py` | 增加 fact-level validation、period/scale/currency checks | +| Tables | `src/pdf/tables.py` | 拆成 extractor protocol + router | +| OCR | `src/pdf/ocr.py` | 保留 OCR bbox 到 evidence,不再只拼文本 | +| Filing | `src/filings/` | 新增 SEC/HTML/XBRL ingestion | +| API | `src/api/routes.py` | facts、corrections、exports、task recovery endpoints | +| Storage | `src/storage/*` | facts/corrections/traces/export artifact 持久化 | +| Eval | `scripts/eval.py` | benchmark runner、thresholds、JSON/Markdown report | +| Metrics | `src/utils/metrics.py` | fact accuracy、evidence coverage、table metrics、cost/latency | +| Frontend API | `web/src/api/types.ts`、`web/src/api/docs.ts` | facts/corrections/export types and clients | +| Frontend UI | `web/src/views/DocumentDashboard.vue` | evidence review workspace | +| PDF Viewer | `web/src/components/PdfViewer.vue` | bbox/cell overlay highlight | +| Evidence | `web/src/components/EvidenceLink.vue` | jump + highlight + metadata tooltip | +| Statements | `web/src/components/StatementsPanel.vue` | facts table、validation state、correction actions | +| Tables | `web/src/components/TablesPanel.vue` | engine/confidence/bbox/cell review | +| Docs | `README.md`、`docs/architecture_and_capabilities.md` | 更新产品定位和能力说明 | + +## 8. 关键验收命令 + +后端: + +```bash +python -m ruff check src tests scripts +python -m mypy src --ignore-missing-imports +python -m pytest -q --timeout=60 +python scripts/eval.py --output-dir data/eval-dev +``` + +前端: + +```bash +cd web +npm run lint +npm run typecheck +npm run build +``` + +Docker smoke: + +```bash +docker compose up --build +``` + +浏览器验证: + +- 打开 `http://127.0.0.1:18000/ui/#/`。 +- 上传 PDF。 +- 等待任务完成。 +- 查看 facts endpoint 是否返回数据。 +- 点击 evidence 后定位到对应 PDF 页。 +- 后续实现 bbox 后应高亮具体证据区域。 + +## 9. 风险与约束 + +### 9.1 准确率风险 + +风险:PDF 表格结构复杂、扫描件质量差、LLM 结构化输出不稳定。 + +缓解:优先结构化来源;引入 table router;所有 LLM 输出必须绑定 evidence;低 confidence 必须进入人工复核。 + +### 9.2 数据合规风险 + +风险:真实财报和人工标签可能包含授权或隐私约束。 + +缓解:真实 PDF 不入 git;benchmark 使用 manifest;必要时使用匿名标签或外部私有数据目录。 + +### 9.3 产品边界风险 + +风险:过早转向聊天、投资建议或多 Agent 展示,导致核心信任问题未解决。 + +缓解:P0 只围绕 facts/evidence/review/export。 + +### 9.4 工程复杂度风险 + +风险:XBRL、HTML、PDF、OCR、LLM vision 同时推进会扩大复杂度。 + +缓解:按 source priority 分阶段接入;每个 engine 必须经过统一 schema 和 eval。 + +## 10. 下一步推荐执行顺序 + +1. 完成 correction API 和 effective facts。 +2. 在前端增加 facts tab 或 review panel。 +3. 给 `PdfViewer` 增加 bbox overlay。 +4. 给 `EvidenceLink` 增加 row/col/bbox payload。 +5. 增加 Excel/CSV/JSON export。 +6. 扩展 benchmark manifest 和 threshold gate。 +7. 开始 table router protocol。 +8. 再接 SEC/XBRL/HTML ingestion。 + +这一路线的判断标准很简单:每增加一个能力,都必须让 facts 更准确、证据更可审计、复核更省时间、输出更能进入真实 analyst workflow。 \ No newline at end of file diff --git a/scripts/eval.py b/scripts/eval.py index 8b1b935..e5747f6 100644 --- a/scripts/eval.py +++ b/scripts/eval.py @@ -1,25 +1,218 @@ -"""Evaluation CLI: runs golden tests and reports accuracy metrics.""" +"""Evaluation CLI: runs golden cases and reports accuracy metrics.""" from __future__ import annotations +import argparse +import importlib.util import json +import os import sys +from datetime import datetime, timezone from pathlib import Path +from typing import Any, Sequence -def main(): - """Run golden test suite and print metrics summary.""" - import subprocess +DEFAULT_OUTPUT_DIR = Path("data") / "eval" + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run Jetbot financial extraction evaluation.") + parser.add_argument("--output-dir", default=str(DEFAULT_OUTPUT_DIR), help="Directory for eval artifacts.") + parser.add_argument("--skip-pytest", action="store_true", help="Skip pytest golden gate and only compute metrics.") + parser.add_argument("--allow-real-llm", action="store_true", help="Do not force the mock LLM provider.") + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv) + if not args.allow_real_llm: + _force_mock_llm() + + output_dir = Path(args.output_dir) + started_at = _utc_now() + pytest_result = None if args.skip_pytest else _run_pytest_gate() + case_results = _run_golden_cases(output_dir) + metrics = _compute_metrics(case_results) + finished_at = _utc_now() + report = build_eval_report( + metrics=metrics, + case_results=case_results, + pytest_result=pytest_result, + started_at=started_at, + finished_at=finished_at, + ) + write_eval_report(report, output_dir) + print(render_markdown_report(report)) + if pytest_result and pytest_result["exit_code"] != 0: + return int(pytest_result["exit_code"]) + return 0 + + +def build_eval_report( + *, + metrics: dict[str, Any], + case_results: list[dict[str, Any]], + pytest_result: dict[str, Any] | None, + started_at: str, + finished_at: str, +) -> dict[str, Any]: + status = "passed" + if pytest_result and pytest_result["exit_code"] != 0: + status = "failed" + return { + "schema_version": 1, + "suite": "golden", + "status": status, + "started_at": started_at, + "finished_at": finished_at, + "metrics": metrics, + "cases": [_case_summary(case) for case in case_results], + "pytest": pytest_result, + } - result = subprocess.run( - [sys.executable, "-m", "pytest", "tests/golden/", "-v", "--tb=short", "-q"], - capture_output=True, - text=True, + +def render_markdown_report(report: dict[str, Any]) -> str: + metrics = report["metrics"] + lines = [ + "# Jetbot Evaluation Report", + "", + f"Status: **{report['status']}**", + f"Suite: `{report['suite']}`", + f"Cases: {metrics.get('n_cases', 0)}", + "", + "## Metrics", + "", + ] + for key, value in metrics.items(): + lines.append(f"- `{key}`: {_format_metric(value)}") + lines.extend(["", "## Cases", ""]) + for case in report["cases"]: + lines.append( + f"- `{case['name']}`: facts={case['fact_count']}, " + f"statements={','.join(case['statement_types']) or 'none'}, errors={len(case['errors'])}" + ) + return "\n".join(lines) + "\n" + + +def write_eval_report(report: dict[str, Any], output_dir: Path) -> None: + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / "eval_report.json").write_text( + json.dumps(report, ensure_ascii=False, indent=2), + encoding="utf-8", ) - print(result.stdout) - if result.stderr: - print(result.stderr) - sys.exit(result.returncode) + (output_dir / "eval_report.md").write_text(render_markdown_report(report), encoding="utf-8") + + +def _case_summary(case: dict[str, Any]) -> dict[str, Any]: + return { + "name": case["name"], + "statement_types": case["statement_types"], + "fact_count": case["fact_count"], + "note_count": len(case.get("notes", [])), + "risk_signal_count": len(case.get("risk_signals", [])), + "errors": case["errors"], + } + + +def _run_pytest_gate() -> dict[str, Any]: + import subprocess + + command = [sys.executable, "-m", "pytest", "tests/golden/", "-v", "--tb=short", "-q"] + result = subprocess.run(command, capture_output=True, text=True, check=False) + return { + "command": command, + "exit_code": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + + +def _run_golden_cases(output_dir: Path) -> list[dict[str, Any]]: + from src.agent.graph import build_graph + from src.agent.state import AgentState + from src.finance.facts import facts_from_statements + from src.schemas.models import DocumentMeta, Page + + graph = build_graph() + results: list[dict[str, Any]] = [] + for case in _load_golden_cases(): + case_dir = output_dir / "artifacts" + pages = [Page(page_number=p["page_number"], text=p["text"], images=[]) for p in case["pages"]] + state = AgentState( + doc_meta=DocumentMeta(doc_id=f"eval-{case['name']}", filename=f"{case['name']}.pdf"), + pdf_path=None, + data_dir=str(case_dir), + debug={"fake_pages": pages}, + ) + result = AgentState.model_validate(graph.invoke(state.model_dump())) + facts = result.facts or facts_from_statements(result.doc_meta.doc_id, result.statements) + expected_facts = _expected_facts(case.get("expected_statements", {})) + results.append({ + "name": case["name"], + "statements": result.statements, + "facts": facts, + "notes": result.notes, + "risk_signals": result.risk_signals, + "expected_totals": case.get("expected_statements", {}), + "expected_facts": expected_facts, + "expected_note_types": set(case.get("expected_note_types", [])), + "expected_signal_categories": set(case.get("expected_signal_categories", [])), + "statement_types": sorted(result.statements), + "fact_count": len(facts), + "errors": result.errors, + }) + return results + + +def _compute_metrics(case_results: list[dict[str, Any]]) -> dict[str, Any]: + from src.utils.metrics import compute_golden_metrics + + return compute_golden_metrics(case_results) + + +def _load_golden_cases() -> list[dict[str, Any]]: + conftest_path = Path("tests") / "golden" / "conftest.py" + spec = importlib.util.spec_from_file_location("jetbot_golden_conftest", conftest_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Unable to load golden cases from {conftest_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + golden_cases = module.golden_cases + + wrapped = getattr(golden_cases, "__pytest_wrapped__", None) + if wrapped is not None and getattr(wrapped, "obj", None) is not None: + return wrapped.obj() + raw = getattr(golden_cases, "__wrapped__", None) + if raw is not None: + return raw() + return golden_cases() + + +def _expected_facts(expected_statements: dict[str, dict[str, float]]) -> dict[str, float]: + expected: dict[str, float] = {} + for statement_type, totals in expected_statements.items(): + for concept, value in totals.items(): + expected[f"{statement_type}:{concept}"] = value + return expected + + +def _force_mock_llm() -> None: + os.environ["LLM_DEFAULT_MODEL"] = "mock:mock" + os.environ.pop("OPENAI_API_KEY", None) + os.environ.pop("ANTHROPIC_API_KEY", None) + from src.llm.base import reset_llm_client + + reset_llm_client() + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _format_metric(value: Any) -> str: + if isinstance(value, float): + return f"{value:.4f}" + return str(value) if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/src/agent/nodes.py b/src/agent/nodes.py index 02bb0d5..66fd117 100644 --- a/src/agent/nodes.py +++ b/src/agent/nodes.py @@ -11,6 +11,7 @@ from src.agent.adapters.hermes import get_hermes_agent_client from src.agent.context import build_analysis_context as build_analysis_context_payload from src.agent.state import AgentState +from src.finance.facts import facts_from_statements from src.finance.normalizer import normalize_account_name from src.finance.signals import generate_signals from src.finance.utils import table_rows @@ -557,6 +558,21 @@ def finalize(state: AgentState) -> AgentState: store.save_json(state.doc_meta.doc_id, "extracted/pages.json", [p.model_dump() for p in state.pages]) store.save_json(state.doc_meta.doc_id, "extracted/tables.json", [t.model_dump() for t in state.tables]) store.save_json(state.doc_meta.doc_id, "extracted/statements.json", {k: v.model_dump() for k, v in state.statements.items()}) + if not state.facts: + state.facts = facts_from_statements(state.doc_meta.doc_id, state.statements) + store.save_json(state.doc_meta.doc_id, "extracted/facts.json", [fact.model_dump(mode="json") for fact in state.facts]) + if state.corrections: + store.save_json( + state.doc_meta.doc_id, + "extracted/corrections.json", + [correction.model_dump(mode="json") for correction in state.corrections], + ) + if state.extraction_traces: + store.save_json( + state.doc_meta.doc_id, + "extracted/extraction_traces.json", + [trace.model_dump(mode="json") for trace in state.extraction_traces], + ) store.save_json(state.doc_meta.doc_id, "extracted/notes.json", [n.model_dump() for n in state.notes]) store.save_json(state.doc_meta.doc_id, "extracted/risk_signals.json", [s.model_dump() for s in state.risk_signals]) if state.analysis_context: diff --git a/src/agent/state.py b/src/agent/state.py index c2516ac..a0de973 100644 --- a/src/agent/state.py +++ b/src/agent/state.py @@ -8,9 +8,12 @@ AgentRun, AnalysisContext, Chunk, + Correction, DeepAnalysisResult, DocumentMeta, EventStudyResult, + ExtractionTrace, + FinancialFact, FinancialStatement, KeyNote, Page, @@ -31,6 +34,9 @@ class AgentState(BaseModel): notes: list[KeyNote] = Field(default_factory=list) validation_results: dict[str, Any] = Field(default_factory=dict) risk_signals: list[RiskSignal] = Field(default_factory=list) + facts: list[FinancialFact] = Field(default_factory=list) + corrections: list[Correction] = Field(default_factory=list) + extraction_traces: list[ExtractionTrace] = Field(default_factory=list) analysis_context: AnalysisContext | None = None deep_analysis: DeepAnalysisResult | None = None agent_runs: list[AgentRun] = Field(default_factory=list) diff --git a/src/api/routes.py b/src/api/routes.py index 87bc64a..a3afaf4 100644 --- a/src/api/routes.py +++ b/src/api/routes.py @@ -266,6 +266,14 @@ async def get_statements(_auth: _AuthDep, doc_id: str): return _ok(data) +@router.get("/documents/{doc_id}/facts") +async def get_facts(_auth: _AuthDep, doc_id: str): + data = store.load_json(doc_id, "extracted/facts.json") + if data is None: + return _err("not_found", "Facts not found") + return _ok(data) + + @router.get("/documents/{doc_id}/notes") async def get_notes(_auth: _AuthDep, doc_id: str): data = store.load_json(doc_id, "extracted/notes.json") @@ -522,6 +530,8 @@ def _save_partial_results(doc_id: str) -> None: s.save_json(doc_id, "extracted/tables.json", [t.model_dump() for t in partial.tables]) if partial.statements: s.save_json(doc_id, "extracted/statements.json", {k: v.model_dump() for k, v in partial.statements.items()}) + if partial.facts: + s.save_json(doc_id, "extracted/facts.json", [fact.model_dump(mode="json") for fact in partial.facts]) if partial.notes: s.save_json(doc_id, "extracted/notes.json", [n.model_dump() for n in partial.notes]) if partial.risk_signals: diff --git a/src/finance/facts.py b/src/finance/facts.py new file mode 100644 index 0000000..994a7c2 --- /dev/null +++ b/src/finance/facts.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import hashlib +from collections.abc import Iterable +from typing import Any + +from src.schemas.models import Correction, FinancialFact, FinancialStatement, SourceRef, StatementLineItem + + +def facts_from_statements(doc_id: str, statements: dict[str, FinancialStatement]) -> list[FinancialFact]: + facts: list[FinancialFact] = [] + seen: set[tuple[str, str]] = set() + + for statement_type, statement in statements.items(): + if statement.statement_type not in {"income", "balance", "cashflow"}: + continue + for item in statement.line_items: + if item.value_current is None and not item.source_refs: + continue + fact = _fact_from_line_item(doc_id, statement, item) + facts.append(fact) + seen.add((statement.statement_type, item.name_norm)) + + for concept, value in statement.totals.items(): + key = (statement.statement_type, concept) + if key in seen: + continue + facts.append( + _build_fact( + doc_id=doc_id, + statement=statement, + concept=concept, + label=concept, + value=value, + unit=None, + currency=None, + source_refs=[], + confidence=statement.extraction_confidence, + metadata={"source": "statement_totals"}, + ) + ) + return facts + + +def apply_corrections(facts: Iterable[FinancialFact], corrections: Iterable[Correction]) -> list[FinancialFact]: + by_id = {fact.fact_id: fact for fact in facts} + valid_fields = set(FinancialFact.model_fields) + + for correction in corrections: + fact = by_id.get(correction.fact_id) + if fact is None or correction.field_name not in valid_fields: + continue + by_id[correction.fact_id] = fact.model_copy(update={correction.field_name: correction.new_value}) + return list(by_id.values()) + + +def _fact_from_line_item( + doc_id: str, + statement: FinancialStatement, + item: StatementLineItem, +) -> FinancialFact: + confidence = _evidence_confidence(item.source_refs, statement.extraction_confidence) + return _build_fact( + doc_id=doc_id, + statement=statement, + concept=item.name_norm, + label=item.name_raw, + value=item.value_current, + unit=item.unit, + currency=item.currency, + source_refs=item.source_refs, + confidence=confidence, + metadata={"notes": item.notes} if item.notes else {}, + ) + + +def _build_fact( + *, + doc_id: str, + statement: FinancialStatement, + concept: str, + label: str, + value: float | None, + unit: str | None, + currency: str | None, + source_refs: list[SourceRef], + confidence: float, + metadata: dict[str, Any], +) -> FinancialFact: + return FinancialFact( + fact_id=_fact_id(doc_id, statement.statement_type, concept, statement.period_end, label), + doc_id=doc_id, + statement_type=statement.statement_type, + concept=concept, + label=label, + value=value, + unit=unit, + scale=_infer_scale(unit), + currency=currency, + period_start=statement.period_start, + period_end=statement.period_end, + period_type="instant" if statement.statement_type == "balance" else "duration", + source_refs=source_refs, + confidence=confidence, + extraction_engine=_evidence_engine(source_refs), + metadata=metadata, + ) + + +def _fact_id(doc_id: str, statement_type: str, concept: str, period_end: object, label: str) -> str: + raw = "|".join([doc_id, statement_type, concept, str(period_end or ""), label]) + return "fact_" + hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16] + + +def _evidence_confidence(source_refs: list[SourceRef], fallback: float) -> float: + if not source_refs: + return fallback + return max(ref.confidence for ref in source_refs) + + +def _evidence_engine(source_refs: list[SourceRef]) -> str | None: + for ref in source_refs: + if ref.engine: + return ref.engine + return None + + +def _infer_scale(unit: str | None) -> float | None: + if not unit: + return None + normalized = unit.lower().replace(" ", "_") + if "billion" in normalized or "十亿" in normalized: + return 1_000_000_000.0 + if "million" in normalized or "百万" in normalized: + return 1_000_000.0 + if "thousand" in normalized or "千" in normalized: + return 1_000.0 + if "亿元" in normalized or normalized.endswith("亿"): + return 100_000_000.0 + if "万元" in normalized or normalized.endswith("万"): + return 10_000.0 + return None \ No newline at end of file diff --git a/src/schemas/models.py b/src/schemas/models.py index 89ba18a..db06576 100644 --- a/src/schemas/models.py +++ b/src/schemas/models.py @@ -16,8 +16,13 @@ class SourceRef(BaseModel): ref_type: Literal["page_text", "table", "image"] page: int table_id: str | None = None + row: int | None = None + col: int | None = None + bbox: tuple[float, float, float, float] | None = None quote: str | None = Field(default=None) confidence: float = Field(ge=0.0, le=1.0) + engine: str | None = None + artifact_path: str | None = None @field_validator("quote") @classmethod @@ -65,6 +70,11 @@ class TableCell(BaseModel): row: int col: int text: str + rowspan: int = Field(default=1, ge=1) + colspan: int = Field(default=1, ge=1) + bbox: tuple[float, float, float, float] | None = None + confidence: float | None = Field(default=None, ge=0.0, le=1.0) + engine: str | None = None class Table(BaseModel): @@ -105,6 +115,58 @@ class FinancialStatement(BaseModel): issues: list[str] = Field(default_factory=list) +class FinancialFact(BaseModel): + model_config = ConfigDict(extra="forbid") + + fact_id: str + doc_id: str + statement_type: Literal["income", "balance", "cashflow", "note", "other"] + concept: str + label: str + value: float | None = None + unit: str | None = None + scale: float | None = None + currency: str | None = None + period_start: date | None = None + period_end: date | None = None + period_type: Literal["instant", "duration", "unknown"] = "unknown" + source_refs: list[SourceRef] = Field(default_factory=list) + confidence: float = Field(default=0.0, ge=0.0, le=1.0) + extraction_engine: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + +class ExtractionTrace(BaseModel): + model_config = ConfigDict(extra="forbid") + + trace_id: str + doc_id: str + stage: str + engine: str + status: Literal["succeeded", "failed", "skipped"] + elapsed_ms: int | None = None + source_refs: list[SourceRef] = Field(default_factory=list) + metrics: dict[str, float | int | str] = Field(default_factory=dict) + metadata: dict[str, Any] = Field(default_factory=dict) + error: str | None = None + created_at: datetime = Field(default_factory=_utc_now) + + +class Correction(BaseModel): + model_config = ConfigDict(extra="forbid") + + correction_id: str + doc_id: str + fact_id: str + field_name: str + old_value: Any = None + new_value: Any = None + actor: str = "system" + reason: str | None = None + source_refs: list[SourceRef] = Field(default_factory=list) + created_at: datetime = Field(default_factory=_utc_now) + + class KeyNote(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/src/utils/metrics.py b/src/utils/metrics.py index 3d19f9f..081c2ac 100644 --- a/src/utils/metrics.py +++ b/src/utils/metrics.py @@ -3,7 +3,7 @@ from typing import Any -from src.schemas.models import FinancialStatement, KeyNote, RiskSignal +from src.schemas.models import FinancialFact, FinancialStatement, KeyNote, RiskSignal def statement_accuracy( @@ -122,6 +122,61 @@ def source_ref_completeness(notes: list[KeyNote], signals: list[RiskSignal]) -> return with_refs / total +def fact_source_ref_completeness(facts: list[FinancialFact]) -> float: + """Compute fraction of facts with at least one source reference.""" + if not facts: + return 1.0 + with_refs = sum(1 for fact in facts if fact.source_refs) + return with_refs / len(facts) + + +def fact_value_accuracy( + actual_facts: list[FinancialFact], + expected_values: dict[str, float], + tolerance: float = 0.05, +) -> dict[str, Any]: + """Compare canonical facts against expected values. + + Expected keys may be either ``concept`` or ``statement_type:concept``. + The statement-qualified form avoids ambiguity when the same concept can + appear in different statements. + """ + indexed: dict[str, FinancialFact] = {} + for fact in actual_facts: + indexed.setdefault(fact.concept, fact) + indexed[f"{fact.statement_type}:{fact.concept}"] = fact + + matched: list[str] = [] + mismatched: list[dict[str, Any]] = [] + missing: list[str] = [] + + for key, expected_val in expected_values.items(): + matched_fact = indexed.get(key) + if matched_fact is None or matched_fact.value is None: + missing.append(key) + continue + denominator = max(abs(expected_val), 1e-6) + diff_ratio = abs(matched_fact.value - expected_val) / denominator + if diff_ratio <= tolerance: + matched.append(key) + else: + mismatched.append({ + "key": key, + "actual": matched_fact.value, + "expected": expected_val, + "diff_ratio": diff_ratio, + }) + + total_expected = len(expected_values) + accuracy = len(matched) / total_expected if total_expected else 1.0 + return { + "matched": matched, + "mismatched": mismatched, + "missing": missing, + "accuracy": accuracy, + } + + def signal_category_recall( actual_categories: set[str], expected_categories: set[str], @@ -171,6 +226,8 @@ def compute_golden_metrics(results: list[dict[str, Any]]) -> dict[str, Any]: "avg_statement_accuracy": 0.0, "balance_equation_pass_rate": 0.0, "avg_source_ref_completeness": 0.0, + "avg_fact_value_accuracy": 0.0, + "avg_fact_source_ref_completeness": 0.0, "avg_signal_category_recall": 0.0, "avg_note_type_recall": 0.0, } @@ -178,6 +235,8 @@ def compute_golden_metrics(results: list[dict[str, Any]]) -> dict[str, Any]: statement_accuracies: list[float] = [] statements_list: list[dict[str, FinancialStatement]] = [] src_completeness_values: list[float] = [] + fact_value_accuracies: list[float] = [] + fact_src_completeness_values: list[float] = [] signal_recalls: list[float] = [] note_recalls: list[float] = [] @@ -185,7 +244,9 @@ def compute_golden_metrics(results: list[dict[str, Any]]) -> dict[str, Any]: statements = r.get("statements", {}) notes = r.get("notes", []) risk_signals = r.get("risk_signals", []) + facts = r.get("facts", []) expected_totals = r.get("expected_totals", {}) + expected_facts = r.get("expected_facts", {}) expected_note_types = r.get("expected_note_types", set()) expected_signal_categories = r.get("expected_signal_categories", set()) @@ -198,6 +259,10 @@ def compute_golden_metrics(results: list[dict[str, Any]]) -> dict[str, Any]: statements_list.append(statements) src_completeness_values.append(source_ref_completeness(notes, risk_signals)) + if expected_facts: + fact_value_accuracies.append(fact_value_accuracy(facts, expected_facts)["accuracy"]) + if facts: + fact_src_completeness_values.append(fact_source_ref_completeness(facts)) actual_categories = {s.category for s in risk_signals} signal_recalls.append(signal_category_recall(actual_categories, expected_signal_categories)) @@ -219,6 +284,16 @@ def compute_golden_metrics(results: list[dict[str, Any]]) -> dict[str, Any]: if src_completeness_values else 0.0 ), + "avg_fact_value_accuracy": ( + sum(fact_value_accuracies) / len(fact_value_accuracies) + if fact_value_accuracies + else 0.0 + ), + "avg_fact_source_ref_completeness": ( + sum(fact_src_completeness_values) / len(fact_src_completeness_values) + if fact_src_completeness_values + else 0.0 + ), "avg_signal_category_recall": ( sum(signal_recalls) / len(signal_recalls) if signal_recalls else 0.0 ), diff --git a/tests/test_eval_script.py b/tests/test_eval_script.py new file mode 100644 index 0000000..481c88d --- /dev/null +++ b/tests/test_eval_script.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from pathlib import Path + +from scripts.eval import build_eval_report, render_markdown_report, write_eval_report + + +def test_eval_report_writer_creates_json_and_markdown(tmp_path: Path) -> None: + report = build_eval_report( + metrics={"n_cases": 1, "avg_fact_value_accuracy": 1.0}, + case_results=[{"name": "case-a", "fact_count": 2, "statement_types": ["income"], "errors": []}], + pytest_result={"exit_code": 0, "command": ["pytest"], "stdout": "", "stderr": ""}, + started_at="2026-01-01T00:00:00+00:00", + finished_at="2026-01-01T00:00:01+00:00", + ) + + write_eval_report(report, tmp_path) + + assert (tmp_path / "eval_report.json").exists() + assert (tmp_path / "eval_report.md").exists() + assert "avg_fact_value_accuracy" in render_markdown_report(report) + + +def test_eval_report_marks_pytest_failure() -> None: + report = build_eval_report( + metrics={"n_cases": 0}, + case_results=[], + pytest_result={"exit_code": 1, "command": ["pytest"], "stdout": "", "stderr": "failed"}, + started_at="2026-01-01T00:00:00+00:00", + finished_at="2026-01-01T00:00:01+00:00", + ) + + assert report["status"] == "failed" \ No newline at end of file diff --git a/tests/test_finance_facts.py b/tests/test_finance_facts.py new file mode 100644 index 0000000..3d914c1 --- /dev/null +++ b/tests/test_finance_facts.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from datetime import date + +from src.finance.facts import apply_corrections, facts_from_statements +from src.schemas.models import Correction, FinancialStatement, SourceRef, StatementLineItem + + +def test_facts_from_statements_preserves_line_item_evidence() -> None: + source = SourceRef( + ref_type="table", + page=3, + table_id="p3_t1", + row=4, + col=2, + bbox=(10.0, 20.0, 30.0, 40.0), + quote="Revenue 100", + confidence=0.82, + engine="pdfplumber", + ) + statement = FinancialStatement( + statement_type="income", + period_end=date(2025, 12, 31), + line_items=[ + StatementLineItem( + name_raw="Revenue", + name_norm="revenue", + value_current=100.0, + unit="USD millions", + currency="USD", + source_refs=[source], + ) + ], + totals={"revenue": 100.0}, + extraction_confidence=0.7, + ) + + facts = facts_from_statements("doc-1", {"income": statement}) + + assert len(facts) == 1 + fact = facts[0] + assert fact.doc_id == "doc-1" + assert fact.statement_type == "income" + assert fact.concept == "revenue" + assert fact.value == 100.0 + assert fact.period_type == "duration" + assert fact.scale == 1_000_000.0 + assert fact.confidence == 0.82 + assert fact.extraction_engine == "pdfplumber" + assert fact.source_refs[0].bbox == (10.0, 20.0, 30.0, 40.0) + + +def test_facts_from_statements_adds_total_when_no_line_item_exists() -> None: + statement = FinancialStatement( + statement_type="balance", + period_end=date(2025, 12, 31), + totals={"total_assets": 500.0}, + extraction_confidence=0.6, + ) + + facts = facts_from_statements("doc-1", {"balance": statement}) + + assert len(facts) == 1 + assert facts[0].concept == "total_assets" + assert facts[0].period_type == "instant" + assert facts[0].confidence == 0.6 + + +def test_apply_corrections_updates_allowed_fact_field() -> None: + statement = FinancialStatement( + statement_type="income", + line_items=[StatementLineItem(name_raw="Revenue", name_norm="revenue", value_current=100.0)], + ) + fact = facts_from_statements("doc-1", {"income": statement})[0] + correction = Correction( + correction_id="c1", + doc_id="doc-1", + fact_id=fact.fact_id, + field_name="value", + old_value=100.0, + new_value=125.0, + actor="analyst", + ) + + corrected = apply_corrections([fact], [correction]) + + assert corrected[0].value == 125.0 + assert fact.value == 100.0 \ No newline at end of file diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 6eaa828..5f82987 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -1,6 +1,7 @@ from __future__ import annotations from src.schemas.models import ( + FinancialFact, FinancialStatement, KeyNote, RiskSignal, @@ -10,6 +11,8 @@ from src.utils.metrics import ( balance_equation_pass_rate, compute_golden_metrics, + fact_source_ref_completeness, + fact_value_accuracy, note_type_recall, signal_category_recall, source_ref_completeness, @@ -168,6 +171,40 @@ def test_only_signals(self) -> None: assert source_ref_completeness([], signals) == 1.0 +# ---- fact metrics ---- + + +def _make_fact(concept: str, value: float | None, refs: list[SourceRef] | None = None) -> FinancialFact: + return FinancialFact( + fact_id=f"fact-{concept}", + doc_id="doc-1", + statement_type="income", + concept=concept, + label=concept, + value=value, + source_refs=refs or [], + confidence=0.8, + ) + + +class TestFactMetrics: + def test_fact_source_ref_completeness(self) -> None: + facts = [_make_fact("revenue", 100, [_make_ref()]), _make_fact("net_income", 20)] + assert fact_source_ref_completeness(facts) == 0.5 + + def test_fact_value_accuracy_supports_statement_qualified_keys(self) -> None: + facts = [_make_fact("revenue", 100), _make_fact("net_income", 20)] + result = fact_value_accuracy(facts, {"income:revenue": 100, "income:net_income": 22}, tolerance=0.05) + assert result["accuracy"] == 0.5 + assert result["matched"] == ["income:revenue"] + assert result["mismatched"][0]["key"] == "income:net_income" + + def test_fact_value_accuracy_missing_value(self) -> None: + result = fact_value_accuracy([_make_fact("revenue", None)], {"income:revenue": 100}) + assert result["accuracy"] == 0.0 + assert result["missing"] == ["income:revenue"] + + # ---- signal_category_recall ---- diff --git a/tests/test_routes_web.py b/tests/test_routes_web.py index 9b802a6..083e5d8 100644 --- a/tests/test_routes_web.py +++ b/tests/test_routes_web.py @@ -226,6 +226,41 @@ def test_tables_endpoint(client: TestClient, tmp_path: Path) -> None: assert body["data"][0]["table_id"] == "p1_t1" +def test_facts_endpoint(client: TestClient, tmp_path: Path) -> None: + _make_doc(tmp_path / "data", "abc123") + facts_path = tmp_path / "data" / "abc123" / "extracted" / "facts.json" + facts_path.write_text( + json.dumps( + [ + { + "fact_id": "fact-1", + "doc_id": "abc123", + "statement_type": "income", + "concept": "revenue", + "label": "Revenue", + "value": 100.0, + "source_refs": [{"ref_type": "table", "page": 1, "table_id": "p1_t1", "confidence": 0.8}], + "confidence": 0.8, + } + ] + ), + encoding="utf-8", + ) + + r = client.get("/v1/documents/abc123/facts") + + assert r.status_code == 200 + body = r.json() + assert body["ok"] is True + assert body["data"][0]["concept"] == "revenue" + + +def test_facts_missing_returns_404(client: TestClient, tmp_path: Path) -> None: + _make_doc(tmp_path / "data", "abc123") + r = client.get("/v1/documents/abc123/facts") + assert r.status_code == 404 + + def test_tables_missing_returns_404(client: TestClient, tmp_path: Path) -> None: _make_doc(tmp_path / "data", "abc123", with_tables=False) r = client.get("/v1/documents/abc123/tables") diff --git a/web/src/api/docs.ts b/web/src/api/docs.ts index 13c3e23..c158b59 100644 --- a/web/src/api/docs.ts +++ b/web/src/api/docs.ts @@ -7,6 +7,7 @@ import type { DeepAnalysisResult, ExtractedPage, ExtractedTable, + FinancialFact, FinancialStatements, KeyNote, MetricItem, @@ -22,10 +23,15 @@ function normalizeSourceRef(raw: any): SourceRef { const page = Number(raw.page) return { page: Number.isFinite(page) && page > 0 ? page : 1, + ref_type: raw.ref_type ?? undefined, table_id: raw.table_id ?? null, + row: typeof raw.row === 'number' ? raw.row : null, + col: typeof raw.col === 'number' ? raw.col : null, bbox: raw.bbox ?? null, quote: raw.quote ?? null, confidence: typeof raw.confidence === 'number' ? raw.confidence : undefined, + engine: raw.engine ?? null, + artifact_path: raw.artifact_path ?? null, } } @@ -176,6 +182,9 @@ export const docsApi = { statements(docId: string) { return unwrap(http.get(`/v1/documents/${docId}/statements`)).then(normalizeStatements) }, + facts(docId: string) { + return unwrap(http.get(`/v1/documents/${docId}/facts`)) + }, riskSignals(docId: string) { return unwrap(http.get(`/v1/documents/${docId}/risk-signals`)).then(normalizeSignals) }, diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 776886f..02235a4 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -1,11 +1,16 @@ // ── API response types (mirror src/finance/schemas.py & src/schemas/models.py) export interface SourceRef { + ref_type?: 'page_text' | 'table' | 'image' | string page: number table_id?: string | null + row?: number | null + col?: number | null bbox?: [number, number, number, number] | null quote?: string | null confidence?: number + engine?: string | null + artifact_path?: string | null } export interface MetricItem { @@ -25,6 +30,25 @@ export interface FinancialStatements { [k: string]: MetricItem[] | undefined } +export interface FinancialFact { + fact_id: string + doc_id: string + statement_type: 'income' | 'balance' | 'cashflow' | 'note' | 'other' | string + concept: string + label: string + value?: number | null + unit?: string | null + scale?: number | null + currency?: string | null + period_start?: string | null + period_end?: string | null + period_type?: 'instant' | 'duration' | 'unknown' | string + source_refs: SourceRef[] + confidence: number + extraction_engine?: string | null + metadata?: Record +} + export interface RiskSignal { id: string category: string @@ -70,6 +94,9 @@ export interface TableCell { text: string rowspan?: number colspan?: number + bbox?: [number, number, number, number] | null + confidence?: number | null + engine?: string | null } export interface ExtractedTable {