[NOTICKET] feat(knowledge-parsing): Mistral OCR block normalizer (section default)
Browse filesMistral OCR (include_blocks=True) block stream -> per-section Chunk[], the
counterpart of normalize.py for the Mistral backend.
- Reuses normalize._heading and render.py so section boundaries and prose
rendering match the MinerU path; artifact shape is identical (seam unchanged,
extraction never learns which parser ran).
- Strips markdown markup Mistral adds (not present in the source PDF) so the
extraction span-check still matches quotes literally; whitespace kept verbatim.
- granularity="section" (default) populates heading_path/heading/section_no,
which extraction needs for outline + source_wording; "page" is recall-only.
- Validated on the BUMA fixture through extraction's own adapter + span filter:
section recall 0.878, page 0.951 (both above MinerU section 0.829).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@@ -0,0 +1,416 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stage 3 (Mistral) — turn Mistral OCR's block stream into per-section chunks.
|
| 2 |
+
|
| 3 |
+
The counterpart of `normalize.py`, for the Mistral OCR backend. With
|
| 4 |
+
`include_blocks=True` Mistral returns a per-page list of typed blocks
|
| 5 |
+
(`title` / `text` / `equation` / `table` / `caption` / `list` / `image` /
|
| 6 |
+
`header` / `footer`), each with a bbox and its content — a structure ~1:1 with
|
| 7 |
+
MinerU's `content_list.json`. So this is a close port of `normalize.py`, and it
|
| 8 |
+
REUSES that module's `_heading` logic verbatim so section boundaries are decided
|
| 9 |
+
identically on both backends.
|
| 10 |
+
|
| 11 |
+
Two things it does that `normalize.py` does not have to:
|
| 12 |
+
|
| 13 |
+
- **Strip markdown markup from text.** Mistral renders content as markdown, so
|
| 14 |
+
a text line arrives as `*Production* yang **digunakan** ...`. Those `*`/`#`
|
| 15 |
+
are markup Mistral added, NOT characters in the source PDF — and the
|
| 16 |
+
extraction span-check locates LLM-quoted spans LITERALLY inside `text`, so
|
| 17 |
+
leaving them in makes a quote of "Production" fail to match. Stripping markup
|
| 18 |
+
is the same move `render.py` makes for LaTeX/HTML; it is NOT reflowing.
|
| 19 |
+
Whitespace and line breaks are still left exactly as they are.
|
| 20 |
+
|
| 21 |
+
- **Correlate image descriptions across the response.** A block of type
|
| 22 |
+
`image` carries only `` + an `image_id`; the model-written
|
| 23 |
+
description lives at the page level in `pages[].images[].image_annotation`
|
| 24 |
+
(produced by `bbox_annotation`). They are matched by id here.
|
| 25 |
+
|
| 26 |
+
⚠️ Text is NEVER tidied beyond markup stripping — no rejoining lines, no
|
| 27 |
+
whitespace normalisation. See contracts.py for why.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
import hashlib
|
| 33 |
+
import re
|
| 34 |
+
from collections.abc import Iterator
|
| 35 |
+
from functools import lru_cache
|
| 36 |
+
from pathlib import Path
|
| 37 |
+
from typing import Any
|
| 38 |
+
|
| 39 |
+
from .contracts import Chunk
|
| 40 |
+
from .normalize import MAX_CHUNK_CHARS, _heading # identical section-boundary logic
|
| 41 |
+
from .render import render_latex, render_table
|
| 42 |
+
|
| 43 |
+
# Fingerprint of the code that decides chunk CONTENT on this backend — same
|
| 44 |
+
# mechanism and reasoning as normalize.normaliser_version (see there). Covers
|
| 45 |
+
# this module and render.py; contracts.py is deliberately excluded (its shape is
|
| 46 |
+
# versioned by schema_version).
|
| 47 |
+
_CONTENT_MODULES = ("normalize_mistral.py", "render.py")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@lru_cache(maxsize=1)
|
| 51 |
+
def normaliser_version() -> str:
|
| 52 |
+
here = Path(__file__).parent
|
| 53 |
+
h = hashlib.sha256()
|
| 54 |
+
for name in _CONTENT_MODULES:
|
| 55 |
+
h.update((here / name).read_bytes())
|
| 56 |
+
return h.hexdigest()[:12]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# Page furniture, dropped on purpose — matches normalize._DISCARDED. `header`
|
| 60 |
+
# is handled separately (it becomes chapter context). `signature` is Mistral's
|
| 61 |
+
# label for the cover-page "Disusun/Disetujui" stamps, whose content is empty in
|
| 62 |
+
# the source anyway.
|
| 63 |
+
_DISCARDED = {"page_number", "footer"}
|
| 64 |
+
|
| 65 |
+
_LEADING_HASHES = re.compile(r"^\s*(#{1,6})\s*")
|
| 66 |
+
_MD_IMAGE = re.compile(r"!\[[^\]]*\]\([^)]*\)")
|
| 67 |
+
_MD_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
|
| 68 |
+
_MD_BOLD = re.compile(r"\*\*(.+?)\*\*", re.S)
|
| 69 |
+
_MD_ITALIC = re.compile(r"\*(.+?)\*", re.S)
|
| 70 |
+
# Inline math inside prose: "... nilai $x_i$ ...". Rendered to readable text for
|
| 71 |
+
# the same reason render.render_table renders math inside table cells.
|
| 72 |
+
_INLINE_MATH = re.compile(r"\$([^$]{1,400})\$")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _strip_markdown(text: str, vocabulary: frozenset[str] | None = None) -> str:
|
| 76 |
+
"""Remove markup Mistral ADDED, keep the document's own characters.
|
| 77 |
+
|
| 78 |
+
Deliberately conservative: only paired `*`/`**` emphasis, heading `#`,
|
| 79 |
+
backticks, image and link syntax, and inline `$…$`. Underscores are left
|
| 80 |
+
untouched — they appear in real variable names (`INPR_Hours`) far more often
|
| 81 |
+
than as emphasis, and stripping them would damage content. Whitespace and
|
| 82 |
+
newlines are preserved: this is markup removal, not reflow.
|
| 83 |
+
"""
|
| 84 |
+
text = _MD_IMAGE.sub("", text) # images: drop
|
| 85 |
+
text = _MD_LINK.sub(r"\1", text) # links: keep label
|
| 86 |
+
text = _INLINE_MATH.sub(lambda m: " " + render_latex(m.group(1), vocabulary) + " ", text)
|
| 87 |
+
text = _LEADING_HASHES.sub("", text)
|
| 88 |
+
text = _MD_BOLD.sub(r"\1", text)
|
| 89 |
+
text = _MD_ITALIC.sub(r"\1", text)
|
| 90 |
+
return text.replace("`", "")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _clean_heading(raw: str) -> tuple[str, int | None]:
|
| 94 |
+
"""A `title` block's markdown → (plain heading text, level from '#' depth)."""
|
| 95 |
+
m = _LEADING_HASHES.match(raw)
|
| 96 |
+
level = len(m.group(1)) if m else None
|
| 97 |
+
text = _LEADING_HASHES.sub("", raw)
|
| 98 |
+
text = _MD_BOLD.sub(r"\1", text)
|
| 99 |
+
text = _MD_ITALIC.sub(r"\1", text)
|
| 100 |
+
return text.strip(), level
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _bbox(block: dict[str, Any]) -> list[int] | None:
|
| 104 |
+
keys = ("top_left_x", "top_left_y", "bottom_right_x", "bottom_right_y")
|
| 105 |
+
if all(k in block and block[k] is not None for k in keys):
|
| 106 |
+
return [int(block[k]) for k in keys]
|
| 107 |
+
return None
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _blocks_with_pages(response: dict[str, Any]) -> Iterator[tuple[int, dict[str, Any]]]:
|
| 111 |
+
"""Flatten pages[].blocks into (page_idx, block), page order preserved."""
|
| 112 |
+
for page in response.get("pages", []):
|
| 113 |
+
pidx = int(page.get("index", 0))
|
| 114 |
+
for block in page.get("blocks") or []:
|
| 115 |
+
yield pidx, block
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _image_descriptions(response: dict[str, Any]) -> dict[str, str]:
|
| 119 |
+
"""image_id -> description, from the page-level annotations (bbox_annotation).
|
| 120 |
+
|
| 121 |
+
Empty when the OCR call did not request annotations; description then stays
|
| 122 |
+
None, exactly as it does on MinerU's `pipeline` backend.
|
| 123 |
+
"""
|
| 124 |
+
out: dict[str, str] = {}
|
| 125 |
+
for page in response.get("pages", []):
|
| 126 |
+
for im in page.get("images") or []:
|
| 127 |
+
ann = im.get("image_annotation")
|
| 128 |
+
iid = im.get("id")
|
| 129 |
+
if iid and ann:
|
| 130 |
+
out[iid] = ann if isinstance(ann, str) else str(ann)
|
| 131 |
+
return out
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _chunk_by_page(
|
| 135 |
+
blocks: list[tuple[int, dict[str, Any]]],
|
| 136 |
+
descriptions: dict[str, str],
|
| 137 |
+
chapter_per_page: dict[int, str],
|
| 138 |
+
doc_id: str,
|
| 139 |
+
) -> list[Chunk]:
|
| 140 |
+
"""Page granularity: all prose on a page → one chunk; tables/images their own.
|
| 141 |
+
|
| 142 |
+
Recall-only. A page chunk has no single heading, so `heading_path` /
|
| 143 |
+
`section_no` stay empty and the extraction outline degrades — section
|
| 144 |
+
granularity (the default) is the real path.
|
| 145 |
+
"""
|
| 146 |
+
def chapters(page: int) -> list[str]:
|
| 147 |
+
return [chapter_per_page[page]] if page in chapter_per_page else []
|
| 148 |
+
|
| 149 |
+
chunks: list[Chunk] = []
|
| 150 |
+
by_page: dict[int, list[tuple[int, dict[str, Any]]]] = {}
|
| 151 |
+
for i, (pidx, block) in enumerate(blocks):
|
| 152 |
+
by_page.setdefault(pidx, []).append((i, block))
|
| 153 |
+
|
| 154 |
+
for pidx in sorted(by_page):
|
| 155 |
+
pieces: list[str] = []
|
| 156 |
+
latex: list[str] = []
|
| 157 |
+
src: list[int] = []
|
| 158 |
+
has_formula = False
|
| 159 |
+
prose_bbox: list[int] | None = None
|
| 160 |
+
|
| 161 |
+
for i, block in by_page[pidx]:
|
| 162 |
+
kind = block.get("type")
|
| 163 |
+
if kind in _DISCARDED or kind == "header":
|
| 164 |
+
continue
|
| 165 |
+
|
| 166 |
+
if kind == "table":
|
| 167 |
+
c = Chunk(
|
| 168 |
+
chunk_id=f"{doc_id}::{len(chunks):04d}", doc_id=doc_id,
|
| 169 |
+
kind="table", text=render_table(block.get("content") or ""),
|
| 170 |
+
page_idx=pidx, page_idxs=[pidx], chapters=chapters(pidx),
|
| 171 |
+
is_tabular=True, table_html=block.get("content") or None,
|
| 172 |
+
source_items=[i], bbox=_bbox(block),
|
| 173 |
+
)
|
| 174 |
+
if c.text or c.images:
|
| 175 |
+
chunks.append(c)
|
| 176 |
+
continue
|
| 177 |
+
|
| 178 |
+
if kind == "image":
|
| 179 |
+
iid = block.get("image_id")
|
| 180 |
+
chunks.append(Chunk(
|
| 181 |
+
chunk_id=f"{doc_id}::{len(chunks):04d}", doc_id=doc_id,
|
| 182 |
+
kind="image", text="", page_idx=pidx, page_idxs=[pidx],
|
| 183 |
+
chapters=chapters(pidx),
|
| 184 |
+
generated_description=descriptions.get(iid) if iid else None,
|
| 185 |
+
source_items=[i], bbox=_bbox(block),
|
| 186 |
+
images=[iid] if iid else [],
|
| 187 |
+
))
|
| 188 |
+
continue
|
| 189 |
+
|
| 190 |
+
if kind == "equation":
|
| 191 |
+
raw = (block.get("content") or "").strip().strip("$").strip()
|
| 192 |
+
has_formula = True
|
| 193 |
+
src.append(i)
|
| 194 |
+
if raw:
|
| 195 |
+
latex.append(raw)
|
| 196 |
+
rendered = render_latex(raw)
|
| 197 |
+
if rendered:
|
| 198 |
+
pieces.append(rendered)
|
| 199 |
+
continue
|
| 200 |
+
|
| 201 |
+
# text / title / list / caption / signature -> prose. A numbered
|
| 202 |
+
# heading is NOT a boundary in page mode; it stays as prose text.
|
| 203 |
+
text = _strip_markdown(block.get("content") or "").rstrip()
|
| 204 |
+
if not text.strip():
|
| 205 |
+
continue
|
| 206 |
+
if prose_bbox is None:
|
| 207 |
+
prose_bbox = _bbox(block)
|
| 208 |
+
src.append(i)
|
| 209 |
+
pieces.append(text)
|
| 210 |
+
|
| 211 |
+
if pieces:
|
| 212 |
+
chunks.append(Chunk(
|
| 213 |
+
chunk_id=f"{doc_id}::{len(chunks):04d}", doc_id=doc_id,
|
| 214 |
+
kind="equation" if has_formula else "text",
|
| 215 |
+
text="\n\n".join(pieces).strip(),
|
| 216 |
+
page_idx=pidx, page_idxs=[pidx], chapters=chapters(pidx),
|
| 217 |
+
has_formula=has_formula, latex=latex, source_items=src,
|
| 218 |
+
bbox=prose_bbox,
|
| 219 |
+
))
|
| 220 |
+
return chunks
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def normalise_mistral(
|
| 224 |
+
response: dict[str, Any], doc_id: str, granularity: str = "section"
|
| 225 |
+
) -> list[Chunk]:
|
| 226 |
+
"""Mistral OCR response (include_blocks=True) → chunks.
|
| 227 |
+
|
| 228 |
+
`granularity` decides how prose is grouped, and only that:
|
| 229 |
+
|
| 230 |
+
- "section" (default): a numbered heading opens a new chunk, mirroring
|
| 231 |
+
normalize.py. This is the default because the extraction half derives
|
| 232 |
+
the document `outline` from `heading_path` and drives `source_wording`
|
| 233 |
+
from `heading` — page grouping leaves both EMPTY (a page chunk has no
|
| 234 |
+
single heading), which silently degrades a locked product behaviour.
|
| 235 |
+
- "page": all prose on a page becomes ONE chunk. Higher raw term recall
|
| 236 |
+
(bigger context per NER window) but no per-chunk heading, so only fit
|
| 237 |
+
for a recall-only measurement, never for the real pipeline.
|
| 238 |
+
|
| 239 |
+
The section/page recall gap (0.878 vs 0.951 on the BUMA fixture) is the NER
|
| 240 |
+
filter's, not content: every gold term is present in the text either way, and
|
| 241 |
+
closing it is the extraction side's lever (span-filter overlap resolution).
|
| 242 |
+
|
| 243 |
+
In BOTH modes tables and images are their OWN chunks, so `table_html` stays
|
| 244 |
+
one-table-per-chunk regardless. Switching modes is a one-word change and
|
| 245 |
+
never touches the extraction half — the artifact shape is identical.
|
| 246 |
+
|
| 247 |
+
No `page_vocabulary` parameter: Mistral does not spell formulas out one
|
| 248 |
+
character at a time, so the word-boundary recovery that MinerU needs has
|
| 249 |
+
nothing to do here.
|
| 250 |
+
"""
|
| 251 |
+
if granularity not in {"page", "section"}:
|
| 252 |
+
raise ValueError(f"granularity must be 'page' or 'section', got {granularity!r}")
|
| 253 |
+
|
| 254 |
+
blocks = list(_blocks_with_pages(response))
|
| 255 |
+
if not blocks:
|
| 256 |
+
raise ValueError(
|
| 257 |
+
"no blocks in Mistral response — call ocr.process with include_blocks=True"
|
| 258 |
+
)
|
| 259 |
+
descriptions = _image_descriptions(response)
|
| 260 |
+
|
| 261 |
+
# Chapter context per page, from the running header (type 'header').
|
| 262 |
+
chapter_per_page: dict[int, str] = {}
|
| 263 |
+
for pidx, b in blocks:
|
| 264 |
+
if b.get("type") == "header":
|
| 265 |
+
txt = _strip_markdown(b.get("content") or "").strip()
|
| 266 |
+
if txt:
|
| 267 |
+
chapter_per_page.setdefault(pidx, txt)
|
| 268 |
+
|
| 269 |
+
# --- page mode: recall-only; a page chunk has no single heading ---
|
| 270 |
+
if granularity == "page":
|
| 271 |
+
return _chunk_by_page(blocks, descriptions, chapter_per_page, doc_id)
|
| 272 |
+
|
| 273 |
+
# --- section mode (default): a numbered heading opens a new chunk (mirrors normalize.py) ---
|
| 274 |
+
chunks: list[Chunk] = []
|
| 275 |
+
running: Chunk | None = None
|
| 276 |
+
pieces: list[str] = []
|
| 277 |
+
stack: list[tuple[int, str]] = []
|
| 278 |
+
|
| 279 |
+
def push_heading(level: int, text: str) -> None:
|
| 280 |
+
while stack and stack[-1][0] >= level:
|
| 281 |
+
stack.pop()
|
| 282 |
+
stack.append((level, text))
|
| 283 |
+
|
| 284 |
+
def close() -> None:
|
| 285 |
+
nonlocal running, pieces
|
| 286 |
+
if running is not None:
|
| 287 |
+
running.text = "\n\n".join(pieces).strip()
|
| 288 |
+
if running.text or running.images:
|
| 289 |
+
running.page_idxs = sorted(set(running.page_idxs))
|
| 290 |
+
chunks.append(running)
|
| 291 |
+
running, pieces = None, []
|
| 292 |
+
|
| 293 |
+
def open_chunk(
|
| 294 |
+
kind: str, page: int, section_no: str | None = None, heading: str | None = None
|
| 295 |
+
) -> Chunk:
|
| 296 |
+
return Chunk(
|
| 297 |
+
chunk_id=f"{doc_id}::{len(chunks):04d}",
|
| 298 |
+
doc_id=doc_id, kind=kind, text="",
|
| 299 |
+
page_idx=page, page_idxs=[page],
|
| 300 |
+
section_no=section_no, heading=heading,
|
| 301 |
+
chapters=[chapter_per_page[page]] if page in chapter_per_page else [],
|
| 302 |
+
heading_path=[t for _, t in stack],
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
for i, (page, block) in enumerate(blocks):
|
| 306 |
+
kind = block.get("type")
|
| 307 |
+
if kind in _DISCARDED or kind == "header":
|
| 308 |
+
continue
|
| 309 |
+
|
| 310 |
+
if kind == "table":
|
| 311 |
+
close()
|
| 312 |
+
c = open_chunk("table", page)
|
| 313 |
+
c.text = render_table(block.get("content") or "")
|
| 314 |
+
c.is_tabular = True
|
| 315 |
+
c.table_html = block.get("content") or None
|
| 316 |
+
c.source_items = [i]
|
| 317 |
+
c.bbox = _bbox(block)
|
| 318 |
+
if c.text or c.images:
|
| 319 |
+
chunks.append(c)
|
| 320 |
+
continue
|
| 321 |
+
|
| 322 |
+
if kind == "image":
|
| 323 |
+
close()
|
| 324 |
+
c = open_chunk("image", page)
|
| 325 |
+
iid = block.get("image_id")
|
| 326 |
+
c.generated_description = descriptions.get(iid) if iid else None
|
| 327 |
+
c.source_items = [i]
|
| 328 |
+
c.bbox = _bbox(block)
|
| 329 |
+
if iid:
|
| 330 |
+
c.images = [iid]
|
| 331 |
+
# text stays "" — a printed caption arrives as its own `caption`
|
| 332 |
+
# block; the model description is kept OUT of text on purpose.
|
| 333 |
+
chunks.append(c)
|
| 334 |
+
continue
|
| 335 |
+
|
| 336 |
+
if kind == "equation":
|
| 337 |
+
latex = (block.get("content") or "").strip().strip("$").strip()
|
| 338 |
+
if running is None:
|
| 339 |
+
running = open_chunk("text", page)
|
| 340 |
+
running.has_formula = True
|
| 341 |
+
running.source_items.append(i)
|
| 342 |
+
running.page_idxs.append(page)
|
| 343 |
+
if latex:
|
| 344 |
+
running.latex.append(latex) # raw, for the formula branch
|
| 345 |
+
rendered = render_latex(latex)
|
| 346 |
+
if rendered:
|
| 347 |
+
pieces.append(rendered) # prose, so NER can find it
|
| 348 |
+
continue
|
| 349 |
+
|
| 350 |
+
raw = block.get("content") or ""
|
| 351 |
+
|
| 352 |
+
# `list` and `caption` are ALWAYS content, never a section boundary: a
|
| 353 |
+
# bullet or a caption can look like a numbered heading, and letting one
|
| 354 |
+
# open a section would split a list from the paragraph introducing it
|
| 355 |
+
# (see normalize.py's list note).
|
| 356 |
+
if kind in {"list", "caption"}:
|
| 357 |
+
text = _strip_markdown(raw).rstrip()
|
| 358 |
+
if text.strip():
|
| 359 |
+
if running is None:
|
| 360 |
+
running = open_chunk("text", page)
|
| 361 |
+
running.bbox = _bbox(block)
|
| 362 |
+
running.source_items.append(i)
|
| 363 |
+
running.page_idxs.append(page)
|
| 364 |
+
pieces.append(text)
|
| 365 |
+
continue
|
| 366 |
+
|
| 367 |
+
# `text` / `title` / `signature`: a NUMBERED line may open a section.
|
| 368 |
+
# normalize._heading is applied to BOTH text and title items on MinerU —
|
| 369 |
+
# here too, because Mistral labels many numbered headings as `text`, not
|
| 370 |
+
# `title`. Checking only `title` left whole sections unopened and every
|
| 371 |
+
# equation piled into one chunk. A title's level comes from its `#`
|
| 372 |
+
# depth; a text line's from numbering depth (fallback inside _heading).
|
| 373 |
+
if kind == "title":
|
| 374 |
+
htext, level = _clean_heading(raw)
|
| 375 |
+
else:
|
| 376 |
+
htext, level = _strip_markdown(raw).strip(), None
|
| 377 |
+
number, heading, hlevel = _heading(
|
| 378 |
+
{"type": "title", "text": htext, "text_level": level}
|
| 379 |
+
)
|
| 380 |
+
if heading is not None:
|
| 381 |
+
if any(htext == t for _, t in stack): # reprinted breadcrumb
|
| 382 |
+
if running is not None:
|
| 383 |
+
running.page_idxs.append(page)
|
| 384 |
+
continue
|
| 385 |
+
close()
|
| 386 |
+
push_heading(hlevel or 1, heading)
|
| 387 |
+
running = open_chunk("text", page, section_no=number, heading=heading)
|
| 388 |
+
running.source_items = [i]
|
| 389 |
+
running.bbox = _bbox(block)
|
| 390 |
+
# heading line NOT copied into text — lives in `heading`. See
|
| 391 |
+
# normalize.py for the measured reason (mention_count inflation).
|
| 392 |
+
continue
|
| 393 |
+
|
| 394 |
+
text = _strip_markdown(raw).rstrip()
|
| 395 |
+
if not text.strip():
|
| 396 |
+
continue
|
| 397 |
+
|
| 398 |
+
if running is None:
|
| 399 |
+
running = open_chunk("text", page)
|
| 400 |
+
running.bbox = _bbox(block)
|
| 401 |
+
running.source_items.append(i)
|
| 402 |
+
running.page_idxs.append(page)
|
| 403 |
+
chapter = chapter_per_page.get(page)
|
| 404 |
+
if chapter and chapter not in running.chapters:
|
| 405 |
+
running.chapters.append(chapter)
|
| 406 |
+
pieces.append(text) # verbatim (markup only removed)
|
| 407 |
+
|
| 408 |
+
if sum(len(x) for x in pieces) >= MAX_CHUNK_CHARS:
|
| 409 |
+
continued_from = running
|
| 410 |
+
close()
|
| 411 |
+
running = open_chunk("text", page,
|
| 412 |
+
section_no=continued_from.section_no,
|
| 413 |
+
heading=continued_from.heading)
|
| 414 |
+
|
| 415 |
+
close()
|
| 416 |
+
return chunks
|