File size: 17,509 Bytes
81b4f4e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
"""Stage 3 (Mistral) β€” turn Mistral OCR's block stream into per-section chunks.

The counterpart of `normalize.py`, for the Mistral OCR backend. With
`include_blocks=True` Mistral returns a per-page list of typed blocks
(`title` / `text` / `equation` / `table` / `caption` / `list` / `image` /
`header` / `footer`), each with a bbox and its content β€” a structure ~1:1 with
MinerU's `content_list.json`. So this is a close port of `normalize.py`, and it
REUSES that module's `_heading` logic verbatim so section boundaries are decided
identically on both backends.

Two things it does that `normalize.py` does not have to:

  - **Strip markdown markup from text.** Mistral renders content as markdown, so
    a text line arrives as `*Production* yang **digunakan** ...`. Those `*`/`#`
    are markup Mistral added, NOT characters in the source PDF β€” and the
    extraction span-check locates LLM-quoted spans LITERALLY inside `text`, so
    leaving them in makes a quote of "Production" fail to match. Stripping markup
    is the same move `render.py` makes for LaTeX/HTML; it is NOT reflowing.
    Whitespace and line breaks are still left exactly as they are.

  - **Correlate image descriptions across the response.** A block of type
    `image` carries only `![img-N](...)` + an `image_id`; the model-written
    description lives at the page level in `pages[].images[].image_annotation`
    (produced by `bbox_annotation`). They are matched by id here.

⚠️ Text is NEVER tidied beyond markup stripping β€” no rejoining lines, no
whitespace normalisation. See contracts.py for why.
"""

from __future__ import annotations

import hashlib
import re
from collections.abc import Iterator
from functools import lru_cache
from pathlib import Path
from typing import Any

from .contracts import Chunk
from .normalize import MAX_CHUNK_CHARS, _heading  # identical section-boundary logic
from .render import render_latex, render_table

# Fingerprint of the code that decides chunk CONTENT on this backend β€” same
# mechanism and reasoning as normalize.normaliser_version (see there). Covers
# this module and render.py; contracts.py is deliberately excluded (its shape is
# versioned by schema_version).
_CONTENT_MODULES = ("normalize_mistral.py", "render.py")


@lru_cache(maxsize=1)
def normaliser_version() -> str:
    here = Path(__file__).parent
    h = hashlib.sha256()
    for name in _CONTENT_MODULES:
        h.update((here / name).read_bytes())
    return h.hexdigest()[:12]


# Page furniture, dropped on purpose β€” matches normalize._DISCARDED. `header`
# is handled separately (it becomes chapter context). `signature` is Mistral's
# label for the cover-page "Disusun/Disetujui" stamps, whose content is empty in
# the source anyway.
_DISCARDED = {"page_number", "footer"}

_LEADING_HASHES = re.compile(r"^\s*(#{1,6})\s*")
_MD_IMAGE = re.compile(r"!\[[^\]]*\]\([^)]*\)")
_MD_LINK = re.compile(r"\[([^\]]*)\]\([^)]*\)")
_MD_BOLD = re.compile(r"\*\*(.+?)\*\*", re.S)
_MD_ITALIC = re.compile(r"\*(.+?)\*", re.S)
# Inline math inside prose: "... nilai $x_i$ ...". Rendered to readable text for
# the same reason render.render_table renders math inside table cells.
_INLINE_MATH = re.compile(r"\$([^$]{1,400})\$")


def _strip_markdown(text: str, vocabulary: frozenset[str] | None = None) -> str:
    """Remove markup Mistral ADDED, keep the document's own characters.

    Deliberately conservative: only paired `*`/`**` emphasis, heading `#`,
    backticks, image and link syntax, and inline `$…$`. Underscores are left
    untouched β€” they appear in real variable names (`INPR_Hours`) far more often
    than as emphasis, and stripping them would damage content. Whitespace and
    newlines are preserved: this is markup removal, not reflow.
    """
    text = _MD_IMAGE.sub("", text)                                    # images: drop
    text = _MD_LINK.sub(r"\1", text)                                  # links: keep label
    text = _INLINE_MATH.sub(lambda m: " " + render_latex(m.group(1), vocabulary) + " ", text)
    text = _LEADING_HASHES.sub("", text)
    text = _MD_BOLD.sub(r"\1", text)
    text = _MD_ITALIC.sub(r"\1", text)
    return text.replace("`", "")


def _clean_heading(raw: str) -> tuple[str, int | None]:
    """A `title` block's markdown β†’ (plain heading text, level from '#' depth)."""
    m = _LEADING_HASHES.match(raw)
    level = len(m.group(1)) if m else None
    text = _LEADING_HASHES.sub("", raw)
    text = _MD_BOLD.sub(r"\1", text)
    text = _MD_ITALIC.sub(r"\1", text)
    return text.strip(), level


def _bbox(block: dict[str, Any]) -> list[int] | None:
    keys = ("top_left_x", "top_left_y", "bottom_right_x", "bottom_right_y")
    if all(k in block and block[k] is not None for k in keys):
        return [int(block[k]) for k in keys]
    return None


def _blocks_with_pages(response: dict[str, Any]) -> Iterator[tuple[int, dict[str, Any]]]:
    """Flatten pages[].blocks into (page_idx, block), page order preserved."""
    for page in response.get("pages", []):
        pidx = int(page.get("index", 0))
        for block in page.get("blocks") or []:
            yield pidx, block


def _image_descriptions(response: dict[str, Any]) -> dict[str, str]:
    """image_id -> description, from the page-level annotations (bbox_annotation).

    Empty when the OCR call did not request annotations; description then stays
    None, exactly as it does on MinerU's `pipeline` backend.
    """
    out: dict[str, str] = {}
    for page in response.get("pages", []):
        for im in page.get("images") or []:
            ann = im.get("image_annotation")
            iid = im.get("id")
            if iid and ann:
                out[iid] = ann if isinstance(ann, str) else str(ann)
    return out


def _chunk_by_page(
    blocks: list[tuple[int, dict[str, Any]]],
    descriptions: dict[str, str],
    chapter_per_page: dict[int, str],
    doc_id: str,
) -> list[Chunk]:
    """Page granularity: all prose on a page β†’ one chunk; tables/images their own.

    Recall-only. A page chunk has no single heading, so `heading_path` /
    `section_no` stay empty and the extraction outline degrades β€” section
    granularity (the default) is the real path.
    """
    def chapters(page: int) -> list[str]:
        return [chapter_per_page[page]] if page in chapter_per_page else []

    chunks: list[Chunk] = []
    by_page: dict[int, list[tuple[int, dict[str, Any]]]] = {}
    for i, (pidx, block) in enumerate(blocks):
        by_page.setdefault(pidx, []).append((i, block))

    for pidx in sorted(by_page):
        pieces: list[str] = []
        latex: list[str] = []
        src: list[int] = []
        has_formula = False
        prose_bbox: list[int] | None = None

        for i, block in by_page[pidx]:
            kind = block.get("type")
            if kind in _DISCARDED or kind == "header":
                continue

            if kind == "table":
                c = Chunk(
                    chunk_id=f"{doc_id}::{len(chunks):04d}", doc_id=doc_id,
                    kind="table", text=render_table(block.get("content") or ""),
                    page_idx=pidx, page_idxs=[pidx], chapters=chapters(pidx),
                    is_tabular=True, table_html=block.get("content") or None,
                    source_items=[i], bbox=_bbox(block),
                )
                if c.text or c.images:
                    chunks.append(c)
                continue

            if kind == "image":
                iid = block.get("image_id")
                chunks.append(Chunk(
                    chunk_id=f"{doc_id}::{len(chunks):04d}", doc_id=doc_id,
                    kind="image", text="", page_idx=pidx, page_idxs=[pidx],
                    chapters=chapters(pidx),
                    generated_description=descriptions.get(iid) if iid else None,
                    source_items=[i], bbox=_bbox(block),
                    images=[iid] if iid else [],
                ))
                continue

            if kind == "equation":
                raw = (block.get("content") or "").strip().strip("$").strip()
                has_formula = True
                src.append(i)
                if raw:
                    latex.append(raw)
                    rendered = render_latex(raw)
                    if rendered:
                        pieces.append(rendered)
                continue

            # text / title / list / caption / signature -> prose. A numbered
            # heading is NOT a boundary in page mode; it stays as prose text.
            text = _strip_markdown(block.get("content") or "").rstrip()
            if not text.strip():
                continue
            if prose_bbox is None:
                prose_bbox = _bbox(block)
            src.append(i)
            pieces.append(text)

        if pieces:
            chunks.append(Chunk(
                chunk_id=f"{doc_id}::{len(chunks):04d}", doc_id=doc_id,
                kind="equation" if has_formula else "text",
                text="\n\n".join(pieces).strip(),
                page_idx=pidx, page_idxs=[pidx], chapters=chapters(pidx),
                has_formula=has_formula, latex=latex, source_items=src,
                bbox=prose_bbox,
            ))
    return chunks


def normalise_mistral(
    response: dict[str, Any], doc_id: str, granularity: str = "section"
) -> list[Chunk]:
    """Mistral OCR response (include_blocks=True) β†’ chunks.

    `granularity` decides how prose is grouped, and only that:

      - "section" (default): a numbered heading opens a new chunk, mirroring
        normalize.py. This is the default because the extraction half derives
        the document `outline` from `heading_path` and drives `source_wording`
        from `heading` β€” page grouping leaves both EMPTY (a page chunk has no
        single heading), which silently degrades a locked product behaviour.
      - "page": all prose on a page becomes ONE chunk. Higher raw term recall
        (bigger context per NER window) but no per-chunk heading, so only fit
        for a recall-only measurement, never for the real pipeline.

    The section/page recall gap (0.878 vs 0.951 on the BUMA fixture) is the NER
    filter's, not content: every gold term is present in the text either way, and
    closing it is the extraction side's lever (span-filter overlap resolution).

    In BOTH modes tables and images are their OWN chunks, so `table_html` stays
    one-table-per-chunk regardless. Switching modes is a one-word change and
    never touches the extraction half β€” the artifact shape is identical.

    No `page_vocabulary` parameter: Mistral does not spell formulas out one
    character at a time, so the word-boundary recovery that MinerU needs has
    nothing to do here.
    """
    if granularity not in {"page", "section"}:
        raise ValueError(f"granularity must be 'page' or 'section', got {granularity!r}")

    blocks = list(_blocks_with_pages(response))
    if not blocks:
        raise ValueError(
            "no blocks in Mistral response β€” call ocr.process with include_blocks=True"
        )
    descriptions = _image_descriptions(response)

    # Chapter context per page, from the running header (type 'header').
    chapter_per_page: dict[int, str] = {}
    for pidx, b in blocks:
        if b.get("type") == "header":
            txt = _strip_markdown(b.get("content") or "").strip()
            if txt:
                chapter_per_page.setdefault(pidx, txt)

    # --- page mode: recall-only; a page chunk has no single heading ---
    if granularity == "page":
        return _chunk_by_page(blocks, descriptions, chapter_per_page, doc_id)

    # --- section mode (default): a numbered heading opens a new chunk (mirrors normalize.py) ---
    chunks: list[Chunk] = []
    running: Chunk | None = None
    pieces: list[str] = []
    stack: list[tuple[int, str]] = []

    def push_heading(level: int, text: str) -> None:
        while stack and stack[-1][0] >= level:
            stack.pop()
        stack.append((level, text))

    def close() -> None:
        nonlocal running, pieces
        if running is not None:
            running.text = "\n\n".join(pieces).strip()
            if running.text or running.images:
                running.page_idxs = sorted(set(running.page_idxs))
                chunks.append(running)
        running, pieces = None, []

    def open_chunk(
        kind: str, page: int, section_no: str | None = None, heading: str | None = None
    ) -> Chunk:
        return Chunk(
            chunk_id=f"{doc_id}::{len(chunks):04d}",
            doc_id=doc_id, kind=kind, text="",
            page_idx=page, page_idxs=[page],
            section_no=section_no, heading=heading,
            chapters=[chapter_per_page[page]] if page in chapter_per_page else [],
            heading_path=[t for _, t in stack],
        )

    for i, (page, block) in enumerate(blocks):
        kind = block.get("type")
        if kind in _DISCARDED or kind == "header":
            continue

        if kind == "table":
            close()
            c = open_chunk("table", page)
            c.text = render_table(block.get("content") or "")
            c.is_tabular = True
            c.table_html = block.get("content") or None
            c.source_items = [i]
            c.bbox = _bbox(block)
            if c.text or c.images:
                chunks.append(c)
            continue

        if kind == "image":
            close()
            c = open_chunk("image", page)
            iid = block.get("image_id")
            c.generated_description = descriptions.get(iid) if iid else None
            c.source_items = [i]
            c.bbox = _bbox(block)
            if iid:
                c.images = [iid]
            # text stays "" β€” a printed caption arrives as its own `caption`
            # block; the model description is kept OUT of text on purpose.
            chunks.append(c)
            continue

        if kind == "equation":
            latex = (block.get("content") or "").strip().strip("$").strip()
            if running is None:
                running = open_chunk("text", page)
            running.has_formula = True
            running.source_items.append(i)
            running.page_idxs.append(page)
            if latex:
                running.latex.append(latex)              # raw, for the formula branch
                rendered = render_latex(latex)
                if rendered:
                    pieces.append(rendered)              # prose, so NER can find it
            continue

        raw = block.get("content") or ""

        # `list` and `caption` are ALWAYS content, never a section boundary: a
        # bullet or a caption can look like a numbered heading, and letting one
        # open a section would split a list from the paragraph introducing it
        # (see normalize.py's list note).
        if kind in {"list", "caption"}:
            text = _strip_markdown(raw).rstrip()
            if text.strip():
                if running is None:
                    running = open_chunk("text", page)
                    running.bbox = _bbox(block)
                running.source_items.append(i)
                running.page_idxs.append(page)
                pieces.append(text)
            continue

        # `text` / `title` / `signature`: a NUMBERED line may open a section.
        # normalize._heading is applied to BOTH text and title items on MinerU β€”
        # here too, because Mistral labels many numbered headings as `text`, not
        # `title`. Checking only `title` left whole sections unopened and every
        # equation piled into one chunk. A title's level comes from its `#`
        # depth; a text line's from numbering depth (fallback inside _heading).
        if kind == "title":
            htext, level = _clean_heading(raw)
        else:
            htext, level = _strip_markdown(raw).strip(), None
        number, heading, hlevel = _heading(
            {"type": "title", "text": htext, "text_level": level}
        )
        if heading is not None:
            if any(htext == t for _, t in stack):        # reprinted breadcrumb
                if running is not None:
                    running.page_idxs.append(page)
                continue
            close()
            push_heading(hlevel or 1, heading)
            running = open_chunk("text", page, section_no=number, heading=heading)
            running.source_items = [i]
            running.bbox = _bbox(block)
            # heading line NOT copied into text β€” lives in `heading`. See
            # normalize.py for the measured reason (mention_count inflation).
            continue

        text = _strip_markdown(raw).rstrip()
        if not text.strip():
            continue

        if running is None:
            running = open_chunk("text", page)
            running.bbox = _bbox(block)
        running.source_items.append(i)
        running.page_idxs.append(page)
        chapter = chapter_per_page.get(page)
        if chapter and chapter not in running.chapters:
            running.chapters.append(chapter)
        pieces.append(text)                              # verbatim (markup only removed)

        if sum(len(x) for x in pieces) >= MAX_CHUNK_CHARS:
            continued_from = running
            close()
            running = open_chunk("text", page,
                                 section_no=continued_from.section_no,
                                 heading=continued_from.heading)

    close()
    return chunks