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

690 lines
25 KiB
Python

"""Spec-compliant sidecar writer.
This module is the *single executable specification* of the LightRAG sidecar
format (``docs/LightRAGSidecarFormat-zh.md``). Engine adapters hand it an
:class:`IRDoc`; it emits the ``*.parsed/`` directory.
Responsibilities (none of these belong in adapters):
- id allocation: ``tb-/im-/eq-<doc_hash>-NNNN`` (4-digit zero-padded,
global per-doc sequence)
- placeholder rendering: ``{{TBL:k}}`` / ``{{IMG:k}}`` / ``{{EQ:k}}`` /
``{{EQI:k}}`` → spec-shaped XML-style tags
- blockid computation: ``md5(doc_id:block_index:heading:content)``
- assets dir creation and file copying; ``asset_dir`` flag in meta is
derived from "directory exists and is non-empty"
- merged_text + document_hash
- meta line shape (spec §3.1)
- conditional writes: ``tables.json`` / ``drawings.json`` / ``equations.json``
appear only when their dict is non-empty
"""
from __future__ import annotations
import hashlib
import json
import re
import shutil
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from lightrag.constants import FULL_DOCS_FORMAT_LIGHTRAG
from lightrag.sidecar.ir import (
AssetSpec,
IRBlock,
IRDoc,
IRDrawing,
IREquation,
IRTable,
)
from lightrag.sidecar.placeholders import (
render_drawing_tag,
render_equation_tag,
render_table_tag,
render_template,
table_body_for_rows,
)
from lightrag.table_markup import header_grid_to_thead_html
from lightrag.utils import logger, strip_control_characters
# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
_VALID_BLOCK_DRAWING_PATH_STYLES = {"with_prefix", "basename_only"}
def write_sidecar(
ir: IRDoc,
*,
parsed_dir: Path,
doc_id: str,
engine: str,
clean_parsed_dir: bool = True,
block_drawing_path_style: str = "with_prefix",
) -> dict[str, Any]:
"""Emit a spec-compliant ``*.parsed/`` directory from an IR.
Args:
ir: Document IR produced by an engine adapter.
parsed_dir: Output directory. By default cleared and recreated; the
caller is responsible for placing it under
``__parsed__/<base>.parsed/``.
doc_id: ``doc-<md5>``; ``doc_hash`` for sidecar ids is the 32-char
tail after stripping the ``doc-`` prefix.
engine: One of ``native`` / ``mineru`` / ``docling`` / ``legacy``;
written verbatim to ``meta.parse_engine``.
clean_parsed_dir: When True (default) the writer ``rmtree``s
``parsed_dir`` before writing. Set to False when the caller has
already pre-populated the directory with side artifacts that
must survive — e.g. the native docx adapter pre-extracts image
bytes into ``<base>.blocks.assets/`` before the writer runs,
and passing ``AssetSpec.source=None`` lets the writer record
them without copying.
block_drawing_path_style: How ``<drawing path="...">`` in
``blocks.jsonl`` resolves the asset path. ``"with_prefix"``
(default) renders ``<base>.blocks.assets/<filename>`` — matches
the path stored in ``drawings.json``. ``"basename_only"``
renders just ``<filename>``; legacy native docx convention
(downstream consumers read the file path from ``drawings.json``,
not from this attribute, so the basename-only form is purely
cosmetic but kept for byte-equivalence with the original
adapter).
Returns:
Dict shaped like the pipeline's existing ``parsed_data`` payload:
``{doc_id, file_path, parse_format, content, blocks_path}``.
``file_path`` is ``ir.document_name``; the caller resolves it to the
actual on-disk path it wants persisted.
"""
if block_drawing_path_style not in _VALID_BLOCK_DRAWING_PATH_STYLES:
allowed = ", ".join(sorted(_VALID_BLOCK_DRAWING_PATH_STYLES))
raise ValueError(
f"block_drawing_path_style must be one of {allowed}, "
f"got {block_drawing_path_style!r}"
)
if clean_parsed_dir and parsed_dir.exists():
shutil.rmtree(parsed_dir)
parsed_dir.mkdir(parents=True, exist_ok=True)
base_name = Path(ir.document_name).stem or ir.document_name
blocks_path = parsed_dir / f"{base_name}.blocks.jsonl"
tables_path = parsed_dir / f"{base_name}.tables.json"
drawings_path = parsed_dir / f"{base_name}.drawings.json"
equations_path = parsed_dir / f"{base_name}.equations.json"
assets_dir = parsed_dir / f"{base_name}.blocks.assets"
# ``clean_parsed_dir=False`` is reserved for callers that pre-populate
# the directory with artifacts that must survive (e.g. the native docx
# adapter pre-extracts assets). If a stale ``blocks.jsonl`` is sitting
# there, the caller forgot to pre-clean — warn so the leftover doesn't
# get silently overwritten with partially-stale neighbors.
if not clean_parsed_dir and blocks_path.exists():
logger.warning(
"[sidecar] clean_parsed_dir=False but %s already exists; "
"caller is expected to pre-clean before invoking write_sidecar",
blocks_path,
)
# Stage 1: realize assets first so drawings can carry resolved paths.
asset_paths = _materialize_assets(ir.assets, assets_dir)
# Stage 2: walk blocks, allocate ids, render templates, accumulate
# sidecar item dicts and blocks.jsonl lines.
doc_hash = doc_id.removeprefix("doc-")
tables: dict[str, dict[str, Any]] = {}
drawings: dict[str, dict[str, Any]] = {}
equations: dict[str, dict[str, Any]] = {}
blocks_lines: list[str] = []
merged_parts: list[str] = []
table_seq = 0
drawing_seq = 0
equation_seq = 0
asset_prefix = f"{assets_dir.name}/"
# ``block_index`` in the blockid hash refers to the position in the
# SOURCE block list (``enumerate`` over ``ir.blocks``), not the emitted
# position. Otherwise an editor turning a previously-non-empty block
# into an empty one — which then gets dropped — would shift the
# blockids of every block after it; we want stable ids across edits.
for block_index, block in enumerate(ir.blocks):
# Allocate ids for items declared on this block. Order: tables ->
# drawings -> equations (per-block deterministic; the global
# sequence advances across blocks).
table_id_by_key: dict[str, str] = {}
for table in block.tables:
table_seq += 1
tb_id = f"tb-{doc_hash}-{table_seq:04d}"
table_id_by_key[table.placeholder_key] = tb_id
drawing_id_by_key: dict[str, str] = {}
for drawing in block.drawings:
drawing_seq += 1
im_id = f"im-{doc_hash}-{drawing_seq:04d}"
drawing_id_by_key[drawing.placeholder_key] = im_id
equation_id_by_key: dict[str, str] = {}
for equation in block.equations:
if not equation.is_block:
continue
equation_seq += 1
eq_id = f"eq-{doc_hash}-{equation_seq:04d}"
equation_id_by_key[equation.placeholder_key] = eq_id
# Render placeholder template.
rendered = _render_block_content(
block,
table_id_by_key=table_id_by_key,
drawing_id_by_key=drawing_id_by_key,
equation_id_by_key=equation_id_by_key,
asset_paths=asset_paths,
asset_prefix=asset_prefix,
block_drawing_path_style=block_drawing_path_style,
)
# Strip C0 control/separator chars (incl. \x1c-\x1f FS/GS/RS/US) before
# whitespace-trimming so they cannot survive into blockid, blocks.jsonl
# content (the chunk source), merged_text/full_docs content, or
# document_hash. A block that is only control chars + whitespace then
# collapses to empty and is dropped below. No-op for clean input, so
# existing blockids/hashes are preserved.
rendered = strip_control_characters(rendered).strip()
if not rendered:
# Drop empty blocks entirely — neither blocks.jsonl entry nor
# sidecar items (the items were tied to the placeholder; if it
# vanished, the items are orphans). This mirrors the existing
# native_docx behaviour and ensures merged_text is contiguous.
continue
blockid = hashlib.md5(
f"{doc_id}:{block_index}:{block.heading}:{rendered}".encode("utf-8")
).hexdigest()
# Realize per-block sidecar item dicts now that blockid is known.
# Defensive: an adapter that declares an item on block.tables /
# drawings / equations but omits the matching ``{{TBL/IMG/EQ:k}}``
# token from ``content_template`` would leave the rendered text
# without the corresponding tag. We detect that by checking whether
# the allocated id (which is doc-unique) appears in the rendered
# output, warn, and skip the sidecar entry — otherwise the per-
# modality JSON would reference a blockid whose body never names it.
for table in block.tables:
tb_id = table_id_by_key[table.placeholder_key]
if tb_id not in rendered:
logger.warning(
"[sidecar] orphan table id=%s on block %d "
"(placeholder %r not referenced in content_template); "
"skipping sidecar entry",
tb_id,
block_index,
table.placeholder_key,
)
continue
tables[tb_id] = _table_item_dict(
tb_id, blockid, block.heading, block.parent_headings, table
)
for drawing in block.drawings:
im_id = drawing_id_by_key[drawing.placeholder_key]
if im_id not in rendered:
logger.warning(
"[sidecar] orphan drawing id=%s on block %d "
"(placeholder %r not referenced in content_template); "
"skipping sidecar entry",
im_id,
block_index,
drawing.placeholder_key,
)
continue
drawings[im_id] = _drawing_item_dict(
im_id,
blockid,
block.heading,
block.parent_headings,
drawing,
asset_paths,
asset_prefix,
)
for equation in block.equations:
if not equation.is_block:
continue
eq_id = equation_id_by_key[equation.placeholder_key]
if eq_id not in rendered:
logger.warning(
"[sidecar] orphan equation id=%s on block %d "
"(placeholder %r not referenced in content_template); "
"skipping sidecar entry",
eq_id,
block_index,
equation.placeholder_key,
)
continue
equations[eq_id] = _equation_item_dict(
eq_id, blockid, block.heading, block.parent_headings, equation
)
row: dict[str, Any] = {
"type": "content",
"blockid": blockid,
"format": "plain_text",
"content": rendered,
"heading": block.heading,
"parent_headings": list(block.parent_headings),
"level": int(block.level),
"session_type": block.session_type or "body",
"table_slice": block.table_slice or "none",
"positions": [p.to_jsonable() for p in block.positions],
}
if block.table_header:
row["table_header"] = block.table_header
blocks_lines.append(json.dumps(row, ensure_ascii=False))
merged_parts.append(rendered)
# Stage 3: doc-level metadata.
merged_text = "\n\n".join(p for p in merged_parts if p.strip())
document_hash = hashlib.sha256(merged_text.encode("utf-8")).hexdigest()
parse_time = datetime.now(timezone.utc).isoformat()
asset_dir_present = assets_dir.exists() and any(assets_dir.iterdir())
if not asset_dir_present and assets_dir.exists():
try:
assets_dir.rmdir()
except OSError:
pass
meta: dict[str, Any] = {
"type": "meta",
"format": "lightrag",
"version": "1.0",
"document_name": ir.document_name,
"document_format": ir.document_format,
"document_hash": f"sha256:{document_hash}",
"table_file": bool(tables),
"equation_file": bool(equations),
"drawing_file": bool(drawings),
"asset_dir": asset_dir_present,
}
# Engine-recorded metadata (e.g. engine_version); omitted entirely when the
# engine recorded nothing so the common native/markdown case doesn't carry a
# dead ``{}`` field. Inserted here to keep the meta key order stable.
split_option = dict(ir.split_option or {})
if split_option:
meta["split_option"] = split_option
meta.update(
{
"blocks": len(blocks_lines),
"doc_id": doc_id,
"parse_engine": engine,
"parse_time": parse_time,
"doc_title": ir.doc_title,
}
)
if ir.bbox_attributes is not None:
meta["bbox_attributes"] = dict(ir.bbox_attributes)
blocks_path.write_text(
"\n".join([json.dumps(meta, ensure_ascii=False)] + blocks_lines) + "\n",
encoding="utf-8",
)
# Sidecar JSONs end with a trailing newline (POSIX text-file convention;
# also keeps end-of-file linters / pre-commit hooks happy and matches the
# ``blocks.jsonl`` convention above).
if tables:
tables_path.write_text(
json.dumps(
{"version": "1.0", "tables": tables},
ensure_ascii=False,
indent=2,
)
+ "\n",
encoding="utf-8",
)
if drawings:
drawings_path.write_text(
json.dumps(
{"version": "1.0", "drawings": drawings},
ensure_ascii=False,
indent=2,
)
+ "\n",
encoding="utf-8",
)
if equations:
equations_path.write_text(
json.dumps(
{"version": "1.0", "equations": equations},
ensure_ascii=False,
indent=2,
)
+ "\n",
encoding="utf-8",
)
logger.info(
"[sidecar] wrote %d blocks for doc_id=%s "
"(%d tables, %d drawings, %d equations, assets=%s, engine=%s)",
len(blocks_lines),
doc_id,
len(tables),
len(drawings),
len(equations),
asset_dir_present,
engine,
)
return {
"doc_id": doc_id,
"file_path": ir.document_name,
"parse_format": FULL_DOCS_FORMAT_LIGHTRAG,
"content": merged_text,
"blocks_path": str(blocks_path),
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _materialize_assets(
assets: list[AssetSpec],
assets_dir: Path,
) -> dict[str, str]:
"""Materialize :class:`AssetSpec` objects into ``assets_dir``.
Returns: ``{ref: filename_inside_assets_dir}``.
Collision policy: if two specs map to the same target name, the second
gets a ``-2``, ``-3``, ... suffix on the stem. We never overwrite a file
we've already produced.
"""
if not assets:
return {}
assets_dir.mkdir(parents=True, exist_ok=True)
out: dict[str, str] = {}
used_names: set[str] = set()
for spec in assets:
target_name = _allocate_unique_name(spec.suggested_name, used_names)
target_path = assets_dir / target_name
try:
target_path.resolve().relative_to(assets_dir.resolve())
except ValueError:
logger.warning(
"[sidecar] unsafe asset target for ref=%s (%s); skipping",
spec.ref,
spec.suggested_name,
)
continue
if isinstance(spec.source, (str, Path)):
src_path = Path(spec.source)
if not src_path.exists():
logger.warning(
"[sidecar] asset source missing for ref=%s (%s); skipping copy",
spec.ref,
src_path,
)
continue
if src_path.resolve() != target_path.resolve():
shutil.copyfile(src_path, target_path)
elif isinstance(spec.source, bytes):
target_path.write_bytes(spec.source)
elif spec.source is None:
# Assumed already on disk at the target location (native_docx
# writes assets during extraction). Verify presence; warn if
# missing.
if not target_path.exists():
logger.warning(
"[sidecar] asset ref=%s declared in place but %s is absent",
spec.ref,
target_path,
)
continue
else:
logger.warning(
"[sidecar] unsupported AssetSpec.source type for ref=%s: %s",
spec.ref,
type(spec.source).__name__,
)
continue
used_names.add(target_name)
out[spec.ref] = target_name
return out
def _allocate_unique_name(suggested: str, used: set[str]) -> str:
"""Make ``suggested`` unique within ``used``: ``foo.png`` → ``foo-2.png``."""
suggested = _safe_asset_filename(suggested)
if suggested not in used:
return suggested
stem = Path(suggested).stem
suffix = Path(suggested).suffix
n = 2
while True:
cand = f"{stem}-{n}{suffix}"
if cand not in used:
return cand
n += 1
def _safe_asset_filename(suggested: str) -> str:
"""Collapse parser-suggested asset names to a safe filename."""
name = Path(str(suggested).replace("\\", "/")).name
name = "".join(c for c in name.strip() if ord(c) >= 32 and c != "\x7f").strip(".")
return name or "asset"
def _render_block_content(
block: IRBlock,
*,
table_id_by_key: dict[str, str],
drawing_id_by_key: dict[str, str],
equation_id_by_key: dict[str, str],
asset_paths: dict[str, str],
asset_prefix: str,
block_drawing_path_style: str = "with_prefix",
) -> str:
"""Expand placeholder tokens in ``block.content_template``."""
tables_by_key = {t.placeholder_key: t for t in block.tables}
drawings_by_key = {d.placeholder_key: d for d in block.drawings}
equations_by_key = {e.placeholder_key: e for e in block.equations}
def _table(key: str) -> str:
table = tables_by_key.get(key)
if table is None:
return ""
tb_id = table_id_by_key.get(key, "")
if table.body_override is not None:
# Verbatim block-text body — used by adapters that need to
# preserve the parser's original whitespace/escaping (native
# docx). Sidecar entry's ``content`` field still gets the
# canonical ``table_body_for_rows`` encoding via
# ``_table_item_dict``.
fmt = "json" if table.rows is not None else "html"
return render_table_tag(tb_id, fmt, table.body_override)
if table.rows is not None:
return render_table_tag(tb_id, "json", table_body_for_rows(table.rows))
return render_table_tag(tb_id, "html", table.html or "")
def _drawing(key: str) -> str:
drawing = drawings_by_key.get(key)
if drawing is None:
return ""
im_id = drawing_id_by_key.get(key, "")
if drawing.path_override is not None:
# Verbatim external/linked reference — pass through unchanged.
path = drawing.path_override
else:
filename = asset_paths.get(drawing.asset_ref, "")
if not filename:
path = ""
elif block_drawing_path_style == "basename_only":
path = filename
else:
path = f"{asset_prefix}{filename}"
return render_drawing_tag(
im_id,
drawing.fmt,
drawing.caption,
path,
drawing.src,
)
def _equation(key: str) -> str:
eq = equations_by_key.get(key)
if eq is None:
return ""
if not eq.is_block:
# Adapter mistake: an EQ token should only be used for block
# equations. Treat as inline to avoid a dangling token.
return render_equation_tag(None, eq.latex, eq.caption)
eq_id = equation_id_by_key.get(key, "")
return render_equation_tag(eq_id, eq.latex, eq.caption)
def _inline_equation(key: str) -> str:
eq = equations_by_key.get(key)
if eq is None:
return ""
return render_equation_tag(None, eq.latex, eq.caption)
return render_template(
block.content_template,
table_renderer=_table,
drawing_renderer=_drawing,
equation_renderer=_equation,
inline_equation_renderer=_inline_equation,
)
def _table_item_dict(
table_id: str,
blockid: str,
heading: str,
parent_headings: list[str],
table: IRTable,
) -> dict[str, Any]:
if table.rows is not None:
fmt = "json"
content = table_body_for_rows(table.rows)
else:
fmt = "html"
content = table.html or ""
item: dict[str, Any] = {
"id": table_id,
"blockid": blockid,
"heading": heading,
"parent_headings": list(parent_headings),
"dimension": [int(table.num_rows), int(table.num_cols)],
"format": fmt,
"content": content,
"caption": table.caption,
"footnotes": list(table.footnotes),
}
if table.table_header is not None:
# The header's stored format follows the table's format (spec §5):
# * HTML tables → a raw ``<thead>…</thead>`` string stored verbatim so
# merged cells (``rowspan``/``colspan``) survive; a grid supplied for
# an HTML table is rendered to a (span-less) ``<thead>`` as fallback.
# * JSON tables → a JSON 2-D array string.
if fmt == "html":
if isinstance(table.table_header, str):
item["table_header"] = table.table_header
else:
item["table_header"] = header_grid_to_thead_html(table.table_header)
elif isinstance(table.table_header, str):
raise ValueError(
f"JSON table {table_id!r} has a string table_header "
f"({table.table_header[:40]!r}…); JSON tables require a 2-D grid"
)
else:
item["table_header"] = json.dumps(table.table_header, ensure_ascii=False)
if table.self_ref:
item["self_ref"] = table.self_ref
if table.extras:
item["extras"] = dict(table.extras)
return item
def _drawing_item_dict(
drawing_id: str,
blockid: str,
heading: str,
parent_headings: list[str],
drawing: IRDrawing,
asset_paths: dict[str, str],
asset_prefix: str,
) -> dict[str, Any]:
if drawing.path_override is not None:
path = drawing.path_override
else:
filename = asset_paths.get(drawing.asset_ref, "")
path = f"{asset_prefix}{filename}" if filename else ""
item: dict[str, Any] = {
"id": drawing_id,
"blockid": blockid,
"heading": heading,
"parent_headings": list(parent_headings),
"format": drawing.fmt,
"path": path,
"src": drawing.src,
"caption": drawing.caption,
"footnotes": list(drawing.footnotes),
}
if drawing.self_ref:
item["self_ref"] = drawing.self_ref
if drawing.extras:
item["extras"] = dict(drawing.extras)
return item
_LATEX_DOLLAR_RE = re.compile(r"^\s*\$\$?(.+?)\$\$?\s*$", re.DOTALL)
def _strip_latex_dollar_wrappers(latex: str) -> str:
"""Strip leading/trailing ``$``/``$$`` wrappers from a latex string.
``equations.json`` stores clean latex (per the MinerU adapter contract:
``blocks.jsonl`` keeps the parser's raw form so the rendered
``<equation>`` body is byte-identical to the source, while the
per-equation sidecar carries delimiter-free latex). Leaves strings
without wrappers untouched.
"""
if not latex:
return latex
m = _LATEX_DOLLAR_RE.match(latex)
return m.group(1).strip() if m else latex.strip()
def _equation_item_dict(
eq_id: str,
blockid: str,
heading: str,
parent_headings: list[str],
equation: IREquation,
) -> dict[str, Any]:
item: dict[str, Any] = {
"id": eq_id,
"blockid": blockid,
"heading": heading,
"parent_headings": list(parent_headings),
"format": "latex",
"content": _strip_latex_dollar_wrappers(equation.latex),
"caption": equation.caption,
"footnotes": list(equation.footnotes),
}
if equation.self_ref:
item["self_ref"] = equation.self_ref
if equation.extras:
item["extras"] = dict(equation.extras)
return item