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

384 lines
15 KiB
Python

"""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
``<cite>`` / ``<drawing>`` / ``<equation>`` 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": <one of block|drawing|table|equation>,
"id": <primary source 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}
# `<cite type="..." refid="...">visible text</cite>` → `visible text`.
_CITE_RE = re.compile(
r"<cite\b[^>]*>(.*?)</cite>",
flags=re.IGNORECASE | re.DOTALL,
)
# Inner attribute stripper used when the caller wants to *preserve* the
# `<cite type="…">…</cite>` 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 `<drawing ...>` 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"<drawing\b([^>]*)/>",
flags=re.IGNORECASE,
)
# Container `<equation id="..." format="...">latex</equation>`. 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"<equation\b([^>]*)>(.*?)</equation>",
flags=re.IGNORECASE | re.DOTALL,
)
# Container `<table id="tb-..." format="json" caption="...">rows</table>`.
# Native parser emits the internal ``tb-<doc>-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"<table\b([^>]*)>(.*?)</table>",
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"<drawing{_format_attrs([('caption', caption)])} />"
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"<equation{_format_attrs(keep)}>{body}</equation>"
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"<table{_format_attrs(keep)}>{body}</table>"
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:
- ``<drawing id="im-…" path="…" src="…" caption="Fig 1" />``
→ ``<drawing caption="Fig 1" />``
(drops the entire tag when no caption is present)
- ``<table id="tb-…" format="json" caption="…">rows</table>``
→ ``<table format="json" caption="…">rows</table>``
- ``<equation id="eq-…" format="latex">…</equation>``
→ ``<equation format="latex">…</equation>``
Cite-tag handling depends on ``keep_cite_tag``:
- ``keep_cite_tag=False`` (default — entity-extraction path):
``<cite type="…" refid="…">Table 1</cite>`` → ``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):
``<cite type="table" refid="…">Table 1</cite>`` →
``<cite type="table">Table 1</cite>``. 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",
]