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,
    )