Commit Β·
c6cfb1f
1
Parent(s): bc61ea7
v2: SEC 10-K pipeline - schema + section chunker + EDGAR downloader
Browse files- README.md +36 -3
- scripts/build_filings_gt.py +169 -0
- scripts/download_edgar.py +211 -0
- src/extractors/extractor.py +69 -2
- src/extractors/prompts.py +50 -0
- src/extractors/section_chunker.py +177 -0
- src/schemas/__init__.py +5 -0
- src/schemas/filing.py +265 -0
- src/schemas/registry.py +2 -3
- tests/unit/test_filing_schema.py +163 -0
- tests/unit/test_section_chunker.py +138 -0
- ui/src/components/Dropzone.tsx +2 -1
- ui/src/types.ts +1 -1
README.md
CHANGED
|
@@ -72,7 +72,7 @@ JSON summary, markdown) land in `evaluation/reports/<timestamp>/` after each run
|
|
| 72 |
|----------|-------------------|-----------|-----------|-----------|------------|--------------|
|
| 73 |
| Receipts | SROIE (5) | **0.938** | **1.000** | 0.20 | **$0.012** | 6.3 s |
|
| 74 |
| Receipts | CORD (5) | **0.914** | 0.839 | **0.80** | **$0.012** | 8.2 s |
|
| 75 |
-
| Filings | SEC 10-K
|
| 76 |
|
| 77 |
**Read the numbers:**
|
| 78 |
- **Micro F1 β 0.92** across both datasets β the model gets ~92% of individual
|
|
@@ -159,6 +159,17 @@ number, then the SEC 10-K schema for the long-doc / dual-domain story.
|
|
| 159 |
ββββββββββββββββββ
|
| 160 |
```
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
## Tech stack
|
| 163 |
|
| 164 |
| Layer | Choice | Why |
|
|
@@ -208,6 +219,28 @@ python scripts/run_eval.py --dataset evaluation/ground_truth/sroie.jsonl \
|
|
| 208 |
Reports (per-record CSV + summary JSON + resume-ready markdown) land in
|
| 209 |
`evaluation/reports/<UTC-timestamp>/`.
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
## Project structure
|
| 212 |
|
| 213 |
```
|
|
@@ -240,8 +273,8 @@ Reports (per-record CSV + summary JSON + resume-ready markdown) land in
|
|
| 240 |
|
| 241 |
## Roadmap
|
| 242 |
|
| 243 |
-
- [x] **v1 β Invoices & Receipts pipeline + multi-model benchmark**
|
| 244 |
-
- [
|
| 245 |
- [ ] v3 β Streaming extraction + async batch API
|
| 246 |
- [ ] v4 β Fine-tuning experiment vs. base GPT-5 nano
|
| 247 |
|
|
|
|
| 72 |
|----------|-------------------|-----------|-----------|-----------|------------|--------------|
|
| 73 |
| Receipts | SROIE (5) | **0.938** | **1.000** | 0.20 | **$0.012** | 6.3 s |
|
| 74 |
| Receipts | CORD (5) | **0.914** | 0.839 | **0.80** | **$0.012** | 8.2 s |
|
| 75 |
+
| Filings | SEC 10-K (n=5) | _v2 pipeline built β live numbers pending_ | | | | |
|
| 76 |
|
| 77 |
**Read the numbers:**
|
| 78 |
- **Micro F1 β 0.92** across both datasets β the model gets ~92% of individual
|
|
|
|
| 159 |
ββββββββββββββββββ
|
| 160 |
```
|
| 161 |
|
| 162 |
+
**Long-document handling (10-K path).** SEC filings are 50-250 pages and would
|
| 163 |
+
cost ~$0.60/doc if we shipped the whole thing to the model on every call. So
|
| 164 |
+
the filing path runs the document through a section chunker
|
| 165 |
+
(`src/extractors/section_chunker.py`) that finds each `Item 1.`, `Item 1A.`,
|
| 166 |
+
`Item 7.`, `Item 8.` heading via regex, deduplicates against the Table of
|
| 167 |
+
Contents (keeping the last occurrence β the real section), and slices the
|
| 168 |
+
plaintext into named chunks. Only the cover, Item 8 (financials), and Item 1A
|
| 169 |
+
(risk factors) are stitched into the prompt β everything else is skipped.
|
| 170 |
+
Result: ~30β40K prompt tokens per 10-K instead of ~150K, on the same
|
| 171 |
+
gpt-5-nano @ minimal reasoning-effort configuration.
|
| 172 |
+
|
| 173 |
## Tech stack
|
| 174 |
|
| 175 |
| Layer | Choice | Why |
|
|
|
|
| 219 |
Reports (per-record CSV + summary JSON + resume-ready markdown) land in
|
| 220 |
`evaluation/reports/<UTC-timestamp>/`.
|
| 221 |
|
| 222 |
+
### 10-K (SEC filings) quick start
|
| 223 |
+
|
| 224 |
+
```bash
|
| 225 |
+
# 1. Download the 5-issuer watchlist (Apple, JPM, ExxonMobil, Pfizer, Walmart).
|
| 226 |
+
# Files land in data/raw/10k/ β plaintext, sidecar JSON, and XBRL companyfacts.
|
| 227 |
+
python scripts/download_edgar.py
|
| 228 |
+
|
| 229 |
+
# 2. Build a ground-truth JSONL from the sidecars + XBRL.
|
| 230 |
+
python scripts/build_filings_gt.py
|
| 231 |
+
|
| 232 |
+
# 3. Run the eval against a real model.
|
| 233 |
+
python scripts/run_eval.py \
|
| 234 |
+
--dataset evaluation/smoke_filings_sample.jsonl \
|
| 235 |
+
--doc-type filing \
|
| 236 |
+
--mode live \
|
| 237 |
+
--model gpt-5-nano \
|
| 238 |
+
--reasoning-effort minimal
|
| 239 |
+
```
|
| 240 |
+
|
| 241 |
+
The filing extractor uses section-based chunking to keep per-doc cost around
|
| 242 |
+
$0.05β0.15 instead of $0.60+ at whole-document context.
|
| 243 |
+
|
| 244 |
## Project structure
|
| 245 |
|
| 246 |
```
|
|
|
|
| 273 |
|
| 274 |
## Roadmap
|
| 275 |
|
| 276 |
+
- [x] **v1 β Invoices & Receipts pipeline + multi-model benchmark**
|
| 277 |
+
- [x] **v2 β SEC 10-K pipeline** (schema + section chunker + EDGAR downloader, live eval pending)
|
| 278 |
- [ ] v3 β Streaming extraction + async batch API
|
| 279 |
- [ ] v4 β Fine-tuning experiment vs. base GPT-5 nano
|
| 280 |
|
scripts/build_filings_gt.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build filings ground-truth JSONL from downloaded EDGAR data.
|
| 2 |
+
|
| 3 |
+
Reads the sidecars + XBRL companyfacts written by `download_edgar.py` and
|
| 4 |
+
produces `evaluation/smoke_filings_sample.jsonl` β the file the eval CLI
|
| 5 |
+
reads in `--mode live --doc-type filing` runs.
|
| 6 |
+
|
| 7 |
+
Each JSONL row has:
|
| 8 |
+
id, source, text (full 10-K plaintext), ground_truth (Filing schema dict)
|
| 9 |
+
|
| 10 |
+
Cover fields come from the sidecar (auto). Financials come from XBRL
|
| 11 |
+
companyfacts using the most-common US-GAAP concepts (see FIN_MAP below).
|
| 12 |
+
Risk factors are left EMPTY in the auto-generated ground truth β they're
|
| 13 |
+
free text and there's no canonical source. Backfill by hand for a stricter
|
| 14 |
+
qualitative eval.
|
| 15 |
+
|
| 16 |
+
Usage
|
| 17 |
+
-----
|
| 18 |
+
python scripts/build_filings_gt.py
|
| 19 |
+
python scripts/build_filings_gt.py --raw-dir data/raw/10k --out evaluation/smoke_filings_sample.jsonl
|
| 20 |
+
"""
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import argparse
|
| 24 |
+
import json
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 28 |
+
|
| 29 |
+
# US-GAAP concept name -> our schema field.
|
| 30 |
+
# Values are lists of candidate concept names in order of preference β real
|
| 31 |
+
# filers use different names for the same line ("Revenues", "SalesRevenueNet",
|
| 32 |
+
# "RevenueFromContractWithCustomerExcludingAssessedTax"...).
|
| 33 |
+
FIN_MAP = {
|
| 34 |
+
"revenue": [
|
| 35 |
+
"Revenues",
|
| 36 |
+
"RevenueFromContractWithCustomerExcludingAssessedTax",
|
| 37 |
+
"SalesRevenueNet",
|
| 38 |
+
],
|
| 39 |
+
"cost_of_revenue": ["CostOfRevenue", "CostOfGoodsAndServicesSold", "CostOfGoodsSold"],
|
| 40 |
+
"gross_profit": ["GrossProfit"],
|
| 41 |
+
"operating_income": ["OperatingIncomeLoss"],
|
| 42 |
+
"net_income": ["NetIncomeLoss"],
|
| 43 |
+
"eps_basic": ["EarningsPerShareBasic"],
|
| 44 |
+
"eps_diluted": ["EarningsPerShareDiluted"],
|
| 45 |
+
"cash_and_equivalents": ["CashAndCashEquivalentsAtCarryingValue", "CashCashEquivalentsAndShortTermInvestments"],
|
| 46 |
+
"total_assets": ["Assets"],
|
| 47 |
+
"total_equity": ["StockholdersEquity", "StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest"],
|
| 48 |
+
"total_debt": [ # sum of short-term + long-term where separately reported
|
| 49 |
+
"LongTermDebt",
|
| 50 |
+
"LongTermDebtNoncurrent",
|
| 51 |
+
],
|
| 52 |
+
"operating_cash_flow": ["NetCashProvidedByUsedInOperatingActivities"],
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _pick_fy_value(concept_data: dict, fy_end: str) -> float | None:
|
| 57 |
+
"""Return the concept value for the fiscal year ending `fy_end` (ISO date).
|
| 58 |
+
|
| 59 |
+
XBRL companyfacts nests values by unit (USD, USD/shares, pure). We take
|
| 60 |
+
the first unit that produces a match. Match rule: same `end` date + form
|
| 61 |
+
starting with '10-K' + `fp == 'FY'`.
|
| 62 |
+
"""
|
| 63 |
+
units = concept_data.get("units", {})
|
| 64 |
+
for _unit, entries in units.items():
|
| 65 |
+
for e in entries:
|
| 66 |
+
if e.get("end") == fy_end and e.get("form", "").startswith("10-K") and e.get("fp") == "FY":
|
| 67 |
+
try:
|
| 68 |
+
return float(e["val"])
|
| 69 |
+
except (KeyError, ValueError, TypeError):
|
| 70 |
+
continue
|
| 71 |
+
return None
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def build_financials(facts: dict, fy_end: str) -> dict:
|
| 75 |
+
"""Populate as many FilingFinancials fields as possible from XBRL."""
|
| 76 |
+
us_gaap = (facts.get("facts", {}) or {}).get("us-gaap", {}) or {}
|
| 77 |
+
fin: dict = {"currency": "USD", "fiscal_year": int(fy_end[:4])}
|
| 78 |
+
|
| 79 |
+
for field, candidates in FIN_MAP.items():
|
| 80 |
+
val = None
|
| 81 |
+
collected: list[float] = []
|
| 82 |
+
for concept in candidates:
|
| 83 |
+
data = us_gaap.get(concept)
|
| 84 |
+
if not data:
|
| 85 |
+
continue
|
| 86 |
+
v = _pick_fy_value(data, fy_end)
|
| 87 |
+
if v is None:
|
| 88 |
+
continue
|
| 89 |
+
if field == "total_debt":
|
| 90 |
+
# Sum across all matching debt concepts (short-term + long-term).
|
| 91 |
+
collected.append(v)
|
| 92 |
+
else:
|
| 93 |
+
val = v
|
| 94 |
+
break
|
| 95 |
+
if field == "total_debt" and collected:
|
| 96 |
+
val = sum(collected)
|
| 97 |
+
if val is not None:
|
| 98 |
+
fin[field] = val
|
| 99 |
+
|
| 100 |
+
return fin
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def build_row(txt_path: Path) -> dict | None:
|
| 104 |
+
"""Produce one ground-truth row for the JSONL, or None if metadata is missing."""
|
| 105 |
+
stem = txt_path.name[:-len(".txt")]
|
| 106 |
+
meta_path = txt_path.parent / f"{stem}.meta.json"
|
| 107 |
+
facts_path = txt_path.parent / f"{stem}.facts.json"
|
| 108 |
+
if not meta_path.exists():
|
| 109 |
+
print(f"[!] no sidecar for {txt_path.name} β skipping")
|
| 110 |
+
return None
|
| 111 |
+
|
| 112 |
+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
| 113 |
+
facts = json.loads(facts_path.read_text(encoding="utf-8")) if facts_path.exists() else None
|
| 114 |
+
|
| 115 |
+
fy_end = meta["reporting_period_end"]
|
| 116 |
+
cover = {
|
| 117 |
+
"company_name": meta["company_name"],
|
| 118 |
+
"cik": meta["cik10"],
|
| 119 |
+
"ticker": meta["ticker"],
|
| 120 |
+
"form_type": meta["form"],
|
| 121 |
+
"fiscal_year_end": fy_end,
|
| 122 |
+
"filing_date": meta["filing_date"],
|
| 123 |
+
}
|
| 124 |
+
financials = build_financials(facts, fy_end) if facts else {"currency": "USD", "fiscal_year": int(fy_end[:4])}
|
| 125 |
+
|
| 126 |
+
return {
|
| 127 |
+
"id": f"filing_{meta['ticker']}_{fy_end}",
|
| 128 |
+
"source": "sec_edgar",
|
| 129 |
+
"text": txt_path.read_text(encoding="utf-8"),
|
| 130 |
+
"ground_truth": {
|
| 131 |
+
"cover": cover,
|
| 132 |
+
"financials": financials,
|
| 133 |
+
"top_risk_factors": [], # backfill by hand for qualitative eval
|
| 134 |
+
},
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def main(argv=None) -> int:
|
| 139 |
+
ap = argparse.ArgumentParser(description=__doc__)
|
| 140 |
+
ap.add_argument("--raw-dir", default="data/raw/10k")
|
| 141 |
+
ap.add_argument("--out", default="evaluation/smoke_filings_sample.jsonl")
|
| 142 |
+
args = ap.parse_args(argv)
|
| 143 |
+
|
| 144 |
+
raw = ROOT / args.raw_dir
|
| 145 |
+
out = ROOT / args.out
|
| 146 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 147 |
+
|
| 148 |
+
txts = sorted(raw.glob("*_10k.txt"))
|
| 149 |
+
if not txts:
|
| 150 |
+
print(f"[!] no *_10k.txt files under {raw} β run download_edgar.py first.")
|
| 151 |
+
return 2
|
| 152 |
+
|
| 153 |
+
n_written = 0
|
| 154 |
+
with out.open("w", encoding="utf-8") as f:
|
| 155 |
+
for p in txts:
|
| 156 |
+
row = build_row(p)
|
| 157 |
+
if not row:
|
| 158 |
+
continue
|
| 159 |
+
f.write(json.dumps(row) + "\n")
|
| 160 |
+
n_written += 1
|
| 161 |
+
fin = row["ground_truth"]["financials"]
|
| 162 |
+
print(f" {row['id']:36s} revenue={fin.get('revenue')} net_income={fin.get('net_income')}")
|
| 163 |
+
|
| 164 |
+
print(f"\n-> {out} ({n_written} rows)")
|
| 165 |
+
return 0
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
if __name__ == "__main__":
|
| 169 |
+
raise SystemExit(main())
|
scripts/download_edgar.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Download the most recent 10-K for a small watchlist of large-cap issuers.
|
| 2 |
+
|
| 3 |
+
Purpose
|
| 4 |
+
-------
|
| 5 |
+
Give the eval pipeline five real 10-K filings to run against, with
|
| 6 |
+
auto-scrapable ground-truth metadata already pulled from SEC EDGAR's
|
| 7 |
+
structured feeds. Filings are saved as plaintext under `data/raw/10k/`
|
| 8 |
+
so `src/eval/cli.py --mode live` can read them via the loader's `.txt` path.
|
| 9 |
+
|
| 10 |
+
Approach
|
| 11 |
+
--------
|
| 12 |
+
SEC EDGAR exposes a JSON submissions feed per issuer at
|
| 13 |
+
https://data.sec.gov/submissions/CIK{cik10}.json
|
| 14 |
+
which lists every filing (form, accession, date, primary document). We:
|
| 15 |
+
1. For each ticker in WATCHLIST, resolve ticker -> CIK via the tickers file.
|
| 16 |
+
2. Fetch the submissions feed and pick the most recent 10-K.
|
| 17 |
+
3. Download the primary document (HTML).
|
| 18 |
+
4. Strip HTML -> plaintext (via BeautifulSoup) and save.
|
| 19 |
+
5. Also fetch companyfacts.json for XBRL-driven ground truth.
|
| 20 |
+
|
| 21 |
+
SEC's rules
|
| 22 |
+
-----------
|
| 23 |
+
- Identify yourself in the User-Agent header (they rate-limit anonymous UAs).
|
| 24 |
+
- Cap traffic at <= 10 req/s. A 250 ms sleep between requests is well under.
|
| 25 |
+
|
| 26 |
+
Usage
|
| 27 |
+
-----
|
| 28 |
+
python scripts/download_edgar.py # default watchlist
|
| 29 |
+
python scripts/download_edgar.py AAPL MSFT NVDA # custom tickers
|
| 30 |
+
python scripts/download_edgar.py --dry-run # print plan only
|
| 31 |
+
"""
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
import argparse
|
| 35 |
+
import json
|
| 36 |
+
import sys
|
| 37 |
+
import time
|
| 38 |
+
from pathlib import Path
|
| 39 |
+
|
| 40 |
+
import requests
|
| 41 |
+
from bs4 import BeautifulSoup
|
| 42 |
+
|
| 43 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 44 |
+
RAW_DIR = ROOT / "data" / "raw" / "10k"
|
| 45 |
+
|
| 46 |
+
# One large-cap per sector β diverse writing styles, real ground truth.
|
| 47 |
+
WATCHLIST_DEFAULT = ["AAPL", "JPM", "XOM", "PFE", "WMT"]
|
| 48 |
+
|
| 49 |
+
# SEC requires an identifying User-Agent. Change the email if you fork.
|
| 50 |
+
USER_AGENT = "structured-data-extractor (Aditya Patel, adityapatel1801@gmail.com)"
|
| 51 |
+
HEADERS = {"User-Agent": USER_AGENT, "Accept-Encoding": "gzip, deflate"}
|
| 52 |
+
SLEEP_SEC = 0.25 # <= 10 req/s per SEC's fair-use policy
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _get(url: str, timeout: int = 30) -> requests.Response:
|
| 56 |
+
"""Throttled GET with SEC-mandated headers."""
|
| 57 |
+
r = requests.get(url, headers=HEADERS, timeout=timeout)
|
| 58 |
+
r.raise_for_status()
|
| 59 |
+
time.sleep(SLEEP_SEC)
|
| 60 |
+
return r
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def load_ticker_index() -> dict:
|
| 64 |
+
"""Return a dict of TICKER -> 10-digit CIK, downloaded from SEC."""
|
| 65 |
+
r = _get("https://www.sec.gov/files/company_tickers.json")
|
| 66 |
+
payload = r.json()
|
| 67 |
+
out = {}
|
| 68 |
+
for row in payload.values():
|
| 69 |
+
out[str(row["ticker"]).upper()] = str(row["cik_str"]).zfill(10)
|
| 70 |
+
return out
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def latest_10k_metadata(cik10: str) -> dict:
|
| 74 |
+
"""Return { accession, primary_doc, filing_date, ... } for the most recent 10-K."""
|
| 75 |
+
r = _get(f"https://data.sec.gov/submissions/CIK{cik10}.json")
|
| 76 |
+
payload = r.json()
|
| 77 |
+
recent = payload["filings"]["recent"]
|
| 78 |
+
for i, form in enumerate(recent["form"]):
|
| 79 |
+
if form == "10-K":
|
| 80 |
+
return {
|
| 81 |
+
"cik10": cik10,
|
| 82 |
+
"company_name": payload.get("name", ""),
|
| 83 |
+
"form": form,
|
| 84 |
+
"accession": recent["accessionNumber"][i],
|
| 85 |
+
"filing_date": recent["filingDate"][i],
|
| 86 |
+
"reporting_period_end": recent["reportDate"][i],
|
| 87 |
+
"primary_doc": recent["primaryDocument"][i],
|
| 88 |
+
}
|
| 89 |
+
raise RuntimeError(f"No 10-K found in recent filings for CIK {cik10}")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def download_10k_html(meta: dict) -> str:
|
| 93 |
+
accession_nodash = meta["accession"].replace("-", "")
|
| 94 |
+
cik_nozero = str(int(meta["cik10"]))
|
| 95 |
+
url = (
|
| 96 |
+
f"https://www.sec.gov/Archives/edgar/data/{cik_nozero}/"
|
| 97 |
+
f"{accession_nodash}/{meta['primary_doc']}"
|
| 98 |
+
)
|
| 99 |
+
r = _get(url, timeout=60)
|
| 100 |
+
return r.text
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def html_to_text(html: str) -> str:
|
| 104 |
+
"""Strip HTML -> plaintext, preserving section headings so the chunker can find them."""
|
| 105 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 106 |
+
for tag in soup(["script", "style", "nav", "footer", "header"]):
|
| 107 |
+
tag.decompose()
|
| 108 |
+
text = soup.get_text(separator="\n")
|
| 109 |
+
lines = [ln.rstrip() for ln in text.splitlines()]
|
| 110 |
+
out = []
|
| 111 |
+
blank = 0
|
| 112 |
+
for ln in lines:
|
| 113 |
+
if ln.strip() == "":
|
| 114 |
+
blank += 1
|
| 115 |
+
if blank <= 1:
|
| 116 |
+
out.append("")
|
| 117 |
+
else:
|
| 118 |
+
blank = 0
|
| 119 |
+
out.append(ln)
|
| 120 |
+
return "\n".join(out).strip() + "\n"
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def download_companyfacts(cik10: str) -> dict:
|
| 124 |
+
"""Return the XBRL companyfacts JSON β used for auto-ground-truth."""
|
| 125 |
+
r = _get(f"https://data.sec.gov/api/xbrl/companyfacts/CIK{cik10}.json")
|
| 126 |
+
return r.json()
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def save_filing(ticker: str, meta: dict, text: str, facts: dict | None) -> Path:
|
| 130 |
+
RAW_DIR.mkdir(parents=True, exist_ok=True)
|
| 131 |
+
stem = f"{ticker.upper()}_{meta['reporting_period_end']}_10k"
|
| 132 |
+
txt_path = RAW_DIR / f"{stem}.txt"
|
| 133 |
+
meta_path = RAW_DIR / f"{stem}.meta.json"
|
| 134 |
+
|
| 135 |
+
txt_path.write_text(text, encoding="utf-8")
|
| 136 |
+
|
| 137 |
+
sidecar = {
|
| 138 |
+
"ticker": ticker.upper(),
|
| 139 |
+
"cik10": meta["cik10"],
|
| 140 |
+
"company_name": meta["company_name"],
|
| 141 |
+
"form": meta["form"],
|
| 142 |
+
"accession": meta["accession"],
|
| 143 |
+
"filing_date": meta["filing_date"],
|
| 144 |
+
"reporting_period_end": meta["reporting_period_end"],
|
| 145 |
+
"primary_doc": meta["primary_doc"],
|
| 146 |
+
"source_url": (
|
| 147 |
+
f"https://www.sec.gov/Archives/edgar/data/{int(meta['cik10'])}/"
|
| 148 |
+
f"{meta['accession'].replace('-', '')}/{meta['primary_doc']}"
|
| 149 |
+
),
|
| 150 |
+
"text_bytes": len(text),
|
| 151 |
+
"has_companyfacts": facts is not None,
|
| 152 |
+
}
|
| 153 |
+
meta_path.write_text(json.dumps(sidecar, indent=2), encoding="utf-8")
|
| 154 |
+
|
| 155 |
+
if facts is not None:
|
| 156 |
+
(RAW_DIR / f"{stem}.facts.json").write_text(
|
| 157 |
+
json.dumps(facts, separators=(",", ":")), encoding="utf-8"
|
| 158 |
+
)
|
| 159 |
+
return txt_path
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def main(argv=None) -> int:
|
| 163 |
+
ap = argparse.ArgumentParser(description=__doc__)
|
| 164 |
+
ap.add_argument("tickers", nargs="*", default=WATCHLIST_DEFAULT,
|
| 165 |
+
help=f"Ticker symbols. Default: {' '.join(WATCHLIST_DEFAULT)}")
|
| 166 |
+
ap.add_argument("--dry-run", action="store_true", help="Print plan + exit.")
|
| 167 |
+
ap.add_argument("--skip-facts", action="store_true",
|
| 168 |
+
help="Skip companyfacts.json download (faster, loses auto-GT).")
|
| 169 |
+
args = ap.parse_args(argv)
|
| 170 |
+
|
| 171 |
+
tickers = [t.upper() for t in args.tickers]
|
| 172 |
+
print(f"Watchlist: {tickers}")
|
| 173 |
+
print(f"Output dir: {RAW_DIR}")
|
| 174 |
+
if args.dry_run:
|
| 175 |
+
return 0
|
| 176 |
+
|
| 177 |
+
print("Loading SEC ticker index ...", flush=True)
|
| 178 |
+
ticker_index = load_ticker_index()
|
| 179 |
+
|
| 180 |
+
for ticker in tickers:
|
| 181 |
+
try:
|
| 182 |
+
cik10 = ticker_index[ticker]
|
| 183 |
+
except KeyError:
|
| 184 |
+
print(f"[!] {ticker} not in SEC ticker index β skipping.")
|
| 185 |
+
continue
|
| 186 |
+
|
| 187 |
+
print(f"\n=== {ticker} (CIK {cik10}) ===")
|
| 188 |
+
try:
|
| 189 |
+
meta = latest_10k_metadata(cik10)
|
| 190 |
+
print(f" latest 10-K: accession={meta['accession']} filed {meta['filing_date']}")
|
| 191 |
+
html = download_10k_html(meta)
|
| 192 |
+
print(f" primary doc: {meta['primary_doc']} ({len(html):,} HTML bytes)")
|
| 193 |
+
text = html_to_text(html)
|
| 194 |
+
print(f" plaintext: {len(text):,} chars")
|
| 195 |
+
facts = None
|
| 196 |
+
if not args.skip_facts:
|
| 197 |
+
facts = download_companyfacts(cik10)
|
| 198 |
+
print(f" companyfacts: {len(facts.get('facts', {}))} taxonomies")
|
| 199 |
+
path = save_filing(ticker, meta, text, facts)
|
| 200 |
+
print(f" -> {path.relative_to(ROOT)}")
|
| 201 |
+
except requests.HTTPError as e:
|
| 202 |
+
print(f"[!] {ticker} HTTP error: {e}")
|
| 203 |
+
except Exception as e:
|
| 204 |
+
print(f"[!] {ticker} failed: {type(e).__name__}: {e}")
|
| 205 |
+
|
| 206 |
+
print(f"\nDone. Files in {RAW_DIR.relative_to(ROOT)}/")
|
| 207 |
+
return 0
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
if __name__ == "__main__":
|
| 211 |
+
raise SystemExit(main())
|
src/extractors/extractor.py
CHANGED
|
@@ -14,6 +14,7 @@ from src.extractors.document_loader import LoadedDocument, load_document
|
|
| 14 |
from src.extractors.envelope import compute_overall_confidence, make_envelope
|
| 15 |
from src.extractors.openai_client import OpenAIExtractionClient
|
| 16 |
from src.extractors.prompts import get_prompt
|
|
|
|
| 17 |
from src.schemas import ExtractionResult
|
| 18 |
from src.schemas.registry import get_schema
|
| 19 |
from src.utils.cost_tracker import ExtractionMetrics
|
|
@@ -62,8 +63,13 @@ class DocumentExtractor:
|
|
| 62 |
if loaded.source_type == "empty":
|
| 63 |
raise ValueError(f"Could not load document {filename!r} (unknown or corrupt format).")
|
| 64 |
|
| 65 |
-
# 3. Build the messages.
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
# 4. Call the model.
|
| 69 |
envelope, metrics = self._client.parse_structured(
|
|
@@ -136,3 +142,64 @@ class DocumentExtractor:
|
|
| 136 |
{"role": "system", "content": system_prompt},
|
| 137 |
{"role": "user", "content": user_content},
|
| 138 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
from src.extractors.envelope import compute_overall_confidence, make_envelope
|
| 15 |
from src.extractors.openai_client import OpenAIExtractionClient
|
| 16 |
from src.extractors.prompts import get_prompt
|
| 17 |
+
from src.extractors.section_chunker import chunk_filing
|
| 18 |
from src.schemas import ExtractionResult
|
| 19 |
from src.schemas.registry import get_schema
|
| 20 |
from src.utils.cost_tracker import ExtractionMetrics
|
|
|
|
| 63 |
if loaded.source_type == "empty":
|
| 64 |
raise ValueError(f"Could not load document {filename!r} (unknown or corrupt format).")
|
| 65 |
|
| 66 |
+
# 3. Build the messages. Filings use a section-aware path β a full 10-K
|
| 67 |
+
# is ~150K tokens; we ship only cover + Item 8 + Item 1A to keep
|
| 68 |
+
# per-call cost + latency reasonable and reduce distractor text.
|
| 69 |
+
if doc_type.strip().lower() == "filing":
|
| 70 |
+
messages = self._build_filing_messages(system_prompt, loaded)
|
| 71 |
+
else:
|
| 72 |
+
messages = self._build_messages(system_prompt, loaded)
|
| 73 |
|
| 74 |
# 4. Call the model.
|
| 75 |
envelope, metrics = self._client.parse_structured(
|
|
|
|
| 142 |
{"role": "system", "content": system_prompt},
|
| 143 |
{"role": "user", "content": user_content},
|
| 144 |
]
|
| 145 |
+
|
| 146 |
+
# ------------------------------------------------------------------
|
| 147 |
+
# Filing path
|
| 148 |
+
# ------------------------------------------------------------------
|
| 149 |
+
|
| 150 |
+
# Per-section byte caps. Real 10-K sections rarely exceed ~40 KB; 60 KB
|
| 151 |
+
# gives us headroom for verbose filers (JPM's Item 1A runs long) while
|
| 152 |
+
# keeping total prompt size ~30-40K tokens β well inside gpt-5-nano's cost
|
| 153 |
+
# sweet spot.
|
| 154 |
+
_FILING_COVER_BYTES = 6_000
|
| 155 |
+
_FILING_ITEM_1A_BYTES = 60_000
|
| 156 |
+
_FILING_ITEM_8_BYTES = 60_000
|
| 157 |
+
|
| 158 |
+
def _build_filing_messages(
|
| 159 |
+
self,
|
| 160 |
+
system_prompt: str,
|
| 161 |
+
loaded: LoadedDocument,
|
| 162 |
+
) -> list[dict[str, Any]]:
|
| 163 |
+
"""Message builder for the 10-K path.
|
| 164 |
+
|
| 165 |
+
Slices the loaded text into cover + Item 8 (financials) + Item 1A
|
| 166 |
+
(risk factors) and hands each to the model as a clearly-labeled block.
|
| 167 |
+
Filings are text-first β vision isn't used here (10-K images are
|
| 168 |
+
usually chart infographics, not extraction targets).
|
| 169 |
+
"""
|
| 170 |
+
text = loaded.text or ""
|
| 171 |
+
chunked = chunk_filing(text, cover_bytes=self._FILING_COVER_BYTES)
|
| 172 |
+
|
| 173 |
+
cover = chunked.cover[: self._FILING_COVER_BYTES]
|
| 174 |
+
item_8 = chunked.get_text("8", default="(Item 8 not found in this filing.)")[
|
| 175 |
+
: self._FILING_ITEM_8_BYTES
|
| 176 |
+
]
|
| 177 |
+
item_1a = chunked.get_text("1A", default="(Item 1A not found in this filing.)")[
|
| 178 |
+
: self._FILING_ITEM_1A_BYTES
|
| 179 |
+
]
|
| 180 |
+
|
| 181 |
+
logger.info(
|
| 182 |
+
f"[filing] chunked: cover={len(cover)}B, "
|
| 183 |
+
f"item_8={len(item_8)}B (present={chunked.has('8')}), "
|
| 184 |
+
f"item_1a={len(item_1a)}B (present={chunked.has('1A')}), "
|
| 185 |
+
f"total_items={len(chunked.item_ids)}"
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
user_text = (
|
| 189 |
+
"Extract the structured filing data. Three relevant sections of the "
|
| 190 |
+
"10-K are provided below. Do NOT hallucinate values from other "
|
| 191 |
+
"sections not shown.\n\n"
|
| 192 |
+
"---COVER SECTION---\n"
|
| 193 |
+
f"{cover}\n"
|
| 194 |
+
"---END COVER SECTION---\n\n"
|
| 195 |
+
"---FINANCIAL SECTION (Item 8)---\n"
|
| 196 |
+
f"{item_8}\n"
|
| 197 |
+
"---END FINANCIAL SECTION---\n\n"
|
| 198 |
+
"---RISK FACTORS SECTION (Item 1A)---\n"
|
| 199 |
+
f"{item_1a}\n"
|
| 200 |
+
"---END RISK FACTORS SECTION---"
|
| 201 |
+
)
|
| 202 |
+
return [
|
| 203 |
+
{"role": "system", "content": system_prompt},
|
| 204 |
+
{"role": "user", "content": [{"type": "text", "text": user_text}]},
|
| 205 |
+
]
|
src/extractors/prompts.py
CHANGED
|
@@ -80,10 +80,60 @@ RECEIPT-SPECIFIC NOTES:
|
|
| 80 |
"""
|
| 81 |
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
# Registry so the extractor can look up a prompt by doc_type
|
| 84 |
PROMPTS: dict[str, str] = {
|
| 85 |
"invoice": SYSTEM_PROMPT_INVOICE,
|
| 86 |
"receipt": SYSTEM_PROMPT_RECEIPT,
|
|
|
|
| 87 |
}
|
| 88 |
|
| 89 |
|
|
|
|
| 80 |
"""
|
| 81 |
|
| 82 |
|
| 83 |
+
SYSTEM_PROMPT_FILING = f"""You are a specialized data extraction engine for U.S. SEC 10-K annual reports.
|
| 84 |
+
|
| 85 |
+
You will receive selected sections of a 10-K filing:
|
| 86 |
+
* COVER SECTION β the first pages: registrant name, CIK, ticker, exchange, fiscal-year end.
|
| 87 |
+
* FINANCIAL SECTION β Item 8 (Financial Statements): income statement, balance sheet, cash flows.
|
| 88 |
+
* RISK FACTORS SECTION β Item 1A (Risk Factors): the enumerated risks the registrant discloses.
|
| 89 |
+
|
| 90 |
+
Your job is to output the structured filing data per the provided Pydantic schema.
|
| 91 |
+
|
| 92 |
+
{_COMMON_RULES}
|
| 93 |
+
|
| 94 |
+
10-K-SPECIFIC NOTES:
|
| 95 |
+
|
| 96 |
+
Cover page:
|
| 97 |
+
- `company_name` is the full legal name as printed on the cover (e.g. "Apple Inc.").
|
| 98 |
+
- `cik` is a 10-digit numeric string. If you see fewer than 10 digits, left-pad with zeros.
|
| 99 |
+
- `ticker` is the trading symbol (uppercase). If multiple share classes are listed, pick the
|
| 100 |
+
primary one (usually the first row of the "Securities registered pursuant to Section 12(b)" table).
|
| 101 |
+
- `exchange` should be verbatim β e.g. "NASDAQ Global Select Market", NOT abbreviated to "NASDAQ".
|
| 102 |
+
- `fiscal_year_end` is the LAST day of the fiscal period being reported (not the filing date).
|
| 103 |
+
|
| 104 |
+
Financials (Item 8 β this is the trickiest part):
|
| 105 |
+
- All monetary values must be in **ABSOLUTE currency units**. Real 10-Ks report "in millions"
|
| 106 |
+
or "in thousands" β YOU must multiply back up. Examples:
|
| 107 |
+
* If Apple's income statement says "Net sales ... 391,035" with a header "(In millions)",
|
| 108 |
+
output revenue = 391035000000 (=$391.035B).
|
| 109 |
+
* If the header says "(In thousands)", multiply by 1000.
|
| 110 |
+
* If the header says nothing about scaling, values are already in absolute dollars.
|
| 111 |
+
- Read column headers carefully β 10-Ks usually show multiple fiscal years side-by-side.
|
| 112 |
+
Extract the values from the **most recent completed fiscal year** column (usually the leftmost
|
| 113 |
+
or the one matching `fiscal_year_end`).
|
| 114 |
+
- `total_debt` = short-term borrowings + long-term borrowings (including the current portion).
|
| 115 |
+
If the filing separates them, SUM them. Do NOT include operating leases or accounts payable.
|
| 116 |
+
- If `free_cash_flow` is not explicitly reported in the filing, leave it null. Do NOT compute
|
| 117 |
+
(operating_cash_flow β capex) yourself β that's an analyst step, not an extraction step.
|
| 118 |
+
- `currency` is the reporting currency, typically "USD". Some ADRs report in EUR / GBP / JPY.
|
| 119 |
+
|
| 120 |
+
Risk factors (Item 1A):
|
| 121 |
+
- Return at most FIVE risks in `top_risk_factors`, ranked by materiality (most significant first).
|
| 122 |
+
- Real 10-Ks have 20-80 individual risks. You are TL;DR-ing them. Group related risks under a
|
| 123 |
+
single theme when the disclosure is fragmented (e.g. combine "supply concentration" and
|
| 124 |
+
"single-source components" under "supply-chain concentration").
|
| 125 |
+
- `theme` is a short 2-6 word label. `summary` is 1-3 sentences using the filer's own language
|
| 126 |
+
where possible β do not editorialize or add outside knowledge.
|
| 127 |
+
- If Item 1A is absent, empty, or points elsewhere ("see Item 1A of our 20xx 10-K"), return an
|
| 128 |
+
empty list and add a warning.
|
| 129 |
+
"""
|
| 130 |
+
|
| 131 |
+
|
| 132 |
# Registry so the extractor can look up a prompt by doc_type
|
| 133 |
PROMPTS: dict[str, str] = {
|
| 134 |
"invoice": SYSTEM_PROMPT_INVOICE,
|
| 135 |
"receipt": SYSTEM_PROMPT_RECEIPT,
|
| 136 |
+
"filing": SYSTEM_PROMPT_FILING,
|
| 137 |
}
|
| 138 |
|
| 139 |
|
src/extractors/section_chunker.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Section-aware chunker for SEC 10-K annual reports.
|
| 2 |
+
|
| 3 |
+
Why this exists
|
| 4 |
+
---------------
|
| 5 |
+
A 10-K is 50-250 pages. Even at gpt-5's 400K-token context, feeding the whole
|
| 6 |
+
thing on every extraction is wasteful β a filing weighs ~150K tokens and each
|
| 7 |
+
call costs ~$0.60. Worse, the model has to skim past long stretches of
|
| 8 |
+
irrelevant text to find the fields you asked for.
|
| 9 |
+
|
| 10 |
+
The 10-K format is our friend: every filing structures its content into
|
| 11 |
+
numbered Items. The three we care about for v2:
|
| 12 |
+
|
| 13 |
+
Item 1 β Business (skipped for now)
|
| 14 |
+
Item 1A β Risk Factors β RiskFactor extraction
|
| 15 |
+
Item 7 β MD&A β macroeconomic + strategy context
|
| 16 |
+
Item 8 β Financial Statements β FilingFinancials extraction
|
| 17 |
+
|
| 18 |
+
The cover page (registrant name, CIK, fiscal year, ticker, exchange) lives in
|
| 19 |
+
the first ~2 KB before Item 1 kicks in, so we grab it as an implicit
|
| 20 |
+
"cover" chunk.
|
| 21 |
+
|
| 22 |
+
Method
|
| 23 |
+
------
|
| 24 |
+
Regex over the plaintext for headings that look like `ITEM 1A.` /
|
| 25 |
+
`Item 1A.` / `Item 1A β` / `ITEM 1A β RISK FACTORS`. We build a list of
|
| 26 |
+
(item_id, start_offset) hits, sort them, and slice the text into (item_id β
|
| 27 |
+
text) chunks. The last item runs to end-of-document.
|
| 28 |
+
|
| 29 |
+
We match generously (case-insensitive; Roman-numeral or "PART II" prefixes
|
| 30 |
+
tolerated) because real 10-K formatting is inconsistent across filers and
|
| 31 |
+
years. Downstream code should never assume every item is present β a filer
|
| 32 |
+
might omit Item 1A when it's not material.
|
| 33 |
+
|
| 34 |
+
Not in scope for v2
|
| 35 |
+
-------------------
|
| 36 |
+
- HTML-to-text (already handled upstream by pdfplumber / bs4 in the loader).
|
| 37 |
+
- Table-of-contents skipping: the TOC often lists all items, and our regex
|
| 38 |
+
will match them. We mitigate this by keeping only the *last* occurrence of
|
| 39 |
+
each item id β which is the actual section, not the TOC entry.
|
| 40 |
+
"""
|
| 41 |
+
from __future__ import annotations
|
| 42 |
+
|
| 43 |
+
import re
|
| 44 |
+
from dataclasses import dataclass
|
| 45 |
+
|
| 46 |
+
# Ordered list of items we recognize. Order matters β the chunker returns
|
| 47 |
+
# sections in filing order for humans reading a report side-by-side.
|
| 48 |
+
KNOWN_ITEMS: tuple[str, ...] = (
|
| 49 |
+
"1", "1A", "1B", "1C",
|
| 50 |
+
"2", "3", "4",
|
| 51 |
+
"5", "6", "7", "7A",
|
| 52 |
+
"8", "9", "9A", "9B",
|
| 53 |
+
"10", "11", "12", "13", "14", "15",
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# The regex allows:
|
| 58 |
+
# ITEM 1A. | Item 1A β | ITEM 1A - | Item 1A:
|
| 59 |
+
# case-insensitive, with optional PART header prefix stripped by callers.
|
| 60 |
+
_ITEM_ID_ALT = "|".join(re.escape(x) for x in KNOWN_ITEMS)
|
| 61 |
+
_ITEM_HEADING = re.compile(
|
| 62 |
+
rf"""
|
| 63 |
+
(?:^|\n) # anchor to start of line (permissive)
|
| 64 |
+
\s{{0,6}} # a few leading spaces okay
|
| 65 |
+
ITEM\s+ # the word ITEM
|
| 66 |
+
(?P<item>{_ITEM_ID_ALT}) # captures 1, 1A, 7, 8, ...
|
| 67 |
+
\b # word boundary
|
| 68 |
+
\s*[\.\-ββ:\)]? # optional punctuation: . - β β : )
|
| 69 |
+
""",
|
| 70 |
+
re.IGNORECASE | re.VERBOSE,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@dataclass
|
| 75 |
+
class SectionChunk:
|
| 76 |
+
"""One chunk of a 10-K aligned to a specific Item heading."""
|
| 77 |
+
item: str # normalized item id, e.g. "1A", "7", "8"
|
| 78 |
+
heading: str # the raw matched heading text, e.g. "Item 1A. RISK FACTORS"
|
| 79 |
+
text: str # the chunk body (from just after the heading to the next heading or EOF)
|
| 80 |
+
start: int # byte offset in the original document
|
| 81 |
+
end: int
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@dataclass
|
| 85 |
+
class ChunkedFiling:
|
| 86 |
+
"""A 10-K sliced into cover + Item chunks. Access via `.get('1A')` etc."""
|
| 87 |
+
cover: str
|
| 88 |
+
sections: list[SectionChunk]
|
| 89 |
+
|
| 90 |
+
def get(self, item: str) -> SectionChunk | None:
|
| 91 |
+
"""Return the section for a given item id, or None if absent."""
|
| 92 |
+
want = item.upper().strip()
|
| 93 |
+
for s in self.sections:
|
| 94 |
+
if s.item == want:
|
| 95 |
+
return s
|
| 96 |
+
return None
|
| 97 |
+
|
| 98 |
+
def get_text(self, item: str, default: str = "") -> str:
|
| 99 |
+
s = self.get(item)
|
| 100 |
+
return s.text if s else default
|
| 101 |
+
|
| 102 |
+
def has(self, item: str) -> bool:
|
| 103 |
+
return self.get(item) is not None
|
| 104 |
+
|
| 105 |
+
@property
|
| 106 |
+
def item_ids(self) -> list[str]:
|
| 107 |
+
return [s.item for s in self.sections]
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# --- API -------------------------------------------------------------------
|
| 111 |
+
|
| 112 |
+
def chunk_filing(text: str, cover_bytes: int = 4000) -> ChunkedFiling:
|
| 113 |
+
"""Slice a 10-K text into cover + per-Item chunks.
|
| 114 |
+
|
| 115 |
+
Args:
|
| 116 |
+
text: The full 10-K plaintext (from pdfplumber, bs4, or similar).
|
| 117 |
+
cover_bytes: How much of the doc to treat as the cover chunk. Cover
|
| 118 |
+
pages are usually ~1-3 KB; 4000 is a safe upper bound.
|
| 119 |
+
|
| 120 |
+
Returns:
|
| 121 |
+
A ChunkedFiling. If no Item headings are found (rare β malformed doc
|
| 122 |
+
or non-10-K input), returns a single "sections" entry with the whole
|
| 123 |
+
text under item="0".
|
| 124 |
+
"""
|
| 125 |
+
if not text or not text.strip():
|
| 126 |
+
return ChunkedFiling(cover="", sections=[])
|
| 127 |
+
|
| 128 |
+
hits = _find_all_item_headings(text)
|
| 129 |
+
|
| 130 |
+
# No Item headings β return everything as one blob and let the caller decide.
|
| 131 |
+
if not hits:
|
| 132 |
+
return ChunkedFiling(
|
| 133 |
+
cover=text[:cover_bytes],
|
| 134 |
+
sections=[SectionChunk(item="0", heading="(no headings)", text=text, start=0, end=len(text))],
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
# Deduplicate: keep only the LAST occurrence of each item id β the earlier
|
| 138 |
+
# ones are almost always the Table of Contents entries.
|
| 139 |
+
last_by_item: dict[str, tuple[str, str, int, int]] = {}
|
| 140 |
+
for m in hits:
|
| 141 |
+
item = m["item"]
|
| 142 |
+
last_by_item[item] = (item, m["heading"], m["start"], m["heading_end"])
|
| 143 |
+
|
| 144 |
+
# Now order by start offset (filing order).
|
| 145 |
+
ordered = sorted(last_by_item.values(), key=lambda x: x[2])
|
| 146 |
+
|
| 147 |
+
# Compute end offsets β each section ends where the next one starts.
|
| 148 |
+
sections: list[SectionChunk] = []
|
| 149 |
+
for i, (item, heading, start, heading_end) in enumerate(ordered):
|
| 150 |
+
end = ordered[i + 1][2] if i + 1 < len(ordered) else len(text)
|
| 151 |
+
sections.append(
|
| 152 |
+
SectionChunk(item=item, heading=heading, text=text[heading_end:end], start=start, end=end)
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
cover_end = min(cover_bytes, ordered[0][2])
|
| 156 |
+
return ChunkedFiling(cover=text[:cover_end], sections=sections)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# --- Internals -------------------------------------------------------------
|
| 160 |
+
|
| 161 |
+
def _find_all_item_headings(text: str) -> list[dict]:
|
| 162 |
+
"""Return every regex match as a dict, normalized item id + offsets."""
|
| 163 |
+
out: list[dict] = []
|
| 164 |
+
for m in _ITEM_HEADING.finditer(text):
|
| 165 |
+
item = m.group("item").upper()
|
| 166 |
+
# Grab the rest of the line as the "heading" for debugging.
|
| 167 |
+
line_end = text.find("\n", m.end())
|
| 168 |
+
if line_end == -1:
|
| 169 |
+
line_end = min(m.end() + 120, len(text))
|
| 170 |
+
heading = text[m.start(): line_end].strip()
|
| 171 |
+
out.append({
|
| 172 |
+
"item": item,
|
| 173 |
+
"heading": heading,
|
| 174 |
+
"start": m.start(),
|
| 175 |
+
"heading_end": line_end,
|
| 176 |
+
})
|
| 177 |
+
return out
|
src/schemas/__init__.py
CHANGED
|
@@ -6,6 +6,7 @@ from src.schemas.base import (
|
|
| 6 |
StrictModel,
|
| 7 |
)
|
| 8 |
from src.schemas.common import Address, Party
|
|
|
|
| 9 |
from src.schemas.invoice import Invoice, LineItem
|
| 10 |
from src.schemas.receipt import Receipt, ReceiptLineItem
|
| 11 |
from src.schemas.registry import get_json_schema, get_schema, list_doc_types
|
|
@@ -17,6 +18,10 @@ __all__ = [
|
|
| 17 |
"ExtractionWarning",
|
| 18 |
"Address",
|
| 19 |
"Party",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
"Invoice",
|
| 21 |
"LineItem",
|
| 22 |
"Receipt",
|
|
|
|
| 6 |
StrictModel,
|
| 7 |
)
|
| 8 |
from src.schemas.common import Address, Party
|
| 9 |
+
from src.schemas.filing import Filing, FilingCover, FilingFinancials, RiskFactor
|
| 10 |
from src.schemas.invoice import Invoice, LineItem
|
| 11 |
from src.schemas.receipt import Receipt, ReceiptLineItem
|
| 12 |
from src.schemas.registry import get_json_schema, get_schema, list_doc_types
|
|
|
|
| 18 |
"ExtractionWarning",
|
| 19 |
"Address",
|
| 20 |
"Party",
|
| 21 |
+
"Filing",
|
| 22 |
+
"FilingCover",
|
| 23 |
+
"FilingFinancials",
|
| 24 |
+
"RiskFactor",
|
| 25 |
"Invoice",
|
| 26 |
"LineItem",
|
| 27 |
"Receipt",
|
src/schemas/filing.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SEC filing schema β for 10-K annual reports (v2).
|
| 2 |
+
|
| 3 |
+
Design intent
|
| 4 |
+
-------------
|
| 5 |
+
A 10-K is a very different beast from a receipt: 50-250 pages, structured into
|
| 6 |
+
Items (1, 1A, 7, 8, ...), with a mix of long-form narrative (business
|
| 7 |
+
description, risk factors) and dense financial tables (income statement,
|
| 8 |
+
balance sheet, cash flow).
|
| 9 |
+
|
| 10 |
+
Rather than model the whole document, we extract the three highest-signal
|
| 11 |
+
slices for downstream analysis and evaluation:
|
| 12 |
+
|
| 13 |
+
1. **Cover page** β identity + registration metadata (CIK, fiscal year,
|
| 14 |
+
ticker, exchange). These are canonical, auto-scrapable from EDGAR, and
|
| 15 |
+
make excellent ground-truth targets.
|
| 16 |
+
2. **Financials** β the top-line income statement + balance-sheet
|
| 17 |
+
figures a human analyst would extract for a quick take: revenue, net
|
| 18 |
+
income, EPS, cash, debt, equity. All in the filing's reporting currency
|
| 19 |
+
(almost always USD), normalized to *absolute* dollars (i.e. the
|
| 20 |
+
extractor must undo "in millions"/"in thousands" scaling).
|
| 21 |
+
3. **Top risk factors** β the extractor summarizes Item 1A into up to 5
|
| 22 |
+
themed risk factors. This is the qualitative story a portfolio project
|
| 23 |
+
needs β it shows the LLM doing unstructured β structured reduction, not
|
| 24 |
+
just field-picking.
|
| 25 |
+
|
| 26 |
+
Conventions kept in line with receipt/invoice schemas
|
| 27 |
+
------------------------------------------------------
|
| 28 |
+
- `StrictModel` base (extra="forbid" for OpenAI structured-outputs strict mode).
|
| 29 |
+
- `MoneyAmount = float` (never Decimal β see common.py for the tradeoff).
|
| 30 |
+
- All fields on the sub-models are Optional except a small "required core"
|
| 31 |
+
(company name, form type). Real 10-Ks omit fields; we shouldn't fail on that.
|
| 32 |
+
"""
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
from datetime import date
|
| 36 |
+
|
| 37 |
+
from pydantic import Field, field_validator
|
| 38 |
+
|
| 39 |
+
from src.schemas.base import StrictModel
|
| 40 |
+
from src.schemas.common import Address, MoneyAmount, normalize_currency, round_money
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ---------------------------------------------------------------------------
|
| 44 |
+
# 1) COVER PAGE
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
|
| 47 |
+
class FilingCover(StrictModel):
|
| 48 |
+
"""Registrant + filing metadata from the 10-K cover page.
|
| 49 |
+
|
| 50 |
+
All of this is scrapable from EDGAR's structured header, so it's a strong
|
| 51 |
+
ground-truth target for evaluation. A well-tuned extractor should hit
|
| 52 |
+
~100% F1 on this section β the interesting question is whether it does.
|
| 53 |
+
"""
|
| 54 |
+
|
| 55 |
+
company_name: str = Field(description="Full legal name of the registrant as it appears on the cover.")
|
| 56 |
+
|
| 57 |
+
cik: str | None = Field(
|
| 58 |
+
default=None,
|
| 59 |
+
description=(
|
| 60 |
+
"SEC Central Index Key β 10-digit numeric string with leading zeros preserved "
|
| 61 |
+
"(e.g. '0000320193' for Apple). Extract as printed if visible; else null."
|
| 62 |
+
),
|
| 63 |
+
)
|
| 64 |
+
ticker: str | None = Field(
|
| 65 |
+
default=None, description="Primary trading symbol, e.g. 'AAPL'. Uppercase."
|
| 66 |
+
)
|
| 67 |
+
exchange: str | None = Field(
|
| 68 |
+
default=None,
|
| 69 |
+
description=(
|
| 70 |
+
"Registered exchange (e.g. 'NASDAQ Global Select Market', 'New York Stock Exchange'). "
|
| 71 |
+
"Verbatim from the cover β do NOT abbreviate 'NASDAQ Global Select Market' to 'NASDAQ'."
|
| 72 |
+
),
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
form_type: str = Field(
|
| 76 |
+
default="10-K",
|
| 77 |
+
description="Filing form (10-K, 10-K/A, 10-Q, etc.) as printed on the cover.",
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
fiscal_year_end: date | None = Field(
|
| 81 |
+
default=None,
|
| 82 |
+
description=(
|
| 83 |
+
"Last day of the fiscal year the filing covers (ISO 8601). "
|
| 84 |
+
"Not the filing date β the *reporting period end* date."
|
| 85 |
+
),
|
| 86 |
+
)
|
| 87 |
+
filing_date: date | None = Field(
|
| 88 |
+
default=None,
|
| 89 |
+
description=(
|
| 90 |
+
"Date the 10-K was filed with the SEC (ISO 8601). Sometimes only "
|
| 91 |
+
"the report date is on the cover; this may need to be inferred from the header."
|
| 92 |
+
),
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
state_of_incorporation: str | None = Field(
|
| 96 |
+
default=None,
|
| 97 |
+
description="US state (two-letter) or country of incorporation. Uppercase.",
|
| 98 |
+
)
|
| 99 |
+
address: Address | None = Field(
|
| 100 |
+
default=None,
|
| 101 |
+
description="Principal executive offices address if listed on the cover.",
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
@field_validator("ticker", "state_of_incorporation", mode="before")
|
| 105 |
+
@classmethod
|
| 106 |
+
def _upper(cls, v: str | None) -> str | None:
|
| 107 |
+
return v.strip().upper() if isinstance(v, str) and v.strip() else None
|
| 108 |
+
|
| 109 |
+
@field_validator("cik", mode="before")
|
| 110 |
+
@classmethod
|
| 111 |
+
def _cik(cls, v):
|
| 112 |
+
"""Left-pad CIK to 10 digits if the model returns 320193 not 0000320193."""
|
| 113 |
+
if v is None:
|
| 114 |
+
return None
|
| 115 |
+
s = str(v).strip()
|
| 116 |
+
return s.zfill(10) if s.isdigit() else s
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# ---------------------------------------------------------------------------
|
| 120 |
+
# 2) FINANCIALS
|
| 121 |
+
# ---------------------------------------------------------------------------
|
| 122 |
+
|
| 123 |
+
class FilingFinancials(StrictModel):
|
| 124 |
+
"""Top-line financial figures from the income statement, balance sheet,
|
| 125 |
+
and cash-flow statement (Item 8).
|
| 126 |
+
|
| 127 |
+
Every monetary field is in **absolute** currency units (dollars, not
|
| 128 |
+
millions). The extraction prompt instructs the model to multiply back
|
| 129 |
+
up when the source states "(in millions)" or "(in thousands)". Getting
|
| 130 |
+
the units right is one of the harder eval targets β a factor-of-1000
|
| 131 |
+
error is easy to make and easy to catch.
|
| 132 |
+
"""
|
| 133 |
+
|
| 134 |
+
currency: str = Field(
|
| 135 |
+
default="USD",
|
| 136 |
+
description="ISO 4217 code for the reporting currency (almost always USD).",
|
| 137 |
+
)
|
| 138 |
+
fiscal_year: int | None = Field(
|
| 139 |
+
default=None,
|
| 140 |
+
ge=1900,
|
| 141 |
+
le=2100,
|
| 142 |
+
description="The 4-digit fiscal year these financials correspond to (e.g. 2025).",
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
# --- Income statement -----------------------------------------------
|
| 146 |
+
revenue: MoneyAmount | None = Field(
|
| 147 |
+
default=None,
|
| 148 |
+
description=(
|
| 149 |
+
"Total net revenue (top line) in absolute currency units. "
|
| 150 |
+
"For Apple FY2024 this would be 391_035_000_000 (=$391.035B), NOT 391035."
|
| 151 |
+
),
|
| 152 |
+
)
|
| 153 |
+
cost_of_revenue: MoneyAmount | None = Field(
|
| 154 |
+
default=None, description="Cost of goods/services sold. Absolute units."
|
| 155 |
+
)
|
| 156 |
+
gross_profit: MoneyAmount | None = Field(default=None)
|
| 157 |
+
operating_income: MoneyAmount | None = Field(default=None)
|
| 158 |
+
net_income: MoneyAmount | None = Field(
|
| 159 |
+
default=None, description="Net income attributable to the parent company."
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
# --- Per-share ------------------------------------------------------
|
| 163 |
+
eps_basic: float | None = Field(
|
| 164 |
+
default=None, description="Basic earnings per share (e.g. 6.11 for $6.11)."
|
| 165 |
+
)
|
| 166 |
+
eps_diluted: float | None = Field(default=None, description="Diluted EPS.")
|
| 167 |
+
|
| 168 |
+
# --- Balance sheet ---------------------------------------------------
|
| 169 |
+
cash_and_equivalents: MoneyAmount | None = Field(
|
| 170 |
+
default=None,
|
| 171 |
+
description="Cash and cash equivalents (excluding marketable securities).",
|
| 172 |
+
)
|
| 173 |
+
total_debt: MoneyAmount | None = Field(
|
| 174 |
+
default=None,
|
| 175 |
+
description=(
|
| 176 |
+
"Total debt = short-term + long-term borrowings. If the filing separates "
|
| 177 |
+
"'current portion of long-term debt' from long-term debt, sum them."
|
| 178 |
+
),
|
| 179 |
+
)
|
| 180 |
+
total_assets: MoneyAmount | None = Field(default=None)
|
| 181 |
+
total_equity: MoneyAmount | None = Field(
|
| 182 |
+
default=None, description="Total stockholders' equity attributable to the parent."
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
# --- Cash flow -------------------------------------------------------
|
| 186 |
+
operating_cash_flow: MoneyAmount | None = Field(
|
| 187 |
+
default=None, description="Net cash provided by operating activities."
|
| 188 |
+
)
|
| 189 |
+
free_cash_flow: MoneyAmount | None = Field(
|
| 190 |
+
default=None,
|
| 191 |
+
description=(
|
| 192 |
+
"Free cash flow = operating_cash_flow - capex. If not explicitly reported, "
|
| 193 |
+
"leave null rather than compute it β many 10-Ks don't state it."
|
| 194 |
+
),
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
@field_validator("currency", mode="before")
|
| 198 |
+
@classmethod
|
| 199 |
+
def _currency(cls, v):
|
| 200 |
+
return normalize_currency(v) or "USD"
|
| 201 |
+
|
| 202 |
+
@field_validator(
|
| 203 |
+
"revenue", "cost_of_revenue", "gross_profit", "operating_income",
|
| 204 |
+
"net_income", "cash_and_equivalents", "total_debt",
|
| 205 |
+
"total_assets", "total_equity",
|
| 206 |
+
"operating_cash_flow", "free_cash_flow",
|
| 207 |
+
mode="before",
|
| 208 |
+
)
|
| 209 |
+
@classmethod
|
| 210 |
+
def _money(cls, v):
|
| 211 |
+
return round_money(v) if v is not None else v
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# ---------------------------------------------------------------------------
|
| 215 |
+
# 3) RISK FACTORS (Item 1A)
|
| 216 |
+
# ---------------------------------------------------------------------------
|
| 217 |
+
|
| 218 |
+
class RiskFactor(StrictModel):
|
| 219 |
+
"""A single themed risk distilled from Item 1A.
|
| 220 |
+
|
| 221 |
+
Item 1A in a real 10-K is 20-80 pages of prose. The model reduces it to
|
| 222 |
+
at most ~5 named themes with 1-3 sentence summaries β the "TL;DR" a
|
| 223 |
+
quant would want on a first pass. `theme` is fuzzy-matchable (eval uses
|
| 224 |
+
rapidfuzz); `summary` is scored more leniently.
|
| 225 |
+
"""
|
| 226 |
+
|
| 227 |
+
theme: str = Field(
|
| 228 |
+
description=(
|
| 229 |
+
"Short (2-6 word) label for the risk β e.g. 'supply chain concentration', "
|
| 230 |
+
"'foreign-exchange exposure', 'regulatory / antitrust'."
|
| 231 |
+
),
|
| 232 |
+
)
|
| 233 |
+
summary: str = Field(
|
| 234 |
+
description=(
|
| 235 |
+
"1-3 sentence plain-English summary of the risk. Use the filer's own language "
|
| 236 |
+
"where possible; do not editorialize."
|
| 237 |
+
),
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
# ---------------------------------------------------------------------------
|
| 242 |
+
# TOP-LEVEL FILING
|
| 243 |
+
# ---------------------------------------------------------------------------
|
| 244 |
+
|
| 245 |
+
class Filing(StrictModel):
|
| 246 |
+
"""A structured extract of a single SEC 10-K annual report.
|
| 247 |
+
|
| 248 |
+
Composed of the three sub-schemas above. The top-level model stays flat
|
| 249 |
+
to keep the JSON output easy to consume in a downstream analytics
|
| 250 |
+
pipeline (Snowflake, DuckDB, a dashboard).
|
| 251 |
+
"""
|
| 252 |
+
|
| 253 |
+
cover: FilingCover = Field(description="Registrant identity + filing metadata.")
|
| 254 |
+
financials: FilingFinancials = Field(
|
| 255 |
+
description="Top-line income statement, balance sheet, and cash flow figures."
|
| 256 |
+
)
|
| 257 |
+
top_risk_factors: list[RiskFactor] = Field(
|
| 258 |
+
default_factory=list,
|
| 259 |
+
max_length=5,
|
| 260 |
+
description=(
|
| 261 |
+
"Up to 5 themed risk factors distilled from Item 1A. "
|
| 262 |
+
"Order = the extractor's ranking of importance. Empty list allowed for filings "
|
| 263 |
+
"with unusually short or missing risk sections."
|
| 264 |
+
),
|
| 265 |
+
)
|
src/schemas/registry.py
CHANGED
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
| 8 |
|
| 9 |
from pydantic import BaseModel
|
| 10 |
|
|
|
|
| 11 |
from src.schemas.invoice import Invoice
|
| 12 |
from src.schemas.receipt import Receipt
|
| 13 |
|
|
@@ -15,9 +16,7 @@ from src.schemas.receipt import Receipt
|
|
| 15 |
_REGISTRY: dict[str, type[BaseModel]] = {
|
| 16 |
"invoice": Invoice,
|
| 17 |
"receipt": Receipt,
|
| 18 |
-
# v2
|
| 19 |
-
# "sec_10k": Filing10K,
|
| 20 |
-
# "sec_10q": Filing10Q,
|
| 21 |
}
|
| 22 |
|
| 23 |
|
|
|
|
| 8 |
|
| 9 |
from pydantic import BaseModel
|
| 10 |
|
| 11 |
+
from src.schemas.filing import Filing
|
| 12 |
from src.schemas.invoice import Invoice
|
| 13 |
from src.schemas.receipt import Receipt
|
| 14 |
|
|
|
|
| 16 |
_REGISTRY: dict[str, type[BaseModel]] = {
|
| 17 |
"invoice": Invoice,
|
| 18 |
"receipt": Receipt,
|
| 19 |
+
"filing": Filing, # SEC 10-K (v2). Same key handles 10-K and 10-K/A.
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
|
tests/unit/test_filing_schema.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Filing schema tests β pure Python, no OpenAI. Covers the required-field,
|
| 2 |
+
validator, currency, and CIK-padding contracts."""
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import date
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
from pydantic import ValidationError
|
| 9 |
+
|
| 10 |
+
from src.schemas import Filing, FilingCover, FilingFinancials, RiskFactor
|
| 11 |
+
from src.schemas.registry import get_schema, list_doc_types
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _cover(**overrides) -> FilingCover:
|
| 15 |
+
return FilingCover(company_name="Apple Inc.", **overrides)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _fin(**overrides) -> FilingFinancials:
|
| 19 |
+
return FilingFinancials(**overrides)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# --- Cover ----------------------------------------------------------------
|
| 23 |
+
|
| 24 |
+
def test_cover_requires_company_name_only():
|
| 25 |
+
c = FilingCover(company_name="Northwind Software, Inc.")
|
| 26 |
+
assert c.company_name == "Northwind Software, Inc."
|
| 27 |
+
assert c.form_type == "10-K"
|
| 28 |
+
assert c.cik is None
|
| 29 |
+
assert c.ticker is None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_cover_ticker_uppercased_and_stripped():
|
| 33 |
+
c = _cover(ticker="aapl")
|
| 34 |
+
assert c.ticker == "AAPL"
|
| 35 |
+
c2 = _cover(ticker=" msft ")
|
| 36 |
+
assert c2.ticker == "MSFT"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_cover_ticker_blank_becomes_none():
|
| 40 |
+
c = _cover(ticker=" ")
|
| 41 |
+
assert c.ticker is None
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_cik_gets_zero_padded_from_str_int():
|
| 45 |
+
assert _cover(cik="320193").cik == "0000320193"
|
| 46 |
+
assert _cover(cik=320193).cik == "0000320193"
|
| 47 |
+
# A CIK already at 10 digits stays the same.
|
| 48 |
+
assert _cover(cik="0000320193").cik == "0000320193"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_cik_non_numeric_passes_through_untouched():
|
| 52 |
+
# Belt-and-suspenders: if the LLM returns something weird, don't crash.
|
| 53 |
+
assert _cover(cik="CIK-320193").cik == "CIK-320193"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_cover_dates_parsed_from_iso_strings():
|
| 57 |
+
c = _cover(fiscal_year_end="2024-09-28", filing_date="2024-11-01")
|
| 58 |
+
assert c.fiscal_year_end == date(2024, 9, 28)
|
| 59 |
+
assert c.filing_date == date(2024, 11, 1)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# --- Financials -----------------------------------------------------------
|
| 63 |
+
|
| 64 |
+
def test_financials_defaults_currency_to_usd():
|
| 65 |
+
assert FilingFinancials().currency == "USD"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_financials_currency_normalized():
|
| 69 |
+
assert _fin(currency="usd").currency == "USD"
|
| 70 |
+
assert _fin(currency=" eur ").currency == "EUR"
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_financials_currency_blank_falls_back_to_usd():
|
| 74 |
+
# Model returning "" or spaces shouldn't produce an invalid currency.
|
| 75 |
+
assert _fin(currency="").currency == "USD"
|
| 76 |
+
assert _fin(currency=" ").currency == "USD"
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_financials_money_rounds_to_two_decimals():
|
| 80 |
+
f = _fin(revenue=391035000000.123456, net_income=93736000000.789)
|
| 81 |
+
assert f.revenue == 391035000000.12
|
| 82 |
+
assert f.net_income == 93736000000.79
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_financials_fiscal_year_range_enforced():
|
| 86 |
+
_fin(fiscal_year=2024) # OK
|
| 87 |
+
with pytest.raises(ValidationError):
|
| 88 |
+
_fin(fiscal_year=1500)
|
| 89 |
+
with pytest.raises(ValidationError):
|
| 90 |
+
_fin(fiscal_year=2200)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_financials_all_money_fields_optional():
|
| 94 |
+
# Building with no fields shouldn't raise β 10-K may omit any of them.
|
| 95 |
+
f = FilingFinancials()
|
| 96 |
+
assert f.revenue is None
|
| 97 |
+
assert f.total_debt is None
|
| 98 |
+
assert f.free_cash_flow is None
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# --- Risk factors + top-level Filing -------------------------------------
|
| 102 |
+
|
| 103 |
+
def test_risk_factor_requires_both_theme_and_summary():
|
| 104 |
+
RiskFactor(theme="supply chain", summary="Depends on China manufacturing.")
|
| 105 |
+
with pytest.raises(ValidationError):
|
| 106 |
+
RiskFactor(theme="supply chain") # missing summary
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def test_filing_composes_all_three_subschemas():
|
| 110 |
+
f = Filing(
|
| 111 |
+
cover=_cover(cik="0000320193", ticker="AAPL", fiscal_year_end="2024-09-28"),
|
| 112 |
+
financials=_fin(fiscal_year=2024, revenue=391035000000, net_income=93736000000, eps_diluted=6.11),
|
| 113 |
+
top_risk_factors=[
|
| 114 |
+
RiskFactor(theme="supply chain concentration", summary="China dependency."),
|
| 115 |
+
RiskFactor(theme="FX exposure", summary="Non-US sales volatility."),
|
| 116 |
+
],
|
| 117 |
+
)
|
| 118 |
+
assert f.cover.ticker == "AAPL"
|
| 119 |
+
assert f.financials.revenue == 391035000000.0
|
| 120 |
+
assert len(f.top_risk_factors) == 2
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def test_top_risk_factors_capped_at_five():
|
| 124 |
+
with pytest.raises(ValidationError):
|
| 125 |
+
Filing(
|
| 126 |
+
cover=_cover(),
|
| 127 |
+
financials=_fin(),
|
| 128 |
+
top_risk_factors=[
|
| 129 |
+
RiskFactor(theme=str(i), summary="x") for i in range(6) # 6 > max_length=5
|
| 130 |
+
],
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_top_risk_factors_default_empty_list():
|
| 135 |
+
# A 10-K with no risk section shouldn't fail extraction.
|
| 136 |
+
f = Filing(cover=_cover(), financials=_fin())
|
| 137 |
+
assert f.top_risk_factors == []
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def test_filing_forbids_extra_fields():
|
| 141 |
+
with pytest.raises(ValidationError):
|
| 142 |
+
Filing.model_validate({
|
| 143 |
+
"cover": {"company_name": "X"},
|
| 144 |
+
"financials": {},
|
| 145 |
+
"top_risk_factors": [],
|
| 146 |
+
"extra_field": "nope", # extra="forbid" should reject this
|
| 147 |
+
})
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# --- Registry hookup ------------------------------------------------------
|
| 151 |
+
|
| 152 |
+
def test_filing_registered_and_returned_by_get_schema():
|
| 153 |
+
assert get_schema("filing") is Filing
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def test_list_doc_types_includes_filing():
|
| 157 |
+
assert "filing" in list_doc_types()
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def test_filing_case_insensitive_lookup():
|
| 161 |
+
# get_schema lowercases the input.
|
| 162 |
+
assert get_schema("FILING") is Filing
|
| 163 |
+
assert get_schema("Filing") is Filing
|
tests/unit/test_section_chunker.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the 10-K section chunker."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import textwrap
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from src.extractors.section_chunker import chunk_filing
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
SYNTHETIC_10K = textwrap.dedent("""\
|
| 12 |
+
APPLE INC.
|
| 13 |
+
FORM 10-K
|
| 14 |
+
Annual Report Pursuant to Section 13 or 15(d) of the Securities Exchange Act of 1934
|
| 15 |
+
|
| 16 |
+
Common Stock, $0.00001 par value per share
|
| 17 |
+
NASDAQ Global Select Market
|
| 18 |
+
Central Index Key: 0000320193
|
| 19 |
+
State of Incorporation: California
|
| 20 |
+
|
| 21 |
+
TABLE OF CONTENTS
|
| 22 |
+
Item 1. Business ..................... 3
|
| 23 |
+
Item 1A. Risk Factors ................. 12
|
| 24 |
+
Item 7. MD&A ......................... 34
|
| 25 |
+
Item 8. Financial Statements ......... 42
|
| 26 |
+
|
| 27 |
+
PART I
|
| 28 |
+
|
| 29 |
+
Item 1. Business
|
| 30 |
+
|
| 31 |
+
Apple Inc. designs, manufactures, and markets smartphones, personal
|
| 32 |
+
computers, tablets, wearables, and accessories.
|
| 33 |
+
|
| 34 |
+
Item 1A. Risk Factors
|
| 35 |
+
|
| 36 |
+
The company faces intense competition and the following risks could
|
| 37 |
+
materially affect the business.
|
| 38 |
+
|
| 39 |
+
Supply chain β heavy dependence on manufacturing in China.
|
| 40 |
+
Foreign exchange β a substantial portion of net sales from non-US markets.
|
| 41 |
+
|
| 42 |
+
PART II
|
| 43 |
+
|
| 44 |
+
Item 7. Management's Discussion and Analysis of Financial Condition
|
| 45 |
+
|
| 46 |
+
Net sales grew 3% year over year, driven by Services growth.
|
| 47 |
+
|
| 48 |
+
Item 8. Financial Statements and Supplementary Data
|
| 49 |
+
|
| 50 |
+
Revenue: $391,035 million
|
| 51 |
+
Net income: $93,736 million
|
| 52 |
+
Diluted EPS: $6.11
|
| 53 |
+
Cash and cash equivalents: $29,943 million
|
| 54 |
+
Total assets: $364,980 million
|
| 55 |
+
""")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_finds_the_four_headline_items_in_order():
|
| 59 |
+
c = chunk_filing(SYNTHETIC_10K)
|
| 60 |
+
assert c.item_ids == ["1", "1A", "7", "8"]
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_toc_entries_deduped_last_wins():
|
| 64 |
+
"""A synthetic 10-K has each Item mentioned twice (TOC + real section).
|
| 65 |
+
We keep the LAST occurrence β the real section, not the TOC entry.
|
| 66 |
+
"""
|
| 67 |
+
c = chunk_filing(SYNTHETIC_10K)
|
| 68 |
+
# If we'd kept the TOC entry, Item 1A's text would start with '. Risk Factors'
|
| 69 |
+
# from the TOC. Real section starts with actual body.
|
| 70 |
+
body = c.get_text("1A").lstrip()
|
| 71 |
+
assert body.startswith("The company faces intense competition"), body[:80]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_cover_captured_before_first_item():
|
| 75 |
+
c = chunk_filing(SYNTHETIC_10K)
|
| 76 |
+
assert "APPLE INC." in c.cover
|
| 77 |
+
assert "Central Index Key: 0000320193" in c.cover
|
| 78 |
+
# The cover ends where the FIRST Item heading starts β so the actual
|
| 79 |
+
# "Item 1. Business" section body must NOT be in it.
|
| 80 |
+
assert "Apple Inc. designs, manufactures" not in c.cover
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_section_bodies_do_not_leak_into_neighbors():
|
| 84 |
+
c = chunk_filing(SYNTHETIC_10K)
|
| 85 |
+
# Item 1A's chunk should have risk-factor content, not Item 7 MD&A content.
|
| 86 |
+
body_1a = c.get_text("1A")
|
| 87 |
+
assert "Supply chain" in body_1a
|
| 88 |
+
assert "Net sales grew 3%" not in body_1a
|
| 89 |
+
# Item 8's chunk holds financials, not MD&A.
|
| 90 |
+
body_8 = c.get_text("8")
|
| 91 |
+
assert "Revenue: $391,035" in body_8
|
| 92 |
+
assert "Net sales grew 3%" not in body_8
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_has_and_get_are_case_insensitive():
|
| 96 |
+
c = chunk_filing(SYNTHETIC_10K)
|
| 97 |
+
assert c.has("1a") is True # lowercase input
|
| 98 |
+
assert c.has("1A") is True
|
| 99 |
+
assert c.get("1a") is not None
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def test_missing_item_returns_none():
|
| 103 |
+
c = chunk_filing(SYNTHETIC_10K)
|
| 104 |
+
assert c.get("15") is None
|
| 105 |
+
assert c.get_text("15", default="β") == "β"
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_empty_input_returns_empty_result():
|
| 109 |
+
c = chunk_filing("")
|
| 110 |
+
assert c.cover == ""
|
| 111 |
+
assert c.sections == []
|
| 112 |
+
c2 = chunk_filing(" \n\n ")
|
| 113 |
+
assert c2.sections == []
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_no_headings_returns_single_blob():
|
| 117 |
+
"""A plain document with no Item headings shouldn't crash β degrades gracefully."""
|
| 118 |
+
text = "This document does not follow SEC 10-K structure."
|
| 119 |
+
c = chunk_filing(text)
|
| 120 |
+
assert len(c.sections) == 1
|
| 121 |
+
assert c.sections[0].item == "0"
|
| 122 |
+
assert c.sections[0].text == text
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_variant_heading_punctuation_still_matches():
|
| 126 |
+
# 10-Ks in the wild use "Item 1A -", "Item 1A β", "Item 1A:"
|
| 127 |
+
for sep in [". ", " - ", " β ", ": "]:
|
| 128 |
+
text = f"cover\n\nItem 1A{sep}Risk Factors\n\nThe risks are as follows."
|
| 129 |
+
c = chunk_filing(text)
|
| 130 |
+
assert c.has("1A"), f"failed on separator {sep!r}"
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def test_case_variants_in_source_still_match():
|
| 134 |
+
# ITEM 1A. and item 1a. are both valid in real filings.
|
| 135 |
+
for form in ["Item 1A.", "ITEM 1A.", "item 1a."]:
|
| 136 |
+
text = f"cover\n\n{form} Risk Factors\n\nRisks."
|
| 137 |
+
c = chunk_filing(text)
|
| 138 |
+
assert c.has("1A"), f"failed on form {form!r}"
|
ui/src/components/Dropzone.tsx
CHANGED
|
@@ -13,7 +13,7 @@ import { useCallback, useRef, useState } from "react";
|
|
| 13 |
import type { DocType } from "@/types";
|
| 14 |
import { SAMPLE_DOCS, loadSampleAsFile, type SampleDoc } from "@/lib/samples";
|
| 15 |
|
| 16 |
-
const ACCEPT = "application/pdf,image/png,image/jpeg,image/webp,image/tiff,image/bmp";
|
| 17 |
|
| 18 |
interface Props {
|
| 19 |
file: File | null;
|
|
@@ -206,6 +206,7 @@ function DocTypePicker({
|
|
| 206 |
const opts: { key: DocType; label: string }[] = [
|
| 207 |
{ key: "receipt", label: "Receipt" },
|
| 208 |
{ key: "invoice", label: "Invoice" },
|
|
|
|
| 209 |
];
|
| 210 |
return (
|
| 211 |
<div className="relative z-10 flex items-center gap-2">
|
|
|
|
| 13 |
import type { DocType } from "@/types";
|
| 14 |
import { SAMPLE_DOCS, loadSampleAsFile, type SampleDoc } from "@/lib/samples";
|
| 15 |
|
| 16 |
+
const ACCEPT = "application/pdf,image/png,image/jpeg,image/webp,image/tiff,image/bmp,text/plain,text/html,.txt,.htm,.html";
|
| 17 |
|
| 18 |
interface Props {
|
| 19 |
file: File | null;
|
|
|
|
| 206 |
const opts: { key: DocType; label: string }[] = [
|
| 207 |
{ key: "receipt", label: "Receipt" },
|
| 208 |
{ key: "invoice", label: "Invoice" },
|
| 209 |
+
{ key: "filing", label: "10-K" },
|
| 210 |
];
|
| 211 |
return (
|
| 212 |
<div className="relative z-10 flex items-center gap-2">
|
ui/src/types.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
* Kept minimal β Pydantic v2 on the server is the source of truth.
|
| 4 |
*/
|
| 5 |
|
| 6 |
-
export type DocType = "invoice" | "receipt";
|
| 7 |
|
| 8 |
export interface FieldConfidence {
|
| 9 |
field: string;
|
|
|
|
| 3 |
* Kept minimal β Pydantic v2 on the server is the source of truth.
|
| 4 |
*/
|
| 5 |
|
| 6 |
+
export type DocType = "invoice" | "receipt" | "filing";
|
| 7 |
|
| 8 |
export interface FieldConfidence {
|
| 9 |
field: string;
|