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

895 lines
35 KiB
Python

"""Pipeline-specific helpers for document status, identity, and content.
These helpers are shared by the LightRAG pipeline mixin (lightrag/pipeline.py)
and by other LightRAG methods that touch the document ingestion paths
(custom-chunks ingest, deletion, etc.). They are kept out of utils.py because
they are tied to the doc_status / full_docs domain rather than to general
text/token utilities.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import time
from pathlib import Path
from typing import Any, cast
from urllib.parse import quote, unquote, urlsplit
from lightrag.base import DocProcessingStatus, DocStatus, DocStatusStorage
from lightrag.constants import (
FILE_EXTRACTION_SUMMARY_PREFIX,
FULL_DOCS_FORMAT_LIGHTRAG,
LIGHTRAG_DOC_CONTENT_PREFIX,
PARSED_DIR_NAME,
PARSER_ENGINE_LEGACY,
PARSER_ENGINE_NATIVE,
)
from lightrag.parser.routing import canonicalize_parser_hinted_basename
from lightrag.utils import (
compute_mdhash_id,
get_content_summary,
logger,
move_file_to_parsed_dir,
)
PLACEHOLDER_DOCUMENT_SOURCES = {"", "no-file-path", "unknown_source"}
SIDECAR_LOCATION_UNKNOWN = "unknown_source"
def build_chunks_dict_from_chunking_result(
chunking_result: list[dict[str, Any]],
*,
doc_id: str,
file_path: str,
) -> dict[str, dict[str, Any]]:
"""Assemble the per-doc chunks dict written into chunks_vdb / text_chunks.
Resolves a stable ``chunk_key`` for each entry — preferring an explicit
``chunk_id`` (auto-prefixed with ``doc_id-`` if not already), falling back
to a positional ``chunk-NNN`` derived from ``chunk_order_index``, and
finally hashing on collision so two entries inside one document never
overwrite each other.
"""
chunks: dict[str, dict[str, Any]] = {}
for dp in chunking_result:
chunk_content = dp.get("content", "")
if not chunk_content:
continue
raw_chunk_id = dp.get("chunk_id", "")
order = dp.get("chunk_order_index")
if isinstance(raw_chunk_id, str) and raw_chunk_id.strip():
chunk_key = (
raw_chunk_id
if raw_chunk_id.startswith(f"{doc_id}-")
else f"{doc_id}-{raw_chunk_id}"
)
elif isinstance(order, int):
chunk_key = f"{doc_id}-chunk-{order:03d}"
else:
chunk_key = compute_mdhash_id(f"{doc_id}:{chunk_content}", prefix="chunk-")
# Hard collision guard (same chunk_id inside one document).
if chunk_key in chunks:
chunk_key = compute_mdhash_id(
f"{doc_id}:{order}:{chunk_content}",
prefix="chunk-",
)
# Preserve any pre-populated cache ids on dp (multimodal chunks
# arrive with analysis cache ids already attached so document
# deletion can find them via the per-chunk llm_cache_list).
existing_cache_list = dp.get("llm_cache_list")
seed_cache_list: list[str] = []
if isinstance(existing_cache_list, list):
seen: set[str] = set()
for entry in existing_cache_list:
key = str(entry or "").strip()
if key and key not in seen:
seen.add(key)
seed_cache_list.append(key)
stored_chunk = {k: v for k, v in dp.items() if k != "_source_span"}
chunks[chunk_key] = {
**stored_chunk,
"full_doc_id": doc_id,
"file_path": file_path,
"llm_cache_list": seed_cache_list,
}
return chunks
def chunk_fields_from_status_doc(
status_doc: DocProcessingStatus,
) -> tuple[list[str], int]:
"""Return (chunks_list, chunks_count) preserved from a status document.
Filters out any non-string or empty chunk IDs. When chunks_count is
absent or invalid, it is inferred from the length of chunks_list.
"""
chunks_list: list[str] = []
if isinstance(status_doc.chunks_list, list):
chunks_list = [
chunk_id
for chunk_id in status_doc.chunks_list
if isinstance(chunk_id, str) and chunk_id
]
if isinstance(status_doc.chunks_count, int) and status_doc.chunks_count >= 0:
return chunks_list, status_doc.chunks_count
return chunks_list, len(chunks_list)
def resolve_doc_file_path(
status_doc: DocProcessingStatus | None = None,
content_data: dict[str, Any] | None = None,
) -> str:
"""Resolve the best available document file path.
Returns the first non-placeholder ``file_path`` from doc_status, then
full_docs. Both are already canonicalized at write time, so this only
has to skip placeholder sentinels.
"""
for source in (
getattr(status_doc, "file_path", None),
content_data.get("file_path") if content_data else None,
):
if not isinstance(source, str):
continue
candidate = source.strip()
if candidate and candidate not in PLACEHOLDER_DOCUMENT_SOURCES:
return candidate
return "unknown_source"
def normalize_document_file_path(file_path: Any) -> str:
"""Return the canonical basename stored as ``file_path``.
Strips any supported ``[hint]`` segment so ``abc.docx`` and
``abc.[native-iet].docx`` map to the same key. Collapses placeholders to
``"unknown_source"``. Idempotent.
"""
source = str(file_path or "").strip()
if source in PLACEHOLDER_DOCUMENT_SOURCES:
return "unknown_source"
canonical = canonicalize_parser_hinted_basename(source).strip()
if canonical in PLACEHOLDER_DOCUMENT_SOURCES:
return "unknown_source"
return canonical or "unknown_source"
# Back-compat alias retained until call sites that import the old name are
# all switched over (the public surface is ``normalize_document_file_path``).
document_canonical_key = normalize_document_file_path
def has_known_document_source(source_key: str) -> bool:
return source_key not in PLACEHOLDER_DOCUMENT_SOURCES
def doc_status_field(doc: Any, field: str, default: Any = "") -> Any:
if isinstance(doc, dict):
return doc.get(field, default)
return getattr(doc, field, default)
def read_source_file_basename(data: Any) -> str | None:
"""Read the source-file basename with backward compatibility.
The ``source_file_name`` → ``source_file`` rename means documents enqueued
before the change persisted the old key in full_docs content_data /
doc_status.metadata. Read through here so resumed/legacy docs still resolve
their original source basename; write sites always use the new
``source_file`` key. Lives here (not in pipeline.py) so utils_pipeline
helpers can reuse it without importing back into the pipeline module.
"""
if not isinstance(data, dict):
return None
return data.get("source_file") or data.get("source_file_name")
# Long-lived per-document metadata fields that must survive every
# doc_status state transition. ``process_options`` records the user's
# per-file processing strategy at enqueue time and is read by analyze /
# chunk / KG-skip stages and by admin/list APIs throughout the document's
# lifetime, so we cannot let an intermediate transition (PARSING /
# ANALYZING / PROCESSING / PROCESSED / FAILED upsert) clobber it.
# ``parse_warnings`` records non-fatal parser warnings (e.g. legacy docx
# tables missing ``w14:paraId``) that admins should be able to surface
# alongside the document record after PROCESSED.
# ``chunk_opts`` is written when entering PROCESSING (via ``extraction_meta``)
# and records the actual chunker params used for that document in the same
# format as the ``Chunking <strategy>: ...`` log line (params portion only).
# Carrying it forward keeps the value visible after PROCESSING -> FAILED,
# whose ``metadata_extra`` only carries timing fields.
# ``parse_start_time`` / ``analyzing_start_time`` are Unix epoch seconds
# stamped at the entry of ``_parse_worker`` / ``_analyze_worker`` (mirrors
# the existing ``process_start_time`` set when entering PROCESSING) so
# per-stage durations can be derived from doc_status post-mortem.
# ``parse_end_time`` is the paired Unix epoch seconds stamped by
# ``_parse_worker`` when the parse stage actually runs (cache-miss branch,
# covering ``parse_native`` too which has no cache concept). Absent on
# cache-hit attempts (``parse_stage_skipped`` is set instead).
# ``analyzing_end_time`` is the paired Unix epoch seconds stamped by
# ``_analyze_worker`` only when ``analyze_multimodal`` returns with
# ``multimodal_processed=True`` (the explicit "fully completed" sentinel).
# It is intentionally NOT stamped on soft-swallowed exception paths or on
# malformed/empty sidecar early returns inside ``analyze_multimodal``, so
# operators can distinguish "analyze actually completed" from "analyze
# attempted but bailed".
# ``parse_stage_skipped`` is written by ``parse_mineru`` / ``parse_docling``
# when the raw bundle cache is valid and the parse stage round trip is
# skipped; absence == not skipped (e.g. native parser, or cache miss).
# ``analyzing_stage_skipped`` is its analyze-stage counterpart, written by
# ``analyze_multimodal``'s three user/config early-return branches (no
# blocks_path, blocks file missing, or user opted out of every i/t/e
# modality). Soft-swallowed exception paths are intentionally NOT considered
# "skipped" — they write neither end_time nor skipped (failure is its own
# state, captured via the FAILED transition's ``error_msg``).
# Within each stage, the ``*_end_time`` and ``*_stage_skipped`` fields are
# mutually exclusive (at most one is written per attempt; both may be
# absent if analyze soft-failed).
# ``source_file`` records the original pending-parse source basename used
# by parser workers; it is intentionally separate from canonical ``file_path``.
#
# The order of this tuple is the rendering order of metadata fields in
# the WebUI ``DocumentStatusDetailsDialog`` (carry-over builds the new
# metadata dict by iterating this tuple, and dict / JSON / JSX preserve
# insertion order all the way to the rendered output). Keep fields
# grouped by stage: parse-stage fields together, analyze-stage fields
# together, etc., so the dialog reads top-to-bottom along the pipeline.
def resolve_doc_status_parse_engine(
parse_format: str | None,
explicit_engine: str | None,
) -> str:
"""Resolve the ``parse_engine`` value recorded in doc_status metadata.
Single source of truth shared by the parse stage (``_parse_worker``) and
the process stage (``process_single_document``) so both stamp the *same*
value — preventing a value "jump" when the field is written early at
PARSING and re-written later at PROCESSING. ``explicit_engine`` wins when
a parser reported the engine it actually ran (or full_docs carries an
enqueue-time directive); otherwise fall back to ``native`` for the
structured ``lightrag`` format and ``legacy`` for everything else
(``raw`` passthrough), mirroring how a pre-engine corpus was processed.
"""
if explicit_engine:
return explicit_engine
return (
PARSER_ENGINE_NATIVE
if parse_format == FULL_DOCS_FORMAT_LIGHTRAG
else PARSER_ENGINE_LEGACY
)
_DOC_STATUS_METADATA_CARRY_OVER_KEYS: tuple[str, ...] = (
"process_options",
"source_file",
"parse_warnings",
"chunk_opts",
"parse_start_time",
"parse_end_time",
"parse_stage_skipped",
"parse_format",
"parse_engine",
"analyzing_start_time",
"analyzing_end_time",
"analyzing_stage_skipped",
)
def doc_status_metadata_carry_over(status_doc: Any) -> dict[str, Any]:
"""Return the subset of ``status_doc.metadata`` to preserve across upserts.
``doc_status`` storage backends generally treat the ``metadata`` field
as an opaque blob and **replace** it on every upsert, so callers must
explicitly carry forward fields they want to keep. This helper centralises
the list of fields we always carry: today only ``process_options``, but
new long-lived metadata can be added by extending
``_DOC_STATUS_METADATA_CARRY_OVER_KEYS``.
"""
if status_doc is None:
return {}
raw_metadata = doc_status_field(status_doc, "metadata", {})
if not isinstance(raw_metadata, dict):
return {}
carry: dict[str, Any] = {}
for key in _DOC_STATUS_METADATA_CARRY_OVER_KEYS:
if key in raw_metadata and raw_metadata[key] not in (None, ""):
carry[key] = raw_metadata[key]
return carry
def doc_status_transition_metadata(
status_doc: Any,
*,
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build a doc_status ``metadata`` payload that preserves carry-over fields.
Use at every state-transition upsert site so the user's
``process_options`` (and any future long-lived metadata fields) survive
PENDING → PARSING → ANALYZING → PROCESSING → PROCESSED / FAILED.
"""
payload = doc_status_metadata_carry_over(status_doc)
if extra:
payload.update(extra)
return payload
# Long-lived per-document *directives* that record how the document should be
# processed (written at enqueue time by ``_initial_doc_status``). Unlike the
# full carry-over list above, this is the subset that must survive a *reset*
# back to PENDING: it deliberately EXCLUDES the per-attempt timing / result
# fields (``parse_*`` / ``analyzing_*`` / ``parse_warnings`` / ``chunk_opts``),
# which the next attempt regenerates and which would otherwise show stale
# values while the document waits in PENDING.
_DOC_STATUS_METADATA_DIRECTIVE_KEYS: tuple[str, ...] = (
"process_options",
"source_file",
)
# Per-attempt metadata produced by a parse/analyze/process attempt. Used as
# the *precise* trigger for normalising an already-PENDING document's metadata
# (see ``apipeline_process_enqueue_documents``' consistency check): only a
# document carrying one of these stale keys is rebuilt to directives-only, so
# unrelated (future / custom) metadata is never dropped just for being
# non-directive. Covers the timing/skip pairs, parser warnings, and the
# ``extraction_meta`` fields stamped when entering PROCESSING.
_DOC_STATUS_METADATA_ATTEMPT_KEYS: frozenset[str] = frozenset(
{
"parse_start_time",
"parse_end_time",
"parse_stage_skipped",
"analyzing_start_time",
"analyzing_end_time",
"analyzing_stage_skipped",
"parse_warnings",
"chunk_opts",
"process_start_time",
"process_end_time",
"parse_format",
"parse_engine",
"chunk_method",
"skip_kg",
"mm_chunks",
"hard_fallback_split",
"error_type",
"error_stage",
}
)
def doc_status_reset_metadata(status_doc: Any) -> dict[str, Any]:
"""Build the ``metadata`` payload for a reset back to PENDING.
Keeps only the long-lived processing directives
(``_DOC_STATUS_METADATA_DIRECTIVE_KEYS``) and drops every per-attempt
timing/result field, so a document that is interrupted and re-queued does
not surface stale parse/analyze timings (or warnings / chunk opts) while it
waits in PENDING. ``source_file`` is read with legacy ``source_file_name``
tolerance and normalised onto the new key, mirroring the parse worker's own
normalisation so a resumed legacy pending_parse doc keeps its source hint.
"""
payload: dict[str, Any] = {}
raw_metadata = doc_status_field(status_doc, "metadata", {})
if not isinstance(raw_metadata, dict):
return payload
# Iterate the directive whitelist so a future addition to
# _DOC_STATUS_METADATA_DIRECTIVE_KEYS is automatically carried across a
# reset. ``source_file`` is read with legacy ``source_file_name``
# tolerance and normalised onto the new key; all other directives carry
# verbatim when present and non-blank.
for key in _DOC_STATUS_METADATA_DIRECTIVE_KEYS:
if key == "source_file":
value = read_source_file_basename(raw_metadata)
else:
value = raw_metadata.get(key)
if value not in (None, ""):
payload[key] = value
return payload
def doc_status_parse_failure_fields(
error: BaseException,
*,
status_doc: Any,
engine_hint: str | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Build the FAILED-upsert extras for a parse-stage failure.
Returns ``(extra_fields, metadata_extra)`` for
``_upsert_doc_status_transition``. Restores the UI fields that the
pre-deferral enqueue-time error documents carried: a human-readable
``content_summary`` (written only when the document has none —
``pending_parse`` docs enqueue with an empty summary, while raw
passthrough docs keep their real one — or when the existing summary
is itself a ``[File Extraction]``-generated one from a previous
failed attempt, which the retry reset preserves and would otherwise
go stale next to the fresh ``error_msg``) plus ``metadata.error_type`` /
``metadata.error_stage`` for error classification. ``error_type``
keeps the legacy ``file_extraction_error`` value consumers may match
on; ``error_stage`` distinguishes the parse-worker failure from an
enqueue-time error document. Both metadata keys ride
``metadata_extra`` and are deliberately NOT in the carry-over /
directive whitelists, so the next transition (retry reset, PARSING)
drops them automatically — mirroring how ``error_msg`` is cleared.
``engine_hint`` (the parse worker's resolved engine key, falling back
to its queue-group id) is stamped as ``parse_engine`` only when the
failure happened before the post-parse stamp put one on
``status_doc.metadata``; an existing value always wins via carry-over.
"""
error_text = str(error)
if not error_text.strip():
# Some exceptions (e.g. certain httpx transport errors) stringify to
# an empty message; fall back to the class name so the WebUI never
# drops the Error Message row for a FAILED document.
error_text = type(error).__name__
extra_fields: dict[str, Any] = {"error_msg": error_text}
current_summary = str(
doc_status_field(status_doc, "content_summary", "") or ""
).strip()
if not current_summary or current_summary.startswith(
FILE_EXTRACTION_SUMMARY_PREFIX
):
extra_fields["content_summary"] = (
FILE_EXTRACTION_SUMMARY_PREFIX + get_content_summary(error_text)
)
metadata_extra: dict[str, Any] = {
"error_type": "file_extraction_error",
"error_stage": "parse",
}
raw_metadata = doc_status_field(status_doc, "metadata", {})
has_engine = isinstance(raw_metadata, dict) and raw_metadata.get("parse_engine")
if engine_hint and not has_engine:
metadata_extra["parse_engine"] = engine_hint
return extra_fields, metadata_extra
def doc_status_metadata_has_attempt_fields(status_doc: Any) -> bool:
"""True when ``status_doc.metadata`` carries any per-attempt field.
Used to decide whether an already-PENDING document needs its metadata
normalised to directives-only — avoids a redundant upsert for documents
whose metadata is already clean (or only holds non-attempt custom fields).
"""
raw_metadata = doc_status_field(status_doc, "metadata", {})
if not isinstance(raw_metadata, dict):
return False
return not _DOC_STATUS_METADATA_ATTEMPT_KEYS.isdisjoint(raw_metadata)
def doc_status_value(doc: Any) -> str:
status = doc_status_field(doc, "status", "")
if isinstance(status, DocStatus):
return status.value
return str(status or "")
# Sidecar item ids embed ``doc_hash`` (= doc_id without the ``doc-`` prefix),
# and for pending_parse uploads doc_id derives from the filename — so the
# same content under two filenames renders with different ids in
# ``merged_text``. Strip those surfaces before hashing so cross-filename
# content_hash dedup actually fires.
_SIDECAR_ID_PATTERN = re.compile(r"\b(tb|im|eq)-[0-9a-f]{32}-(\d{4})\b")
_ASSET_PATH_PATTERN = re.compile(r'(?<=path=")[^"]*\.blocks\.assets/')
def normalize_merged_text_for_hash(content: str) -> str:
"""Strip filename-derived prefixes from sidecar ids and asset paths.
Idempotent and safe on plain text (matches the doc_hash literal only —
32 lowercase hex digits between the modality prefix and a 4-digit
sequence). RAW text bodies without sidecar markup pass through
unchanged.
"""
if not content:
return content
content = _SIDECAR_ID_PATTERN.sub(r"\1-<DOC>-\2", content)
content = _ASSET_PATH_PATTERN.sub("<ASSETS>/", content)
return content
def compute_text_content_hash(content: str) -> str:
"""MD5 hex digest of text content used for cross-filename dedup.
Input is normalized via :func:`normalize_merged_text_for_hash` first so
sidecar-rendered bodies dedupe across filenames despite carrying
filename-derived item ids and asset paths.
"""
return compute_mdhash_id(normalize_merged_text_for_hash(content), prefix="")
def compute_file_content_hash(path_str: str) -> str | None:
"""Stream-compute MD5 of a file's bytes; returns None if unreadable.
Resolves the LightRAG ``*.blocks.jsonl`` conventions used by
``_load_lightrag_document_content`` so the hash matches the actual
document body regardless of whether ``path_str`` points at the blocks
file directly or its parent directory/base name.
"""
if not path_str:
return None
try:
path = Path(path_str)
if path.is_dir():
candidates = sorted(path.glob("*.blocks.jsonl"))
if not candidates:
return None
path = candidates[0]
elif not (path.exists() and path.is_file()):
blocks_path = Path(path_str + ".blocks.jsonl")
if blocks_path.exists() and blocks_path.is_file():
path = blocks_path
else:
return None
h = hashlib.md5()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
except Exception as e:
logger.warning(f"Failed to compute file content hash for {path_str}: {e}")
return None
def configured_input_dir() -> Path:
input_dir = os.getenv("INPUT_DIR", "").strip()
return Path(input_dir) if input_dir else Path.cwd() / "inputs"
async def get_existing_doc_by_file_basename(
doc_status: DocStatusStorage, file_path: Any
) -> tuple[str, Any] | None:
"""Find an existing doc_status record by canonical file basename.
Inputs are normalized via :func:`normalize_document_file_path` so callers
may pass either the bare canonical name (``abc.docx``) or a hint-bearing
variant (``abc.[native-iet].docx``); both resolve to the same logical
document.
"""
basename = normalize_document_file_path(file_path)
if basename == "unknown_source":
return None
return await doc_status.get_doc_by_file_basename(basename)
async def get_existing_doc_by_content_hash(
doc_status: DocStatusStorage, content_hash: str
) -> tuple[str, Any] | None:
"""Find an existing doc_status record by content hash."""
if not content_hash:
return None
return await doc_status.get_doc_by_content_hash(content_hash)
async def get_duplicate_doc_by_content_hash(
doc_status: DocStatusStorage, content_hash: str, current_doc_id: str
) -> tuple[str, Any] | None:
"""Find another doc_status record with the same content hash."""
if not content_hash:
return None
match = await doc_status.get_doc_by_content_hash(content_hash)
if match and match[0] != current_doc_id:
return match
try:
docs = await doc_status.get_docs_by_statuses(list(DocStatus))
except Exception:
return None
for doc_id, doc in docs.items():
if doc_id == current_doc_id:
continue
if doc_status_field(doc, "content_hash", "") == content_hash:
return doc_id, doc
return None
def make_lightrag_doc_content(merged_text: str) -> str:
"""Build the ``full_docs.content`` value for ``format=lightrag`` records.
The result has shape ``"{{LRdoc}}<merged_text>"`` — the marker prefix
distinguishes lightrag-format full_docs from raw-format ones, and the
body is the complete merged text from the ``.blocks.jsonl`` content
lines so F-chunking can run identically on raw and lightrag inputs
(the prefix is stripped at chunking time via
``strip_lightrag_doc_prefix``).
"""
return f"{LIGHTRAG_DOC_CONTENT_PREFIX}{merged_text or ''}"
def strip_lightrag_doc_prefix(content: str | None, parse_format: str | None) -> str:
"""Return the bare body for a stored ``full_docs.content`` value.
The ``{{LRdoc}}`` marker is stripped **only** when ``parse_format``
indicates the record is in lightrag format. Any other ``parse_format``
(``raw``, ``pending_parse``, ``None`` ...) returns the content
unchanged so a raw document whose literal body happens to start with
``{{LRdoc}}`` is never silently truncated.
Centralizing the format check here turns "must check format before
stripping" from a caller-side discipline into a structural property of
the function: any future call site that forgets to gate is protected
automatically.
"""
if (
parse_format == FULL_DOCS_FORMAT_LIGHTRAG
and isinstance(content, str)
and content.startswith(LIGHTRAG_DOC_CONTENT_PREFIX)
):
return content[len(LIGHTRAG_DOC_CONTENT_PREFIX) :]
return content or ""
# ---------------------------------------------------------------------------
# Document path / artifact helpers (moved from _PipelineMixin)
# ---------------------------------------------------------------------------
def input_dir_path() -> Path:
return configured_input_dir()
def parsed_dir() -> Path:
"""Return the project-wide parsed-artifact root: ``<input_dir>/__parsed__``."""
return input_dir_path() / PARSED_DIR_NAME
def parsed_artifact_dir_for(
file_path: str, *, parent_hint: Path | str | None = None
) -> Path:
"""Return the per-document sidecar directory for ``file_path``.
``file_path`` must already be canonical (run ``normalize_document_file_path``
first if unsure). When ``parent_hint`` is supplied (e.g. the live source
file's parent), the sidecar is placed next to it under ``__parsed__/``
rather than under the global ``input_dir``; this keeps test isolation
intact when the source lives outside ``INPUT_DIR``. On collision with an
existing non-directory entry, the helper appends ``_001``..``_999`` and
finally a unix timestamp suffix.
"""
if parent_hint is not None:
hint = Path(parent_hint)
# ``hint`` may already point at a ``__parsed__/`` dir (e.g. when the
# caller re-archived a source); reuse it in place rather than nesting.
root = hint if hint.name == PARSED_DIR_NAME else hint / PARSED_DIR_NAME
else:
root = parsed_dir()
source_name = (
canonicalize_parser_hinted_basename(file_path or "document") or "document"
)
artifact_name = f"{source_name}.parsed"
artifact_dir = root / artifact_name
if not artifact_dir.exists() or artifact_dir.is_dir():
return artifact_dir
for i in range(1, 1000):
candidate = root / f"{artifact_name}_{i:03d}"
if not candidate.exists() or candidate.is_dir():
return candidate
return root / f"{artifact_name}_{int(time.time())}"
# ---------------------------------------------------------------------------
# Sidecar URI helpers (``full_docs.sidecar_location``)
# ---------------------------------------------------------------------------
#
# Sidecar URI scheme conventions:
# - Local: ``file:///abs/path/to/abc.parsed/`` (trailing slash required)
# - Remote: ``s3://bucket/workspace/abc.parsed/`` (future; resolver returns
# None today so local readers gracefully skip)
# - Unknown sentinel: literal string ``"unknown_source"``
def sidecar_uri_for(parsed_artifact_dir: Path | str) -> str:
"""Build the canonical sidecar URI for a local artifact directory.
The result always ends with ``/`` so a reader can distinguish a directory
from a file at the URI level. Non-ASCII characters are percent-encoded.
"""
p = Path(parsed_artifact_dir).resolve()
encoded = quote(str(p), safe="/")
return f"file://{encoded}/"
def resolve_sidecar_uri(uri: str | None) -> Path | None:
"""Decode a sidecar URI into a local filesystem Path.
Returns None for the unknown sentinel, empty input, or any non-``file://``
scheme (remote schemes will get their own resolvers).
"""
if not uri or uri == SIDECAR_LOCATION_UNKNOWN:
return None
parts = urlsplit(uri)
if parts.scheme != "file":
return None
path_str = unquote(parts.path)
if path_str.endswith("/") and len(path_str) > 1:
path_str = path_str[:-1]
return Path(path_str)
def sidecar_blocks_path(uri: str | None) -> str | None:
"""Locate the first ``*.blocks.jsonl`` file inside a sidecar URI.
Returns the absolute path as a string, or None when the URI cannot be
resolved locally or the directory holds no blocks file.
"""
d = resolve_sidecar_uri(uri)
if d is None or not d.is_dir():
return None
candidates = sorted(d.glob("*.blocks.jsonl"))
return str(candidates[0]) if candidates else None
def sidecar_modality_path(uri: str | None, modality: str) -> str | None:
"""Return the path for a sidecar modality JSON (drawings/tables/equations).
Does not require the file to exist — callers check. Returns None when the
sidecar URI cannot be resolved or has no blocks file to anchor the name.
"""
blocks = sidecar_blocks_path(uri)
if not blocks:
return None
return f"{blocks[: -len('.blocks.jsonl')]}.{modality}.json"
def sidecar_assets_dir_for_uri(uri: str | None) -> Path | None:
"""Return the ``*.blocks.assets/`` directory Path for a sidecar URI.
The directory may not exist; callers create it on first asset write.
"""
blocks = sidecar_blocks_path(uri)
if not blocks:
return None
return Path(f"{blocks[: -len('.blocks.jsonl')]}.blocks.assets")
# ---------------------------------------------------------------------------
# Source archive helpers
# ---------------------------------------------------------------------------
async def archive_docx_source_after_full_docs_sync(source_path: str) -> str | None:
source = Path(source_path)
try:
target = await move_file_to_parsed_dir(source, skip_if_already_parsed=True)
except Exception as e:
logger.warning(
f"[parse] Source archive skipped after full_docs sync: {source_path}: {e}"
)
return None
if target is None:
return None
if target != source:
logger.debug(
f"[parse] Archived DOCX source after full_docs sync: {source} -> {target}"
)
return str(target)
async def archive_source_after_full_docs_sync(source_path: str) -> str | None:
return await archive_docx_source_after_full_docs_sync(source_path)
# ---------------------------------------------------------------------------
# LightRAG Document blocks loader
# ---------------------------------------------------------------------------
async def load_lightrag_document_content(sidecar_uri: str) -> tuple[str, str]:
"""Load LightRAG Document blocks and return ``(merged_text, blocks_path)``.
``sidecar_uri`` is a sidecar location URI (see ``sidecar_uri_for``); this
locates the ``*.blocks.jsonl`` file inside it, reads the content lines
(skipping the meta header at index 0 and any non-content entries), and
returns the merged body plus the absolute blocks path.
"""
resolved = sidecar_blocks_path(sidecar_uri)
if resolved is None:
raise FileNotFoundError(
f"LightRAG blocks file not found from sidecar uri: {sidecar_uri}"
)
blocks_path = Path(resolved)
merged_parts: list[str] = []
with blocks_path.open("r", encoding="utf-8") as f:
for i, line in enumerate(f):
text = line.strip()
if not text:
continue
obj = json.loads(text)
if i == 0:
continue
if obj.get("type") != "content":
continue
content = obj.get("content", "")
if isinstance(content, str) and content.strip():
merged_parts.append(content)
return "\n\n".join(merged_parts), str(blocks_path)
# ---------------------------------------------------------------------------
# Payload introspection helpers (parser response normalization)
# ---------------------------------------------------------------------------
def get_by_path(payload: Any, path: str) -> Any:
if not path:
return None
cur = payload
for part in path.split("."):
if isinstance(cur, dict) and part in cur:
cur = cur[part]
else:
return None
return cur
def extract_content_list_from_payload(
payload: Any,
) -> list[dict[str, Any]] | None:
"""Try to find a MinerU/Docling-like content list from arbitrary JSON payload."""
if isinstance(payload, list):
if payload and all(isinstance(x, dict) for x in payload):
first = payload[0]
if "type" in first or "label" in first or "text" in first:
return cast(list[dict[str, Any]], payload)
return None
if not isinstance(payload, dict):
return None
# Common direct keys first
for key in ("content_list", "content", "items", "result"):
value = payload.get(key)
if isinstance(value, list):
extracted = extract_content_list_from_payload(value)
if extracted is not None:
return extracted
elif isinstance(value, dict):
extracted = extract_content_list_from_payload(value)
if extracted is not None:
return extracted
# Deep search as fallback
for value in payload.values():
extracted = extract_content_list_from_payload(value)
if extracted is not None:
return extracted
return None
def normalize_parser_result_to_content_list(
parser_result: str | list[dict[str, Any]] | dict[str, Any] | None,
) -> list[dict[str, Any]] | None:
"""Normalize parser result to structured content list if possible."""
if parser_result is None:
return None
if isinstance(parser_result, list):
return extract_content_list_from_payload(parser_result)
if isinstance(parser_result, dict):
return extract_content_list_from_payload(parser_result)
text = str(parser_result).strip()
if not text:
return None
try:
payload = json.loads(text)
return extract_content_list_from_payload(payload)
except Exception:
return None
# Multimodal entity injection used to live here as a centralized post-pass
# over all chunk_results. It has been moved into
# :func:`lightrag.operate.extract_entities._process_single_content` so each
# multimodal chunk injects its own entity/relation records while still under
# its concurrency slot. The chunk's ``sidecar.type`` (drawing/table/equation)
# is the dispatch key; see operate.py for the new logic.