| """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 |
|
|
|
|
| |
|
|
| @dataclass |
| class ParsedSection: |
| heading: str |
| level: int |
| section_number: str |
| 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 |
| 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_PATTERNS = [ |
| |
| (re.compile(r"^\s*(Item\s+\d+[A-Z]?)\.?\s+(.{3,200})$", re.MULTILINE), "item"), |
| |
| (re.compile(r"^\s*(Article\s+\d+[a-z]?)\s*[—:.\-]?\s*(.{3,200})$", re.MULTILINE), "article"), |
| |
| (re.compile(r"^\s*(Chapter\s+(?:\d+|[IVXLCDM]+))\s*[—:.\-]?\s*(.{3,200})$", re.MULTILINE), "chapter"), |
| |
| (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() |
| |
| if kind == "item": |
| level = 2 |
| elif kind == "chapter": |
| level = 1 |
| elif kind == "article": |
| level = 2 |
| elif kind == "numbered": |
| |
| level = min(number.count(".") + 1, 6) |
| else: |
| level = 3 |
| |
| 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, |
| ) |
|
|
| sections = sorted(candidates.values(), key=lambda s: s.char_start) |
| |
| for i, sec in enumerate(sections): |
| sec.char_end = sections[i + 1].char_start if i + 1 < len(sections) else len(text) |
| return sections |
|
|
|
|
| |
|
|
| 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: list[ParsedTable] = [] |
| return full_text, pages, sections, tables |
|
|
|
|
| |
|
|
| |
| _HTML_DROP_TAGS = {"script", "style", "noscript", "head", "meta", "link"} |
| |
| |
|
|
|
|
| def _table_to_markdown(table: Tag) -> tuple[str, int, int]: |
| """Convert a <table> Tag to a simple markdown representation.""" |
| rows: list[list[str]] = [] |
| for tr in table.find_all("tr"): |
| cells = [cell.get_text(" ", strip=True) for cell in tr.find_all(["td", "th"])] |
| if cells: |
| rows.append(cells) |
| if not rows: |
| return "", 0, 0 |
|
|
| n_cols = max(len(r) for r in rows) |
| |
| rows = [r + [""] * (n_cols - len(r)) for r in rows] |
|
|
| md_lines = [] |
| header = rows[0] |
| md_lines.append("| " + " | ".join(header) + " |") |
| md_lines.append("| " + " | ".join(["---"] * n_cols) + " |") |
| for row in rows[1:]: |
| md_lines.append("| " + " | ".join(row) + " |") |
| return "\n".join(md_lines), len(rows), n_cols |
|
|
|
|
| def parse_html(path: Path, *, extract_tables: bool) -> tuple[ |
| str, list[ParsedSection], list[ParsedTable] |
| ]: |
| raw = path.read_bytes() |
| soup = BeautifulSoup(raw, "lxml") |
|
|
| for tag in soup(_HTML_DROP_TAGS): |
| tag.decompose() |
|
|
| |
| |
| parts: list[str] = [] |
| sections: list[ParsedSection] = [] |
| tables: list[ParsedTable] = [] |
| cursor = 0 |
|
|
| body = soup.body or soup |
|
|
| for element in body.descendants: |
| if isinstance(element, NavigableString): |
| |
| if any(isinstance(p, Tag) and p.name in ( |
| "h1", "h2", "h3", "h4", "h5", "h6", "table", "script", "style" |
| ) for p in element.parents): |
| continue |
| text = str(element).strip() |
| if text: |
| block = text + " " |
| parts.append(block) |
| cursor += len(block) |
| continue |
|
|
| if not isinstance(element, Tag): |
| continue |
|
|
| |
| if element.name in ("p", "div", "li", "br", "section", "article"): |
| if parts and not parts[-1].endswith("\n"): |
| parts.append("\n") |
| cursor += 1 |
| continue |
|
|
| if element.name in ("h1", "h2", "h3", "h4", "h5", "h6"): |
| heading_text = element.get_text(" ", strip=True) |
| if not heading_text: |
| continue |
| |
| m = re.match(r"^\s*(Article\s+\d+[a-z]?|Chapter\s+(?:\d+|[IVXLCDM]+)|" |
| r"Item\s+\d+[A-Z]?|\d+(?:\.\d+){0,3})\.?\s*[—:.\-]?\s*(.*)$", |
| heading_text) |
| if m: |
| section_number = m.group(1).strip() |
| heading_clean = (m.group(2) or "").strip() or heading_text |
| else: |
| section_number = "" |
| heading_clean = heading_text |
|
|
| level = int(element.name[1]) |
| char_start = cursor |
| block = f"\n\n{heading_text}\n\n" |
| parts.append(block) |
| cursor += len(block) |
| sections.append(ParsedSection( |
| heading=heading_clean, level=level, section_number=section_number, |
| char_start=char_start, char_end=char_start, |
| )) |
| continue |
|
|
| if element.name == "table" and extract_tables: |
| md, n_rows, n_cols = _table_to_markdown(element) |
| if not md: |
| continue |
| char_start = cursor |
| block = f"\n\n{md}\n\n" |
| parts.append(block) |
| cursor += len(block) |
| tables.append(ParsedTable( |
| markdown=md, char_start=char_start, char_end=cursor, |
| n_rows=n_rows, n_cols=n_cols, |
| )) |
| continue |
|
|
| full_text = "".join(parts) |
|
|
| |
| full_text = re.sub(r"[ \t]+", " ", full_text) |
| full_text = re.sub(r"\n{3,}", "\n\n", full_text) |
|
|
| |
| |
| |
| for sec in sections: |
| idx = full_text.find(sec.heading) |
| if idx >= 0: |
| sec.char_start = idx |
| sections.sort(key=lambda s: s.char_start) |
| for i, sec in enumerate(sections): |
| sec.char_end = sections[i + 1].char_start if i + 1 < len(sections) else len(full_text) |
|
|
| for tbl in tables: |
| idx = full_text.find(tbl.markdown) |
| if idx >= 0: |
| tbl.char_start = idx |
| tbl.char_end = idx + len(tbl.markdown) |
|
|
| |
| if not sections: |
| sections = detect_sections_regex(full_text) |
|
|
| return full_text, sections, tables |
|
|
|
|
| |
|
|
| def parse_document( |
| path: Path, |
| metadata: dict, |
| module: str, |
| ) -> ParsedDoc: |
| """Parse a single document. Dispatches by file extension.""" |
| ext = path.suffix.lower().lstrip(".") |
| extract_tables = (module == "credit") |
|
|
| if ext == "pdf": |
| full_text, pages, sections, tables = parse_pdf(path) |
| elif ext in ("html", "htm", "xhtml"): |
| full_text, sections, tables = parse_html(path, extract_tables=extract_tables) |
| pages = [] |
| else: |
| raise ValueError(f"Unsupported file extension: {ext} ({path})") |
|
|
| return ParsedDoc( |
| doc_id=metadata.get("doc_id", path.stem), |
| doc_title=metadata.get("doc_title", path.stem), |
| doc_type=metadata.get("doc_type", "unknown"), |
| module=module, |
| metadata=metadata, |
| full_text=full_text, |
| pages=pages, |
| sections=sections, |
| tables=tables, |
| ) |
|
|