"""Chunk schema helpers shared across the chunking + extraction pipeline. Three responsibilities live here so chunker implementations and the pipeline both consume identical normalization rules: - :func:`normalize_chunk_heading` collapses the legacy flat ``heading``/``parent_headings``/``level`` triple and the new nested form into the canonical ``{"level", "heading", "parent_headings"}`` dict. - :func:`normalize_chunk_sidecar` validates the new ``sidecar`` payload and ensures ``refs`` is always present as a list (single-source items may omit it before normalization; we materialize a single-element list for the storage layer). - :func:`strip_internal_multimodal_markup_for_extraction` rewrites ```` / ```` / ```` markup so the entity-extraction LLM sees a clean text body. The original ``chunk["content"]`` is never mutated; the cleaned string is only used to build the extraction prompt. The clean function is intentionally conservative: it only strips parser-emitted identifier attributes that have no business reaching the LLM (``id``, ``refid``, ``path``, ``src``). Visible captions and equation bodies are preserved so the extracted entities can still ground against them. """ from __future__ import annotations import re import unicodedata from typing import Any from lightrag.constants import DEFAULT_HEADING_LEVEL_MAX_CHARS _SIDECAR_TYPES = frozenset({"block", "drawing", "table", "equation"}) # Separator joining heading levels into a single breadcrumb line. Shared so the # extraction-side token budgeter (see ``operate._truncate_section_context``) can # split the rendered breadcrumb back into levels without a drifting magic string. HEADING_BREADCRUMB_SEP = " → " def normalize_chunk_heading(dp: dict[str, Any]) -> dict[str, Any] | None: """Return the canonical nested heading dict or ``None`` when absent. Accepts: - ``dp["heading"]`` already a dict ``{"level", "heading", "parent_headings"}``. - Legacy flat fields ``heading: str`` + ``parent_headings: list[str]`` + ``level: int``. Empty / missing inputs collapse to ``None`` so callers can simply omit the field when writing the chunk record. """ nested = dp.get("heading") if isinstance(nested, dict): heading_text = str(nested.get("heading") or "").strip() parents_raw = nested.get("parent_headings") or [] level_raw = nested.get("level", 0) else: heading_text = str(nested or "").strip() parents_raw = dp.get("parent_headings") or [] level_raw = dp.get("level", 0) parent_headings: list[str] = [] if isinstance(parents_raw, list): for entry in parents_raw: text = str(entry or "").strip() if text: parent_headings.append(text) try: level = int(level_raw or 0) except (TypeError, ValueError): level = 0 if not heading_text and not parent_headings and level == 0: return None return { "level": level, "heading": heading_text, "parent_headings": parent_headings, } _HEADING_WHITESPACE_RE = re.compile(r"\s+") # Unicode categories stripped from a heading: control (Cc) and format (Cf) # chars — NULs, zero-width marks (ZWSP/ZWNJ/ZWJ/WORD JOINER/BOM are all Cf), # directional/format codes — that only add token noise to the LLM prompt. _HEADING_STRIP_CATEGORIES = frozenset({"Cc", "Cf"}) # The only Cc chars that ``\s`` folds into a space; keep them through the strip # pass so they become a space below instead of gluing adjacent words. (Cannot # use ``str.isspace()`` for this: Python treats \x1c-\x1f as whitespace but # ``\s`` does not, so those would survive the strip — an explicit set matches # the regex.) _HEADING_KEEP_WS = frozenset("\t\n\r\f\v") def _clean_heading_text(text: str) -> str: """Flatten a heading into one clean line for the LLM. Converts the breadcrumb separator ``→`` to a space, drops every Unicode control (Cc) / format (Cf) char (zero-width marks, NULs, directional/format codes), and collapses every run of whitespace (tab, newline, NBSP, full-width / ideographic space, ...) into a single regular space as the final step. Normal CJK, Latin, digits, and punctuation are left untouched, and no spaces are inserted between adjacent CJK characters, so it is safe for Chinese headings. """ # ``→`` (U+2192, the breadcrumb separator char) must never survive inside a # single heading, or it would forge an extra level when the breadcrumb is # split back on " → " (see operate._truncate_section_context). text = text.replace("→", " ") text = "".join( ch for ch in text if ch in _HEADING_KEEP_WS or unicodedata.category(ch) not in _HEADING_STRIP_CATEGORIES ) # Collapse LAST so the spaces introduced above (→, kept whitespace controls, # and any gap left around a removed control char) all fold into one space. text = _HEADING_WHITESPACE_RE.sub(" ", text) return text.strip() def _truncate_heading_level(text: str, max_chars: int) -> str: """Hard-cap a single heading level, marking elision with an ellipsis.""" if max_chars <= 0 or len(text) <= max_chars: return text # Reserve one char for the ellipsis so the result length stays <= max_chars. return text[: max_chars - 1].rstrip() + "…" def _clean_and_cap_headings(headings: list[str], max_heading_len: int) -> list[str]: """Clean each heading, drop empties, then hard-cap each level's length. Shared by :func:`format_parent_headings` and :func:`format_heading_context` so both the query-stage and extraction-stage breadcrumbs apply identical cleaning (:func:`_clean_heading_text`) and per-level truncation, and cannot drift apart. Order matters: clean → drop empties → cap. """ return [ _truncate_heading_level(c, max_heading_len) for c in (_clean_heading_text(h) for h in headings) if c ] def format_parent_headings( dp: dict[str, Any], *, max_heading_len: int = DEFAULT_HEADING_LEVEL_MAX_CHARS, ) -> str: """Join a chunk's parent heading chain into ``h1 → h2 → h3``. Reuses :func:`normalize_chunk_heading` so both the nested and legacy flat heading shapes are handled, then cleans each heading via :func:`_clean_heading_text` so the string sent to the LLM is a single tidy line. Returns an empty string when the chunk has no (non-empty) parent headings, so callers can simply omit the field when it is empty. Each individual heading level is capped at ``max_heading_len`` characters (set ``<= 0`` to disable), matching :func:`format_heading_context`, so one runaway title cannot bloat the query context before its token truncation. """ normalized = normalize_chunk_heading(dp) if not normalized: return "" cleaned = _clean_and_cap_headings(normalized["parent_headings"], max_heading_len) return HEADING_BREADCRUMB_SEP.join(cleaned) def format_heading_context( dp: dict[str, Any], *, max_heading_len: int = DEFAULT_HEADING_LEVEL_MAX_CHARS, ) -> str: """Join a chunk's full heading chain (parents + current) into ``h1 → h2 → h3``. Like :func:`format_parent_headings` but appends the chunk's own section heading after the parent chain, so the entity-extraction LLM sees the complete breadcrumb of the section the input text belongs to. Reuses :func:`normalize_chunk_heading` (handles both nested and legacy flat shapes) and :func:`_clean_heading_text`. Returns an empty string when the chunk carries no (non-empty) heading information, so callers can simply omit the field when it is empty. Each individual heading level is capped at ``max_heading_len`` characters (set ``<= 0`` to disable) so one runaway title cannot bloat the prompt; the caller is still responsible for token-budgeting the joined breadcrumb. """ normalized = normalize_chunk_heading(dp) if not normalized: return "" chain = list(normalized["parent_headings"]) if normalized["heading"]: chain.append(normalized["heading"]) cleaned = _clean_and_cap_headings(chain, max_heading_len) return HEADING_BREADCRUMB_SEP.join(cleaned) def normalize_chunk_sidecar(dp: dict[str, Any]) -> dict[str, Any] | None: """Return the canonical sidecar dict or ``None`` when absent / invalid. Output shape:: {"type": , "id": , "refs": [{"type": ..., "id": ...}, ...]} ``refs`` is always materialized as a list with at least the primary id. Single-source chunks therefore land in storage with ``refs=[{type,id}]`` so downstream consumers don't need to special-case the field's presence. """ sidecar = dp.get("sidecar") if not isinstance(sidecar, dict): return None sidecar_type = str(sidecar.get("type") or "").strip() sidecar_id = str(sidecar.get("id") or "").strip() if sidecar_type not in _SIDECAR_TYPES or not sidecar_id: return None refs_raw = sidecar.get("refs") refs: list[dict[str, str]] = [] if isinstance(refs_raw, list): for entry in refs_raw: if not isinstance(entry, dict): continue ref_type = str(entry.get("type") or "").strip() ref_id = str(entry.get("id") or "").strip() if ref_type in _SIDECAR_TYPES and ref_id: refs.append({"type": ref_type, "id": ref_id}) if not refs: refs = [{"type": sidecar_type, "id": sidecar_id}] return {"type": sidecar_type, "id": sidecar_id, "refs": refs} # `visible text` → `visible text`. _CITE_RE = re.compile( r"]*>(.*?)", flags=re.IGNORECASE | re.DOTALL, ) # Inner attribute stripper used when the caller wants to *preserve* the # `` wrapper but drop the parser-internal `refid`. # Matches ` refid="…"` (leading whitespace + quoted value) so the # surrounding attribute layout (e.g. `type="table"`) stays intact. _CITE_REFID_ATTR_RE = re.compile( r'\s+refid\s*=\s*"[^"]*"', flags=re.IGNORECASE, ) # Self-closing `` placeholder. We keep `caption` (visible) and # drop `id`, `path`, `src`, `format`, etc. Tags without any caption are # removed entirely so they don't pollute extraction input. _DRAWING_RE = re.compile( r"]*)/>", flags=re.IGNORECASE, ) # Container `latex`. Strip # identifier attributes; preserve the body and the `format` attribute so # extraction still sees the equation is a structured element. _EQUATION_RE = re.compile( r"]*)>(.*?)", flags=re.IGNORECASE | re.DOTALL, ) # Container `rows
`. # Native parser emits the internal ``tb--NNNN`` identifier here, which # would otherwise leak into the entity-extraction prompt and become a noisy # entity. Strip ``id``; keep ``format`` / ``caption`` (and the body verbatim) # so the extractor still recognizes the element as a structured table. _TABLE_RE = re.compile( r"]*)>(.*?)", flags=re.IGNORECASE | re.DOTALL, ) # Match attribute pairs like ``caption="text with \"escapes\""``. We treat # only the safe identifier-style attributes; complex quoting is rare in # parser output. _ATTR_RE = re.compile( r'(\w+)\s*=\s*"((?:[^"\\]|\\.)*)"', ) def _attrs_to_dict(attr_string: str) -> dict[str, str]: return { match.group(1).lower(): match.group(2) for match in _ATTR_RE.finditer(attr_string) } def _format_attrs(pairs: list[tuple[str, str]]) -> str: return "".join(f' {k}="{v}"' for k, v in pairs if v) def _replace_drawing(match: re.Match[str]) -> str: attrs = _attrs_to_dict(match.group(1)) caption = attrs.get("caption", "") if not caption.strip(): return "" return f"" def _replace_equation(match: re.Match[str]) -> str: attrs = _attrs_to_dict(match.group(1)) body = match.group(2) keep: list[tuple[str, str]] = [] fmt = attrs.get("format", "") if fmt: keep.append(("format", fmt)) caption = attrs.get("caption", "") if caption.strip(): keep.append(("caption", caption)) return f"{body}" def _replace_table(match: re.Match[str]) -> str: attrs = _attrs_to_dict(match.group(1)) body = match.group(2) keep: list[tuple[str, str]] = [] fmt = attrs.get("format", "") if fmt: keep.append(("format", fmt)) caption = attrs.get("caption", "") if caption.strip(): keep.append(("caption", caption)) return f"{body}" def strip_internal_multimodal_markup_for_extraction( content: str, *, keep_cite_tag: bool = False ) -> str: """Strip parser-internal identifiers from a chunk content string. Only the entity-extraction prompt should receive the cleaned form; callers must NOT mutate the stored chunk ``content`` so query-time citations still resolve back to the original parser output. Transformations always applied: - ```` → ```` (drops the entire tag when no caption is present) - ``rows
`` → ``rows
`` - ```` → ```` Cite-tag handling depends on ``keep_cite_tag``: - ``keep_cite_tag=False`` (default — entity-extraction path): ``Table 1`` → ``Table 1``. The cite wrapper is dropped so the extractor does not surface it as a noisy structural entity. - ``keep_cite_tag=True`` (multimodal-analysis surrounding path): ``Table 1`` → ``Table 1``. Only the internal ``refid`` is removed; the wrapper survives so the VLM/LLM can tell visible reference labels (e.g. "Table 1") apart from inline prose. """ if not content: return content if keep_cite_tag: cleaned = _CITE_REFID_ATTR_RE.sub("", content) else: cleaned = _CITE_RE.sub(lambda m: m.group(1), content) cleaned = _DRAWING_RE.sub(_replace_drawing, cleaned) cleaned = _TABLE_RE.sub(_replace_table, cleaned) cleaned = _EQUATION_RE.sub(_replace_equation, cleaned) return cleaned __all__ = [ "normalize_chunk_heading", "format_parent_headings", "format_heading_context", "HEADING_BREADCRUMB_SEP", "normalize_chunk_sidecar", "strip_internal_multimodal_markup_for_extraction", ]