sofhiaazzhr Claude Opus 4.8 commited on
Commit
81b4f4e
·
1 Parent(s): 58e2002

[NOTICKET] feat(knowledge-parsing): Mistral OCR block normalizer (section default)

Browse files

Mistral 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>

src/knowledge_parsing/normalize_mistral.py ADDED
@@ -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 `![img-N](...)` + 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