Files
2026-07-13 12:08:54 +08:00

330 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Scenario fixtures for the native docx → SidecarWriter migration tests.
Each scenario describes:
- ``blocks`` — what ``extract_docx_blocks`` would return (the synthetic
block dicts that the adapter consumes).
- ``parse_metadata`` — the dict the upstream parser fills in (only
``first_heading`` is currently consumed by the adapter).
- ``assets`` — files the upstream extractor would have written into
``<base>.blocks.assets/`` before the IR builder runs. Maps relative names
inside the asset dir → byte content.
- ``doc_id`` — fixed so blockid + sidecar ids are deterministic.
- ``file_path`` — used for canonical basename / doc_title fallback.
The captured outputs (``blocks.jsonl`` + per-modality JSONs + assets) live
under ``tests/parser/docx/golden/native_docx/<scenario>/``. The
production path (``LightRAG.parse_native``) must produce byte-identical
bytes vs those fixtures; the regen script under ``scripts/`` rewrites
them when the format intentionally changes.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
def _block(
content: str,
*,
heading: str = "",
level: int = 0,
parent: list[str] | None = None,
uuid: str = "p1",
uuid_end: str | None = None,
table_headers: list[Any] | None = None,
table_chunk_role: str = "none",
) -> dict[str, Any]:
"""Build a synthetic block matching ``extract_docx_blocks`` output."""
out: dict[str, Any] = {
"uuid": uuid,
"uuid_end": uuid_end if uuid_end is not None else uuid,
"heading": heading,
"content": content,
"type": "text",
"parent_headings": list(parent or []),
"level": level,
"table_chunk_role": table_chunk_role,
}
if table_headers is not None:
out["table_headers"] = table_headers
return out
@dataclass
class Scenario:
name: str
doc_id: str
file_path: str # canonical-ish; what the pipeline would pass
blocks: list[dict[str, Any]]
parse_metadata: dict[str, Any] = field(default_factory=dict)
assets: dict[str, bytes] = field(default_factory=dict)
SCENARIOS: list[Scenario] = [
# --- 1: text-only, multi-heading -----------------------------------
Scenario(
name="text_only_hierarchy",
doc_id="doc-aaaa111122223333aaaa111122223333",
file_path="paper.docx",
parse_metadata={"first_heading": "Introduction"},
blocks=[
_block(
"# Introduction",
heading="Introduction",
level=1,
uuid="h1",
),
_block(
"Body paragraph one.",
heading="Introduction",
level=1,
uuid="p1",
uuid_end="p2",
),
_block(
"## Background",
heading="Background",
level=2,
parent=["Introduction"],
uuid="h2",
),
_block(
"Sub body.",
heading="Background",
level=2,
parent=["Introduction"],
uuid="p3",
),
],
),
# --- 2: block + inline equations -----------------------------------
Scenario(
name="equations_block_and_inline",
doc_id="doc-bbbb222233334444bbbb222233334444",
file_path="formulas.docx",
parse_metadata={"first_heading": "Equations"},
blocks=[
_block(
"# Equations",
heading="Equations",
level=1,
uuid="h1",
),
_block(
# Inline equation (no surrounding \n on either side)
"Energy is <equation>E=mc^2</equation> per Einstein.",
heading="Equations",
level=1,
uuid="p1",
),
_block(
# Block equation (wedged between newlines)
"Consider:\n<equation>x^2 + y^2 = r^2</equation>\nThe circle equation.",
heading="Equations",
level=1,
uuid="p2",
),
_block(
# Block at content edge (start == 0)
"<equation>a + b = c</equation>\ntext after",
heading="Equations",
level=1,
uuid="p3",
),
],
),
# --- 3: tables with and without table_headers ----------------------
Scenario(
name="tables_mixed",
doc_id="doc-cccc333344445555cccc333344445555",
file_path="report.docx",
parse_metadata={"first_heading": "Report"},
blocks=[
_block(
"# Report",
heading="Report",
level=1,
uuid="h1",
),
_block(
# Table with table_headers (cross-page repeating)
'See table:\n<table>[["X","Y"],["1","2"],["3","4"]]</table>',
heading="Report",
level=1,
uuid="t1",
table_headers=[[["X", "Y"]]], # one table, one header row
),
_block(
# Table without table_headers
'Plain table:\n<table>[["a","b"]]</table>',
heading="Report",
level=1,
uuid="t2",
),
_block(
# Two tables in one block
'<table>[["p"]]</table>\nthen\n<table>[["q","r"],["s","t"]]</table>',
heading="Report",
level=1,
uuid="t3",
table_headers=[None, [["q", "r"]]],
),
],
),
# --- 4: drawings + assets ------------------------------------------
Scenario(
name="drawings_with_assets",
doc_id="doc-dddd444455556666dddd444455556666",
file_path="diagrams.docx",
parse_metadata={"first_heading": "Diagrams"},
assets={
"fig1.png": b"\x89PNG\r\n\x1a\n-fig1-fake",
"fig2.jpg": b"\xff\xd8\xff\xe0-fig2-fake",
},
blocks=[
_block(
"# Diagrams",
heading="Diagrams",
level=1,
uuid="h1",
),
_block(
"Figure one:\n"
'<drawing id="x" format="png" '
'path="diagrams.blocks.assets/fig1.png" '
'src="docx://image1" />\n'
"Figure two:\n"
'<drawing id="y" format="jpg" '
'path="diagrams.blocks.assets/fig2.jpg" '
'src="docx://image2" />',
heading="Diagrams",
level=1,
uuid="p1",
),
],
),
# --- 5: all modalities mixed ---------------------------------------
Scenario(
name="all_modalities",
doc_id="doc-eeee555566667777eeee555566667777",
file_path="combo.docx",
parse_metadata={"first_heading": "Combined"},
assets={"pic.png": b"PNG-combo"},
blocks=[
_block(
"# Combined",
heading="Combined",
level=1,
uuid="h1",
),
_block(
"Look at this figure:\n"
'<drawing id="z" format="png" '
'path="combo.blocks.assets/pic.png" '
'src="docx://img" />\n'
"Plus a table:\n"
'<table>[["α","β"],["γ","δ"]]</table>\n'
"And a block equation:\n"
"<equation>F = ma</equation>\n"
"And an inline <equation>v=d/t</equation> here.",
heading="Combined",
level=1,
uuid="p1",
),
],
),
# --- 6: empty block dropped ----------------------------------------
Scenario(
name="empty_block_dropped",
doc_id="doc-ffff666677778888ffff666677778888",
file_path="sparse.docx",
parse_metadata={"first_heading": "Sparse"},
blocks=[
_block(
"# Sparse",
heading="Sparse",
level=1,
uuid="h1",
),
_block(
" \n ", # strips to empty — must be dropped
heading="Sparse",
level=1,
uuid="p_empty",
),
_block(
"Real content after empty.",
heading="Sparse",
level=1,
uuid="p_real",
),
],
),
# --- 7: external / linked image references ------------------------
# DOCX can carry ``<a:blip r:link="rId…"/>`` references to image
# targets that live outside the package — the upstream extractor
# then emits ``<drawing path="<external URL or unresolved path>" />``
# WITHOUT writing bytes into ``<base>.blocks.assets/``. The adapter
# must pass those paths through verbatim (both in ``blocks.jsonl``
# and ``drawings.json``); turning them into AssetSpecs with
# ``source=None`` would make the writer warn-and-skip → ``path=""``,
# losing the only reference downstream consumers have.
Scenario(
name="external_image_link",
doc_id="doc-1111aaaa2222bbbb1111aaaa2222bbbb",
file_path="linked.docx",
parse_metadata={"first_heading": "Linked"},
# No on-disk assets — the path points elsewhere.
assets={},
blocks=[
_block(
"# Linked",
heading="Linked",
level=1,
uuid="h1",
),
_block(
"See the diagram online:\n"
'<drawing id="z" format="png" '
'path="https://example.com/diagrams/architecture.png" '
'src="docx://external" />\n'
"And a relative-but-not-asset path:\n"
'<drawing id="z2" format="gif" '
'path="../images/legacy.gif" '
'src="docx://legacy" />',
heading="Linked",
level=1,
uuid="p1",
),
],
),
# --- 8: missing paraid ---------------------------------------------
Scenario(
name="missing_paraid",
doc_id="doc-99990000111122229999000011112222",
file_path="legacy.docx",
parse_metadata={"first_heading": ""}, # no headings at all
blocks=[
_block(
"Just plain text without a heading.",
heading="",
level=0,
uuid="", # missing
uuid_end="",
),
_block(
"Another paragraph with no paraId.",
heading="",
level=0,
uuid="",
uuid_end="",
),
],
),
]
__all__ = ["Scenario", "SCENARIOS", "_block"]