File size: 11,285 Bytes
b68816f f07443e b68816f f07443e b68816f f07443e b68816f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 | """Seam adapter: parsed-document artifact β the pipeline's internal `Chunk`.
**This is the only file that knows the seam's shape.** Every stage downstream
depends on `models.Chunk` alone, so when the artifact contract settles with
Sofhia the change lands here and nowhere else.
The seam is still under discussion (KNOWLEDGE_PIPELINE_TODO.md Β§3), so this
reads defensively: it accepts either the draft's field names or the prototype's,
takes plain dicts, and tolerates missing optional structure. It deliberately
does **not** accept a file path β extraction never opens a document. That
constraint is the point of the split, not an implementation detail.
Two things it must never do:
- reflow, strip or whitespace-normalise `text`. Span validation locates quoted
spans literally inside it; cleaning the text makes the lookup fail and the
field go silently null, which presents as a bad model.
- infer a page number it was not given. A wrong page sends the reviewer to the
wrong part of the document, which is worse than no page at all.
"""
from __future__ import annotations
import hashlib
import json
from typing import Any
from src.middlewares.logging import get_logger
from .models import Chunk, ChunkAsset, ParsedDoc
logger = get_logger("knowledge_adapter")
# Field names accepted for the same concept. `page_idx`/`page_idxs` are the
# parsing half's contract; the rest are earlier drafts and the prototype's shape,
# kept so old fixtures still load.
#
# Page numbers are 0-BASED throughout, exactly as the parser reports them. No
# conversion happens anywhere in this pipeline: converting to 1-based is the
# review UI's job, done once at display time. An off-by-one here would be
# invisible until an expert opened the wrong page.
_PAGE_KEYS = ("page_idx", "page_start", "page")
_PAGES_KEYS = ("page_idxs", "pages", "page_list")
_PAGE_END_KEYS = ("page_idx_end", "page_end")
# ββ Seam version gate (N6b) ββββββββββββββββββββββββββββββββββββββββββββββ
#
# The artifact's SHAPE version, as `major.minor`. Parsing owns the contract and
# bumps `SCHEMA_VERSION` in `knowledge_parsing/contracts.py`; this is the range
# extraction knows how to read.
#
# Deliberately a literal rather than an import: extraction must never depend on
# the parsing package at runtime β that independence is the whole point of the
# seam. Drift is caught instead by a local seam test that imports
# `knowledge_parsing.contracts` directly and asserts the two agree, so a bump on
# either side fails loudly in tests rather than silently at read time.
SUPPORTED_SCHEMA = (0, 4)
class IncompatibleArtifactError(ValueError):
"""The artifact declares a shape this code cannot read.
Raised rather than degraded on purpose. Every other seam in this repo
tolerates missing structure, because a chunk without a heading is still
usable β but a shape we do not understand is not a smaller version of the
right input, it is a different input. Reading it anyway produces entries
that look fine and are wrong, which is the single most expensive failure
this pipeline can have.
"""
def _parse_version(declared: str) -> tuple[int, int] | None:
parts = str(declared).split(".")
try:
return int(parts[0]), int(parts[1])
except (IndexError, ValueError):
return None
def check_schema_version(declared: Any) -> None:
"""Gate one artifact's declared `schema_version`. Raises or returns None.
- **Major mismatch β reject.** A major bump is by definition breaking.
- **Newer minor β reject.** The artifact carries structure this code has
never seen; guessing at it is how a silent misread starts.
- **Older minor, or any patch β accept.** Minor bumps have been additive by
convention (0.3.0 β 0.3.3 added `source_title`, `normaliser_version` and
`page_no`), and the committed eval fixture is deliberately an older minor
than the current parser, so rejecting those would break the baseline
measurement for no safety gain.
- **Absent β accept with a warning.** Bare chunk lists and pre-envelope
fixtures carry no version; the CLI already refuses unlabelled artifacts
where labelling matters.
"""
if declared in (None, ""):
logger.warning(
"artifact_schema_version_missing",
extra={"supported": ".".join(map(str, SUPPORTED_SCHEMA))},
)
return
parsed = _parse_version(declared)
if parsed is None:
raise IncompatibleArtifactError(
f"artifact schema_version {declared!r} is not a major.minor version"
)
major, minor = parsed
supported_major, supported_minor = SUPPORTED_SCHEMA
if major != supported_major or minor > supported_minor:
raise IncompatibleArtifactError(
f"artifact schema_version {declared} is not readable by this "
f"extraction build (supports {supported_major}.{supported_minor}.x). "
"Re-parse the document, or update the adapter to the new shape."
)
if minor < supported_minor:
logger.info(
"artifact_schema_version_older",
extra={"declared": str(declared), "supported": f"{supported_major}.{supported_minor}"},
)
def chunk_from_dict(raw: dict[str, Any], doc_id: str, ordinal: int = 0) -> Chunk:
"""Map one artifact item onto the internal chunk.
`kind`/`is_tabular` are reconciled: the draft carries a `kind` discriminator
while the prototype carried booleans. Either is accepted.
"""
kind = raw.get("kind")
pages = _first(raw, _PAGES_KEYS) or []
page_start = _first(raw, _PAGE_KEYS)
if page_start is None:
page_start = min(pages) if pages else 0
page_end = _first(raw, _PAGE_END_KEYS)
if page_end is None:
page_end = max(pages) if pages else page_start
return Chunk(
chunk_id=raw.get("chunk_id") or f"{doc_id}::{ordinal:04d}",
doc_id=raw.get("doc_id") or doc_id,
text=raw["text"], # verbatim, never cleaned
page_start=int(page_start),
page_end=int(page_end),
ordinal=int(raw.get("ordinal", ordinal)),
section_no=raw.get("section_no"),
heading=raw.get("heading"),
# Same story as `latex` below: the parsing half has always emitted this
# and extraction dropped it here, which left the document's own
# structure unavailable to a branch that was inventing prose instead.
heading_path=list(raw.get("heading_path") or []),
has_formula=bool(raw.get("has_formula", kind == "equation")),
is_tabular=bool(raw.get("is_tabular", kind == "table")),
bold_spans=list(raw.get("bold_spans") or []),
# The parsing half has always emitted this; extraction used to drop it on
# the floor here, which left `formula_latex` unprovable β `text` carries
# rendered prose, so a LaTeX claim had nothing to be checked against.
latex=list(raw.get("latex") or []),
# Same story a third time: the parsing half emits `table_html` and
# extraction dropped it here, so a table reached the model ONLY as
# pipe-linearised text with its row/column structure destroyed. It is the
# haystack the formula guard needs for tables and the input a future
# table branch needs; carrying it costs nothing.
table_html=raw.get("table_html") or None,
# New at 0.4.0 β see ChunkAsset. A pointer the adapter drops is a pointer
# that does not exist, which is exactly what happened to `table_html`.
assets=[_asset(a) for a in (raw.get("assets") or [])],
referenced_by=list(raw.get("referenced_by") or []),
)
def _asset(raw: dict[str, Any]) -> ChunkAsset:
"""Map one artifact asset onto the internal shape, tolerating absences."""
return ChunkAsset(
asset_id=str(raw.get("asset_id") or ""),
kind=str(raw.get("kind") or "figure"),
caption=raw.get("caption") or None,
description=raw.get("description") or None,
page_no=raw.get("page_no"),
storage_key=raw.get("storage_key") or None,
)
def parsed_doc_from_artifact(
artifact: Any,
doc_id: str | None = None,
source_ref: str = "",
parser_name: str = "unknown",
parser_version: str = "",
) -> ParsedDoc:
"""Build a `ParsedDoc` from either shape of the artifact.
Accepts a bare `list[chunk]` (the draft's current shape) or a mapping with a
`chunks` key (the shape proposed for the document-level envelope). When the
envelope lands, its `content_hash`/`n_pages`/`version` are preferred over
the values derived here.
"""
if hasattr(artifact, "model_dump"): # a ParsedDocument from the parsing half
artifact = artifact.model_dump(mode="json")
if isinstance(artifact, dict):
items = artifact.get("chunks") or []
doc_id = doc_id or artifact.get("doc_id")
source_ref = source_ref or artifact.get("source_path") or artifact.get("source_ref") or ""
parser_name = artifact.get("parser_name") or parser_name
parser_version = artifact.get("parser_version") or parser_version
# The backend matters as much as the version: the same MinerU build can
# emit different text from `pipeline` and `vlm`, so a shift in extraction
# output has to be attributable to one or the other.
backend = artifact.get("parser_backend") or ""
if backend:
parser_version = f"{parser_version}/{backend}" if parser_version else backend
# Gate the shape BEFORE reading any of it (N6b).
check_schema_version(artifact.get("schema_version"))
declared_hash = artifact.get("content_hash")
declared_pages = artifact.get("n_pages")
else:
# A bare chunk list carries no provenance at all. It stays loadable for
# fixtures, but it arrives unlabelled and the CLI will refuse it.
items = list(artifact)
backend = ""
declared_hash, declared_pages = None, None
if not doc_id:
doc_id = (items[0].get("doc_id") if items else None) or "unknown"
chunks = [chunk_from_dict(raw, doc_id, i) for i, raw in enumerate(items)]
pages = {p for c in chunks for p in (c.page_start, c.page_end)}
return ParsedDoc(
doc_id=doc_id,
source_ref=source_ref,
content_hash=declared_hash or content_hash(chunks),
n_pages=int(declared_pages) if declared_pages else (max(pages) + 1 if pages else 0),
chunks=chunks,
parser_name=parser_name,
parser_version=parser_version,
parser_backend=backend,
used_heading_split=any(c.section_no for c in chunks),
)
def content_hash(chunks: list[Chunk]) -> str:
"""Stable hash of the chunk text, so a re-parse that changed nothing can be
detected and the expensive stages skipped."""
blob = json.dumps([c.text for c in chunks], ensure_ascii=False).encode()
return hashlib.sha256(blob).hexdigest()[:16]
def _first(raw: dict[str, Any], keys: tuple[str, ...]) -> Any:
for key in keys:
if raw.get(key) is not None:
return raw[key]
return None
|