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:
2026-08-18 10:22:17 +03:30
parent 80ed5b1577
commit 5cdfb70085
26 changed files with 2438 additions and 18 deletions

View File

@@ -0,0 +1,225 @@
"""DOCX parsing against the real sample corpus (ADR-0004, ADR-0018).
Every assertion here was written after reading the document it covers, so the
numbers are observations rather than predictions.
"""
import pytest
from src.application.ingestion import ContentType, DocumentParseError, parse_docx
from src.application.ingestion.docx_parser import heading_level_from_style
from src.config import ChunkingSettings
from tests.support.documents import (
LAYOUT_DOCX,
LIST_DOCX,
MIXED_DOCX,
PROSE_DOCX,
TABLE_DOCX,
load_document,
)
pytestmark = pytest.mark.unit
@pytest.fixture
def settings() -> ChunkingSettings:
return ChunkingSettings()
def _units(filename: str, settings: ChunkingSettings):
return parse_docx(load_document(filename), settings).units
def _types(units) -> set[ContentType]:
return {unit.content_type for unit in units}
# ── heading styles ────────────────────────────────────────────────
@pytest.mark.parametrize(
("style", "expected"),
[
("Heading1", 1), # the styleId Word actually stores
("Heading 1", 1), # the friendly name python-docx reports
("heading 2", 2),
("HEADING3", 3),
("Normal", None),
("List Paragraph", None),
("Heading", None),
("Headingfoo", None),
],
)
def test_heading_level_from_style_maps_word_styles_only(style: str, expected: int | None) -> None:
assert heading_level_from_style(style) == expected
def test_parse_docx_invents_no_headings_when_document_declares_none(
settings: ChunkingSettings,
) -> None:
"""Every document in the corpus lacks Heading styles, so this is the norm.
The evaluation repo upgraded paragraphs to headings by text pattern; a
wrongly-detected heading silently reshapes the tree, so ADR-0018 drops it.
"""
parsed = parse_docx(load_document(PROSE_DOCX), settings)
assert "#" not in (parsed.markdown or "")
# ── prose documents ───────────────────────────────────────────────
def test_parse_docx_prose_document_becomes_one_flowing_block(
settings: ChunkingSettings,
) -> None:
"""Consecutive paragraphs accumulate into one prose unit (ADR-0004).
One unit per paragraph would hand the splitter 36 fragments instead of
flowing text.
"""
units = _units(PROSE_DOCX, settings)
assert len(units) == 1
assert units[0].content_type is ContentType.PARAGRAPH
assert units[0].text.count("\n\n") == 35
def test_parse_docx_list_styled_paragraphs_stay_prose(settings: ChunkingSettings) -> None:
units = _units(LIST_DOCX, settings)
assert _types(units) == {ContentType.PARAGRAPH}
def test_parse_docx_normalizes_persian_letterforms(settings: ChunkingSettings) -> None:
parsed = parse_docx(load_document(PROSE_DOCX), settings)
# No Arabic kaf or yeh survives normalization.
assert chr(0x0643) not in (parsed.markdown or "")
assert chr(0x064A) not in (parsed.markdown or "")
def test_parse_docx_preserves_paragraph_breaks_in_markdown(
settings: ChunkingSettings,
) -> None:
"""Normalization collapses whitespace, so it must run per block.
Applied to the assembled document it would flatten every paragraph onto a
single line -- the easiest way to get this wrong.
"""
parsed = parse_docx(load_document(PROSE_DOCX), settings)
assert "\n\n" in (parsed.markdown or "")
# ── data tables ───────────────────────────────────────────────────
def test_parse_docx_data_table_becomes_one_unit_per_row(settings: ChunkingSettings) -> None:
"""A 35-row contact table: one header row plus 34 data rows."""
units = _units(TABLE_DOCX, settings)
assert len(units) == 34
assert _types(units) == {ContentType.TABLE_ROW}
def test_parse_docx_data_table_rows_are_labeled_with_headers(
settings: ChunkingSettings,
) -> None:
units = _units(TABLE_DOCX, settings)
assert "\n" in units[0].text
assert all(":" in line for line in units[0].text.split("\n"))
def test_parse_docx_vertically_merged_cell_repeats_on_every_row(
settings: ChunkingSettings,
) -> None:
"""The group column is merged down several rows; each row must carry it."""
units = _units(TABLE_DOCX, settings)
labels = [line.split(":")[0] for unit in units for line in unit.text.split("\n")]
group_label = units[1].text.split(":")[0]
assert labels.count(group_label) > 1
def test_parse_docx_empty_cell_is_omitted_not_rendered_as_bare_label(
settings: ChunkingSettings,
) -> None:
"""The first data row has no group; it should have one fewer field."""
units = _units(TABLE_DOCX, settings)
assert len(units[0].text.split("\n")) < len(units[1].text.split("\n"))
# ── tables without a header ───────────────────────────────────────
def test_parse_docx_headerless_table_keeps_every_row(settings: ChunkingSettings) -> None:
"""A 30-row compensation list plus an 11-row table, all rows preserved.
Regression: scanning forward for a header row discarded the rows above the
match and labeled the rest from a data row (`80: 70`).
"""
units = _units(MIXED_DOCX, settings)
rows = [unit for unit in units if unit.content_type is ContentType.TABLE_ROW]
assert len(rows) == 41
def test_parse_docx_headerless_table_rows_are_unlabeled(settings: ChunkingSettings) -> None:
"""No header is provable, so cells are joined rather than mislabeled."""
units = _units(MIXED_DOCX, settings)
first_row = next(unit for unit in units if unit.content_type is ContentType.TABLE_ROW)
assert " | " in first_row.text
assert first_row.text.startswith("1 | ")
def test_parse_docx_mixed_document_interleaves_prose_and_rows(
settings: ChunkingSettings,
) -> None:
units = _units(MIXED_DOCX, settings)
assert _types(units) == {ContentType.PARAGRAPH, ContentType.TABLE_ROW}
# ── layout tables ─────────────────────────────────────────────────
def test_parse_docx_extracts_document_held_inside_a_table_cell(
settings: ChunkingSettings,
) -> None:
"""One merged cell holds 77k characters across 738 paragraphs.
Regression: a merged cell reported once per spanned grid position was
deduplicated by `id()`, which lxml reuses across element proxies, so the
whole sub-document vanished and the file yielded 70 tokens.
"""
units = _units(LAYOUT_DOCX, settings)
assert sum(len(unit.text) for unit in units) > 70_000
def test_parse_docx_layout_table_cells_become_prose_not_rows(
settings: ChunkingSettings,
) -> None:
"""A cell that alone exceeds chunk_size holds a document, not a field."""
units = _units(LAYOUT_DOCX, settings)
assert ContentType.PARAGRAPH in _types(units)
# ── failure modes ─────────────────────────────────────────────────
def test_parse_docx_unopenable_bytes_raises_parse_error(settings: ChunkingSettings) -> None:
with pytest.raises(DocumentParseError, match="Could not open DOCX"):
parse_docx(b"not a docx at all", settings)
def test_parse_docx_legacy_doc_binary_raises_parse_error(settings: ChunkingSettings) -> None:
"""`.doc` is rejected rather than converted (ADR-0018); python-docx cannot open it."""
with pytest.raises(DocumentParseError):
parse_docx(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" + b"\x00" * 512, settings)