"""Parse heterogeneous source documents into a uniform `ParsedDoc` shape. Inputs: - Regulatory PDFs (Basel, Bank Act, Fed Reg W) → pdfplumber - Regulatory HTML (OSFI, FINTRAC, GDPR) → BeautifulSoup - EDGAR filings (10-K/10-Q/8-K/40-F/6-K) → BeautifulSoup (XBRL-aware via tag stripping) Output (per source file): ParsedDoc with: - full_text: one big string, the canonical text - pages: list of (page_number, char_start, char_end) — populated for PDFs only - sections: list of (heading, level, section_number, char_start, char_end) - tables: list of (markdown_repr, char_start, char_end) — credit module only CRITICAL: char_start/char_end indices into full_text are the foundation for the dual-track evaluation (Track A overlap-based relevance). Sections, pages, and tables MUST have accurate offsets — every chunker reads from these. """ from __future__ import annotations import re from dataclasses import dataclass, field from pathlib import Path from typing import Optional import pdfplumber from bs4 import BeautifulSoup, NavigableString, Tag # --- Data shapes --------------------------------------------------------------- @dataclass class ParsedSection: heading: str level: int # 1..6 section_number: str # "1.2.3" or "Article 5" or "Item 7A" etc., "" if unknown char_start: int char_end: int @dataclass class ParsedPage: page_number: int char_start: int char_end: int @dataclass class ParsedTable: markdown: str char_start: int char_end: int n_rows: int n_cols: int @dataclass class ParsedDoc: doc_id: str doc_title: str doc_type: str module: str # 'compliance' | 'credit' metadata: dict full_text: str pages: list[ParsedPage] = field(default_factory=list) sections: list[ParsedSection] = field(default_factory=list) tables: list[ParsedTable] = field(default_factory=list) def to_dict(self) -> dict: return { "doc_id": self.doc_id, "doc_title": self.doc_title, "doc_type": self.doc_type, "module": self.module, "metadata": self.metadata, "full_text": self.full_text, "n_chars": len(self.full_text), "pages": [vars(p) for p in self.pages], "sections": [vars(s) for s in self.sections], "tables": [vars(t) for t in self.tables], } # --- Section detection (regex-based, used for PDFs and as fallback) ------------ # Note: ordering matters — more specific patterns first. # Each pattern captures (section_number, heading_text). SECTION_PATTERNS = [ # SEC 10-K Items: "Item 1.", "Item 1A.", "Item 7.", etc. (re.compile(r"^\s*(Item\s+\d+[A-Z]?)\.?\s+(.{3,200})$", re.MULTILINE), "item"), # GDPR-style articles: "Article 5", "Article 17 — Right to erasure" (re.compile(r"^\s*(Article\s+\d+[a-z]?)\s*[—:.\-]?\s*(.{3,200})$", re.MULTILINE), "article"), # Chapters: "Chapter I", "Chapter 1 — Title" (re.compile(r"^\s*(Chapter\s+(?:\d+|[IVXLCDM]+))\s*[—:.\-]?\s*(.{3,200})$", re.MULTILINE), "chapter"), # Numbered sections: "1. Title", "1.2 Title", "1.2.3 Title" (re.compile(r"^\s*(\d+(?:\.\d+){0,3})\.?\s+([A-Z][^\n]{3,200})$", re.MULTILINE), "numbered"), ] def detect_sections_regex(text: str) -> list[ParsedSection]: """Run all section regexes; merge by char_start; assign levels by depth.""" candidates: dict[int, ParsedSection] = {} for pat, kind in SECTION_PATTERNS: for m in pat.finditer(text): number = m.group(1).strip() heading = m.group(2).strip() char_start = m.start() # Determine level if kind == "item": level = 2 # SEC Items are sub-document elif kind == "chapter": level = 1 elif kind == "article": level = 2 elif kind == "numbered": # depth = number of dots + 1 (1 → level 1, 1.2 → level 2, 1.2.3 → level 3) level = min(number.count(".") + 1, 6) else: level = 3 # Earliest match at a given char_start wins (most specific pattern, since list-ordered) if char_start not in candidates: candidates[char_start] = ParsedSection( heading=heading, level=level, section_number=number, char_start=char_start, char_end=char_start, # filled in below ) sections = sorted(candidates.values(), key=lambda s: s.char_start) # Fill char_end as start of the next section (or end of text) for i, sec in enumerate(sections): sec.char_end = sections[i + 1].char_start if i + 1 < len(sections) else len(text) return sections # --- PDF parsing --------------------------------------------------------------- def parse_pdf(path: Path) -> tuple[str, list[ParsedPage], list[ParsedSection], list[ParsedTable]]: full_text_parts: list[str] = [] pages: list[ParsedPage] = [] cursor = 0 with pdfplumber.open(str(path)) as pdf: for page_idx, page in enumerate(pdf.pages, start=1): text = page.extract_text() or "" text = text.strip() if not text: continue block = text + "\n\n" char_start = cursor full_text_parts.append(block) cursor += len(block) pages.append(ParsedPage(page_number=page_idx, char_start=char_start, char_end=cursor)) full_text = "".join(full_text_parts) sections = detect_sections_regex(full_text) # Tables in PDFs are noisy; defer extraction (the credit pipeline mostly cares # about EDGAR HTML tables, which we handle via BeautifulSoup below). tables: list[ParsedTable] = [] return full_text, pages, sections, tables # --- HTML parsing -------------------------------------------------------------- # Tags whose text we drop entirely (script/style/etc.) _HTML_DROP_TAGS = {"script", "style", "noscript", "head", "meta", "link"} # XBRL tags that EDGAR filings embed inline; we keep their text content. # (BeautifulSoup .get_text() handles this naturally — we don't strip them.) def _table_to_markdown(table: Tag) -> tuple[str, int, int]: """Convert a