feat(ingestion): add DOCX/CSV/XLSX parsing and fixed-size chunking (ADR-0018)
Adds src/application/ingestion/ -- Persian normalization, DOCX body walk with structural data/layout table classification, CSV/XLSX row rendering, and fixed-size token chunking (cl100k_base, 400/60/512) -- as pure functions per ADR-0015, tested against real production documents (asia_data_sample, kept out of the repo). ADR-0018 records where this diverges from ADR-0004 (fixed-size default, no invented headings/tree, structural table classification, header-provable labeling only). Plan 001's scope line is corrected from CSV-only to DOCX/XLSX/CSV, and CLAUDE.md's stale project-status paragraph is updated to match current implementation state.
This commit is contained in:
167
src/application/ingestion/tabular.py
Normal file
167
src/application/ingestion/tabular.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Shared row-to-chunk rendering for every tabular source (ADR-0004, ADR-0018).
|
||||
|
||||
A table row is an atomic structural unit regardless of the container it
|
||||
arrived in -- a docx table, an xlsx sheet, or a csv file -- so one renderer
|
||||
serves all three and a Q&A sheet needs no special case against a branch
|
||||
directory:
|
||||
|
||||
q: ... ردیف: 1
|
||||
a: ... استان: اردبیل
|
||||
شعبه: پارس آباد
|
||||
|
||||
The header rules exist because real tables are not uniform. Of the tables in
|
||||
the sample corpus, some carry a header row, one is a bare list of values with
|
||||
no header at all, and one is page decoration. The guiding constraint is
|
||||
therefore: **never invent structure that is not provably there, and never
|
||||
discard a row.** A wrongly-detected header turns every chunk into nonsense
|
||||
(`80: 70`), which is worse than an unlabeled row.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable, Sequence
|
||||
|
||||
from src.application.ingestion.normalization import normalize_persian_text
|
||||
|
||||
Row = Sequence[str]
|
||||
|
||||
# A header cell is a label, not a sentence.
|
||||
MAX_HEADER_CELL_LENGTH = 80
|
||||
|
||||
# How much shorter a header cell must be than the column beneath it before
|
||||
# length alone is taken as evidence of a header (the `q`/`a` case, where
|
||||
# one-character labels sit above paragraph-long answers).
|
||||
_HEADER_LENGTH_RATIO = 3.0
|
||||
|
||||
|
||||
def clean_cell(value: object) -> str:
|
||||
"""Normalize a cell to text; None and blank cells become the empty string.
|
||||
|
||||
`object` rather than a union: a spreadsheet cell holds whatever the
|
||||
workbook stored -- str, int, float, bool, datetime, a formula error -- and
|
||||
every one of them is handled the same way, by rendering it.
|
||||
"""
|
||||
if value is None:
|
||||
return ""
|
||||
return normalize_persian_text(str(value))
|
||||
|
||||
|
||||
def _is_numeric(text: str) -> bool:
|
||||
return bool(text) and text.replace(",", "").replace(".", "").replace("-", "").isdigit()
|
||||
|
||||
|
||||
def _looks_like_title_row(row: Row) -> bool:
|
||||
"""A merged title spanning the sheet resolves to one value, or repeats it."""
|
||||
populated = [cell for cell in row if cell]
|
||||
return len(populated) < 2 or len(set(populated)) == 1
|
||||
|
||||
|
||||
def has_header(rows: Sequence[Row]) -> bool:
|
||||
"""Whether the first row labels the columns beneath it.
|
||||
|
||||
Decided by comparing row 0 against the column below it, not by how row 0
|
||||
looks on its own: a label row is *inconsistent* with its data (text above
|
||||
numbers, or a short label above long prose), while a data row is
|
||||
consistent with the rows that follow. This is the test `csv.Sniffer`
|
||||
uses, and it is a property of the table rather than a pattern borrowed
|
||||
from one document.
|
||||
"""
|
||||
if len(rows) < 2:
|
||||
return False
|
||||
|
||||
candidate, data = rows[0], rows[1:]
|
||||
if not all(cell for cell in candidate[: len(data[0])] if cell) and _looks_like_title_row(
|
||||
candidate
|
||||
):
|
||||
return False
|
||||
if any(len(cell) > MAX_HEADER_CELL_LENGTH for cell in candidate):
|
||||
return False
|
||||
|
||||
for index, label in enumerate(candidate):
|
||||
if not label:
|
||||
continue
|
||||
column = [row[index] for row in data if index < len(row) and row[index]]
|
||||
if not column:
|
||||
continue
|
||||
|
||||
# A text label above a numeric column.
|
||||
if not _is_numeric(label) and all(_is_numeric(value) for value in column):
|
||||
return True
|
||||
|
||||
# A short label above a column of much longer values.
|
||||
mean_length = sum(len(value) for value in column) / len(column)
|
||||
if mean_length > len(label) * _HEADER_LENGTH_RATIO:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def strip_title_rows(rows: Sequence[Row]) -> Sequence[Row]:
|
||||
"""Drop leading merged-title and blank rows.
|
||||
|
||||
Structural, not a content guess: these rows are the artifact of a merged
|
||||
range spanning the sheet width, so they carry one value across many cells.
|
||||
Only *leading* rows are dropped, so no data row is ever lost.
|
||||
"""
|
||||
start = 0
|
||||
while start < len(rows) and _looks_like_title_row(rows[start]):
|
||||
start += 1
|
||||
return rows[start:]
|
||||
|
||||
|
||||
def _column_name(header: Row, index: int) -> str:
|
||||
"""Return a header label, falling back positionally past the header width."""
|
||||
if index < len(header) and header[index]:
|
||||
return header[index]
|
||||
return f"column_{index + 1}"
|
||||
|
||||
|
||||
def _dedupe_horizontal_merge(row: Row) -> list[str]:
|
||||
"""Collapse the repeats a horizontally merged cell produces.
|
||||
|
||||
Both python-docx and openpyxl report a merged cell once per grid column it
|
||||
spans, so an unlabeled row would otherwise repeat the same value.
|
||||
"""
|
||||
collapsed: list[str] = []
|
||||
for cell in row:
|
||||
if cell and (not collapsed or collapsed[-1] != cell):
|
||||
collapsed.append(cell)
|
||||
return collapsed
|
||||
|
||||
|
||||
def render_rows(rows: Sequence[Row]) -> list[str]:
|
||||
"""Render table rows as text, one string per row.
|
||||
|
||||
With a provable header each row becomes `"{header}: {value}"` lines, which
|
||||
makes it self-describing. Without one, cells are joined with `" | "` --
|
||||
unlabeled, but never mislabeled.
|
||||
"""
|
||||
rows = strip_title_rows(rows)
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
if not has_header(rows):
|
||||
return [text for row in rows if (text := " | ".join(_dedupe_horizontal_merge(row)))]
|
||||
|
||||
header, data = rows[0], rows[1:]
|
||||
# A header merged vertically across two rows resolves to the same text in
|
||||
# the row below it; that duplicate is the header, not data.
|
||||
while data and list(data[0]) == list(header):
|
||||
data = data[1:]
|
||||
|
||||
rendered: list[str] = []
|
||||
for row in data:
|
||||
lines = [
|
||||
f"{_column_name(header, index)}: {value}" for index, value in enumerate(row) if value
|
||||
]
|
||||
if lines:
|
||||
rendered.append("\n".join(lines))
|
||||
return rendered
|
||||
|
||||
|
||||
def clean_rows(rows: Iterable[Iterable[object]]) -> list[Row]:
|
||||
"""Normalize every cell and drop rows that are entirely empty."""
|
||||
cleaned: list[Row] = []
|
||||
for row in rows:
|
||||
values = [clean_cell(cell) for cell in row]
|
||||
if any(values):
|
||||
cleaned.append(values)
|
||||
return cleaned
|
||||
Reference in New Issue
Block a user