aditya0103 commited on
Commit
c6cfb1f
Β·
1 Parent(s): bc61ea7

v2: SEC 10-K pipeline - schema + section chunker + EDGAR downloader

Browse files
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 sample | _v2 in progress_ | _v2_ | _v2_ | _v2_ | _v2_ |
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** _(in progress)_
244
- - [ ] v2 β€” SEC Filings pipeline (10-K / 10-Q, long-doc handling)
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
- messages = self._build_messages(system_prompt, loaded)
 
 
 
 
 
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 additions will land here:
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;