Spaces:
Sleeping
Sleeping
| """Parse downloaded 10-K HTML files into chunks ready for embedding. | |
| Pipeline: | |
| data/raw/{TICKER}_{FY}/filing.htm + metadata.json | |
| βββΆ partition_html βββΆ list[Element] | |
| βββΆ segment by Title, split tables off | |
| βββΆ chunk_by_title within each section (narrative) | |
| βββΆ emit table elements as their own chunks | |
| data/processed/{TICKER}_{FY}.jsonl (one Chunk per line) | |
| data/processed/manifest.json | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import json | |
| from pathlib import Path | |
| from typing import Any | |
| from pydantic import BaseModel | |
| # NOTE: `unstructured` is imported lazily inside parse_filing() (see below), not | |
| # at module top. It's a heavy, ingestion-only dependency (torch/transformers via | |
| # unstructured[pdf]) kept OUT of the runtime image β but this module is imported | |
| # transitively at runtime for its constants (PROCESSED_DIR, Chunk), so a top-level | |
| # import would crash the server. Install it for ingestion with `uv sync --group ingestion`. | |
| # ββ Paths βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # parse.py β ingestion/ β finrag/ β src/ β backend/ β ROOT | |
| REPO_ROOT = Path(__file__).resolve().parents[4] | |
| RAW_DIR = REPO_ROOT / "data" / "raw" | |
| PROCESSED_DIR = REPO_ROOT / "data" / "processed" | |
| # ββ Chunking knobs (see Decision 5 discussion for reasoning) ββββββββββββββ | |
| # max: hard ceiling. Stays comfortably under Cohere embed-v3's ~512 token limit. | |
| # new_after: soft target β lets natural breaks happen before reaching max. | |
| # combine_under: absorbs tiny stub chunks (lone section titles, footers). | |
| # overlap: only kicks in when a section is split mid-section due to length. | |
| MAX_CHARACTERS = 1500 | |
| NEW_AFTER_N_CHARS = 1200 | |
| COMBINE_TEXT_UNDER_N_CHARS = 200 | |
| OVERLAP = 150 | |
| # ββ Models ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class Chunk(BaseModel): | |
| """One retrievable unit: either a narrative passage or a single table. | |
| Every field after `chunk_type` is provenance β copied from the filing's | |
| metadata.json so the chunk is self-contained when it lands in Qdrant. | |
| The retriever can filter on any of these fields without joining back. | |
| """ | |
| chunk_id: str # deterministic SHA-256 hash, 16 hex chars | |
| text: str # the actual content to embed | |
| chunk_type: str # "narrative" | "table" | |
| section_title: str | None | |
| section_path: list[str] | |
| # Filing provenance | |
| ticker: str | |
| company_name: str | |
| fiscal_year: int | |
| period_of_report: str # ISO date string | |
| accession_number: str | |
| sec_url: str | |
| # Position within document β currently a monotonic ordinal per filing. | |
| # On Day 2+ this becomes the anchor for citation-viewer highlighting. | |
| element_index: int | |
| # ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _hash_chunk(ticker: str, fiscal_year: int, position: int, text: str) -> str: | |
| """Stable ID. Including `text` means changing chunking params produces | |
| new IDs rather than silently overwriting old vectors with new content.""" | |
| h = hashlib.sha256() | |
| h.update(f"{ticker}|{fiscal_year}|{position}|".encode()) | |
| h.update(text.encode()) | |
| return h.hexdigest()[:16] | |
| def _load_filing_metadata(filing_dir: Path) -> dict[str, Any]: | |
| return json.loads((filing_dir / "metadata.json").read_text()) | |
| def _element_category(el: Any) -> str: | |
| # Unstructured elements expose `.category`; fall back to class name. | |
| return getattr(el, "category", type(el).__name__) | |
| def _table_text(el: Any) -> str: | |
| """Prefer the HTML representation β preserves rows/columns for the | |
| embedder. Falls back to flattened text if HTML isn't available.""" | |
| md = getattr(el, "metadata", None) | |
| if md is not None: | |
| html = getattr(md, "text_as_html", None) | |
| if html: | |
| return html | |
| return el.text | |
| # ββ Core ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def parse_filing(filing_dir: Path) -> list[Chunk]: | |
| """Read a filing directory, return chunks ready for embedding.""" | |
| # Lazy import: keeps `unstructured` out of the runtime import path (it's only | |
| # needed here, during offline ingestion). Clear hint if the group is missing. | |
| try: | |
| from unstructured.chunking.title import chunk_by_title | |
| from unstructured.partition.html import partition_html | |
| except ModuleNotFoundError as e: # pragma: no cover | |
| raise ModuleNotFoundError( | |
| "Ingestion requires the 'unstructured' extra. Install it with " | |
| "`uv sync --group ingestion` (it's excluded from the runtime image)." | |
| ) from e | |
| meta = _load_filing_metadata(filing_dir) | |
| htm_path = filing_dir / "filing.htm" | |
| # Stage 1 β atomize the HTML into typed elements. | |
| # `partition_html` is slow on first run (downloads NLTK data); fast after. | |
| elements = partition_html(filename=str(htm_path)) | |
| # Stage 2a β walk elements once, building two parallel structures: | |
| # - sections: a list of section buckets, each holding narrative elements | |
| # - tables: pulled out into their own stream with section context attached | |
| # | |
| # Why bucket by section ourselves rather than relying on chunk_by_title's | |
| # implicit handling? Because we need clean `section_title` attribution per | |
| # chunk, and Unstructured's CompositeElement doesn't always expose the | |
| # underlying Title element reliably across versions. | |
| sections: list[dict[str, Any]] = [] | |
| tables: list[dict[str, Any]] = [] | |
| current_section: dict[str, Any] | None = None | |
| for idx, el in enumerate(elements): | |
| category = _element_category(el) | |
| if category == "Title": | |
| # Start a new section bucket. The Title element itself goes in | |
| # so chunk_by_title sees it as the leading boundary. | |
| current_section = { | |
| "title": el.text, | |
| "elements": [el], | |
| } | |
| sections.append(current_section) | |
| elif category == "Table": | |
| tables.append( | |
| { | |
| "idx": idx, | |
| "element": el, | |
| "section_title": current_section["title"] if current_section else None, | |
| } | |
| ) | |
| else: | |
| # Anything else: NarrativeText, ListItem, Header, etc. | |
| if current_section is None: | |
| # Content before the first Title (cover page, etc.) | |
| current_section = {"title": None, "elements": []} | |
| sections.append(current_section) | |
| current_section["elements"].append(el) | |
| chunks: list[Chunk] = [] | |
| position = 0 # monotonic counter, used as element_index for stable IDs | |
| # Stage 2b β chunk narrative *within* each section. | |
| # By calling chunk_by_title per section, chunks never cross section | |
| # boundaries β a guarantee we couldn't make with a single document-wide call. | |
| for section in sections: | |
| if not section["elements"]: | |
| continue | |
| composite_chunks = chunk_by_title( | |
| section["elements"], | |
| max_characters=MAX_CHARACTERS, | |
| new_after_n_chars=NEW_AFTER_N_CHARS, | |
| combine_text_under_n_chars=COMBINE_TEXT_UNDER_N_CHARS, | |
| overlap=OVERLAP, | |
| ) | |
| for cc in composite_chunks: | |
| text = cc.text.strip() | |
| if not text: | |
| continue # skip empty composite results | |
| chunks.append( | |
| Chunk( | |
| chunk_id=_hash_chunk( | |
| meta["ticker"], meta["fiscal_year"], position, text | |
| ), | |
| text=text, | |
| chunk_type="narrative", | |
| section_title=section["title"], | |
| section_path=[section["title"]] if section["title"] else [], | |
| ticker=meta["ticker"], | |
| company_name=meta["company_name"], | |
| fiscal_year=meta["fiscal_year"], | |
| period_of_report=meta["period_of_report"], | |
| accession_number=meta["accession_number"], | |
| sec_url=meta["sec_url"], | |
| element_index=position, | |
| ) | |
| ) | |
| position += 1 | |
| # Stage 2c β emit tables as their own chunks. | |
| # The text is the table's HTML (when available), which keeps cell/column | |
| # structure visible to the embedder. Tables that are very large will get | |
| # truncated by Cohere's 512-token limit β accepted, because Day 2's | |
| # DuckDB extractor will handle these structurally anyway. | |
| for tbl in tables: | |
| text = _table_text(tbl["element"]).strip() | |
| if not text: | |
| continue | |
| chunks.append( | |
| Chunk( | |
| chunk_id=_hash_chunk( | |
| meta["ticker"], meta["fiscal_year"], position, text | |
| ), | |
| text=text, | |
| chunk_type="table", | |
| section_title=tbl["section_title"], | |
| section_path=[tbl["section_title"]] if tbl["section_title"] else [], | |
| ticker=meta["ticker"], | |
| company_name=meta["company_name"], | |
| fiscal_year=meta["fiscal_year"], | |
| period_of_report=meta["period_of_report"], | |
| accession_number=meta["accession_number"], | |
| sec_url=meta["sec_url"], | |
| element_index=position, | |
| ) | |
| ) | |
| position += 1 | |
| return chunks | |
| # ββ CLI βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _write_chunks(chunks: list[Chunk], out_file: Path) -> None: | |
| with out_file.open("w", encoding="utf-8") as f: | |
| for c in chunks: | |
| f.write(c.model_dump_json() + "\n") | |
| def _read_chunks(out_file: Path) -> list[Chunk]: | |
| return [ | |
| Chunk.model_validate_json(line) | |
| for line in out_file.read_text(encoding="utf-8").splitlines() | |
| if line.strip() | |
| ] | |
| def main() -> None: | |
| PROCESSED_DIR.mkdir(parents=True, exist_ok=True) | |
| filing_dirs = sorted( | |
| d for d in RAW_DIR.iterdir() if d.is_dir() and (d / "metadata.json").exists() | |
| ) | |
| print(f"Found {len(filing_dirs)} filings to parse.\n") | |
| manifest_entries: list[dict[str, Any]] = [] | |
| for filing_dir in filing_dirs: | |
| meta = _load_filing_metadata(filing_dir) | |
| ticker = meta["ticker"] | |
| fy = meta["fiscal_year"] | |
| out_file = PROCESSED_DIR / f"{ticker}_{fy}.jsonl" | |
| if out_file.exists(): | |
| chunks = _read_chunks(out_file) | |
| print(f" β³ skip {ticker} FY{fy} ({len(chunks)} chunks on disk)") | |
| else: | |
| print(f" β³ parse {ticker} FY{fy}β¦", end="", flush=True) | |
| chunks = parse_filing(filing_dir) | |
| _write_chunks(chunks, out_file) | |
| print(f" β {len(chunks)} chunks") | |
| manifest_entries.append( | |
| { | |
| "ticker": ticker, | |
| "fiscal_year": fy, | |
| "chunks_total": len(chunks), | |
| "chunks_narrative": sum(1 for c in chunks if c.chunk_type == "narrative"), | |
| "chunks_table": sum(1 for c in chunks if c.chunk_type == "table"), | |
| "path": out_file.relative_to(REPO_ROOT).as_posix(), | |
| } | |
| ) | |
| manifest_path = PROCESSED_DIR / "manifest.json" | |
| manifest_path.write_text(json.dumps({"filings": manifest_entries}, indent=2)) | |
| total = sum(e["chunks_total"] for e in manifest_entries) | |
| print(f"\nDone. {total} chunks across {len(manifest_entries)} filings.") | |
| print(f"Manifest: {manifest_path}") | |
| if __name__ == "__main__": | |
| main() | |