File size: 12,259 Bytes
657d287 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 | """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 <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)
# Pad ragged 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()
# Walk the tree once. For each visible element, append its text and record
# heading/table positions with accurate char offsets.
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):
# Skip if any ancestor is a heading/table β those are handled at the tag level
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
# Block boundaries β push a newline so paragraphs separate
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
# Try to extract a section number from the heading text
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, # filled later
))
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)
# Backfill section char_end and collapse runs of whitespace
full_text = re.sub(r"[ \t]+", " ", full_text)
full_text = re.sub(r"\n{3,}", "\n\n", full_text)
# Reconstruct offsets after whitespace collapse: section/table char_starts
# were tracked in the unnormalized stream. Re-derive their positions by
# finding their heading/markdown text in the normalized 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 HTML had no semantic headings, fall back to regex-based section detection
if not sections:
sections = detect_sections_regex(full_text)
return full_text, sections, tables
# --- Top-level dispatcher ------------------------------------------------------
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,
)
|