[NOTICKET] feat(knowledge-parsing): carry image descriptions, apart from the text
Browse filesMinerU describes images and charts, but only on some backends. Read from its
own source (is_image_analysis_option_visible): never on `pipeline`, always on
`vlm`, and on `hybrid` only at effort=high — medium disables it outright.
Two faults kept that description from ever arriving.
`normalize.py` had no branch for `image` items at all. Different backends label
the same picture differently: what `pipeline` calls a chart, `vlm` calls an
image. Image items fell through to the generic text path, which reads only
item["text"] — a field pictures do not have — so caption AND description were
dropped in silence. That is most of what looked like "vlm loses chart
detection" in yesterday's comparison: vlm found them, we discarded them.
The second is worse and was waiting to fire. `_teks_chart` appended `content`
straight into Chunk.text. Harmless today because `pipeline` always leaves it
empty — but the moment the backend flips to vlm, model-written prose starts
flowing into the field the extraction guardrail searches to prove a quote is
verbatim. A hallucinated sentence would pass the very check built to catch
hallucination, silently, with no code change to blame.
So generated text now has its own field, `Chunk.generated_description`, and
never enters `text`. A caption printed in the document is verbatim and stays;
a description invented about the picture does not. Consumers must treat it as
a model claim about an image — it cannot be span-checked against anything.
Contract moves to schema 0.3.0: `image` joins ChunkKind, and the new field is
additive. Also adds `--effort` to the CLI, without which hybrid could only ever
run at its default.
Verified on `pipeline`: 31 chunks unchanged, generated_description null
throughout, chart captions still in text — exactly as it should be for a
backend that never describes anything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@@ -41,9 +41,9 @@ from typing import Literal
|
|
| 41 |
|
| 42 |
from pydantic import BaseModel, Field
|
| 43 |
|
| 44 |
-
ChunkKind = Literal["text", "table", "chart", "equation"]
|
| 45 |
|
| 46 |
-
SCHEMA_VERSION = "0.
|
| 47 |
|
| 48 |
|
| 49 |
class Chunk(BaseModel):
|
|
@@ -121,6 +121,21 @@ class Chunk(BaseModel):
|
|
| 121 |
latex: list[str] = Field(default_factory=list)
|
| 122 |
table_html: str | None = None
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
class ParsedDocument(BaseModel):
|
| 126 |
"""The artifact itself — one parsed document, self-describing.
|
|
|
|
| 41 |
|
| 42 |
from pydantic import BaseModel, Field
|
| 43 |
|
| 44 |
+
ChunkKind = Literal["text", "table", "chart", "equation", "image"]
|
| 45 |
|
| 46 |
+
SCHEMA_VERSION = "0.3.0"
|
| 47 |
|
| 48 |
|
| 49 |
class Chunk(BaseModel):
|
|
|
|
| 121 |
latex: list[str] = Field(default_factory=list)
|
| 122 |
table_html: str | None = None
|
| 123 |
|
| 124 |
+
# ⚠️ MODEL-WRITTEN, NOT FROM THE DOCUMENT. MinerU's `vlm` backend (and
|
| 125 |
+
# `hybrid` at effort=high) describes images and charts; that description
|
| 126 |
+
# arrives in the item's `content` field.
|
| 127 |
+
#
|
| 128 |
+
# It is kept OUT of `text` deliberately. `text` is the haystack the
|
| 129 |
+
# extraction guardrail searches to prove a quote is verbatim; putting
|
| 130 |
+
# generated prose there would let a hallucinated sentence pass the very
|
| 131 |
+
# check that exists to catch hallucination. A caption printed in the
|
| 132 |
+
# document is verbatim and belongs in `text`; a description invented about
|
| 133 |
+
# the picture does not.
|
| 134 |
+
#
|
| 135 |
+
# Any consumer using this must treat it as a model claim about the image,
|
| 136 |
+
# never as evidence — it cannot be span-checked against anything.
|
| 137 |
+
generated_description: str | None = None
|
| 138 |
+
|
| 139 |
|
| 140 |
class ParsedDocument(BaseModel):
|
| 141 |
"""The artifact itself — one parsed document, self-describing.
|
|
@@ -105,12 +105,22 @@ def _teks_tabel(item: dict[str, Any], kosakata: frozenset[str] | None = None) ->
|
|
| 105 |
return "\n\n".join(b for b in bagian if b)
|
| 106 |
|
| 107 |
|
| 108 |
-
def
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
|
| 116 |
def normalisasi(
|
|
@@ -190,10 +200,15 @@ def normalisasi(
|
|
| 190 |
chunks.append(c)
|
| 191 |
continue
|
| 192 |
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
tutup()
|
| 195 |
-
c = buka(
|
| 196 |
-
c.text =
|
| 197 |
c.source_items = [i]
|
| 198 |
c.bbox = item.get("bbox")
|
| 199 |
if item.get("img_path"):
|
|
|
|
| 105 |
return "\n\n".join(b for b in bagian if b)
|
| 106 |
|
| 107 |
|
| 108 |
+
def _teks_gambar(item: dict[str, Any], jenis: str) -> tuple[str, str | None]:
|
| 109 |
+
"""(teks verbatim, deskripsi karangan model) untuk satu item chart/image.
|
| 110 |
+
|
| 111 |
+
`content` SENGAJA dipisahkan dari teks. Di backend `pipeline` field itu
|
| 112 |
+
selalu kosong, jadi selama ini tidak kelihatan — tapi `vlm` dan
|
| 113 |
+
`hybrid --effort high` mengisinya dengan deskripsi hasil analisis gambar.
|
| 114 |
+
|
| 115 |
+
Menaruhnya di `text` berarti kalimat karangan model ikut jadi jerami yang
|
| 116 |
+
ditelusuri span-check extraction, sehingga halusinasi lolos lewat
|
| 117 |
+
pemeriksaan yang justru dibuat untuk menangkapnya. Caption yang benar-benar
|
| 118 |
+
tercetak di dokumen tetap verbatim, dan tetap masuk `text`.
|
| 119 |
+
"""
|
| 120 |
+
bagian = list(item.get(f"{jenis}_caption") or [])
|
| 121 |
+
bagian += list(item.get(f"{jenis}_footnote") or [])
|
| 122 |
+
deskripsi = (item.get("content") or "").strip() or None
|
| 123 |
+
return "\n\n".join(b for b in bagian if b), deskripsi
|
| 124 |
|
| 125 |
|
| 126 |
def normalisasi(
|
|
|
|
| 200 |
chunks.append(c)
|
| 201 |
continue
|
| 202 |
|
| 203 |
+
# `image` berdampingan dengan `chart`: backend berbeda memberi label
|
| 204 |
+
# berbeda untuk gambar yang sama — `pipeline` menyebut chart apa yang
|
| 205 |
+
# `vlm` sebut image. Tanpa cabang ini item `image` jatuh ke jalur teks
|
| 206 |
+
# umum, yang cuma membaca `item["text"]`, dan gambar tidak punya field
|
| 207 |
+
# itu — jadi caption DAN deskripsinya hilang tanpa suara.
|
| 208 |
+
if tipe in {"chart", "image"}:
|
| 209 |
tutup()
|
| 210 |
+
c = buka(tipe, page)
|
| 211 |
+
c.text, c.generated_description = _teks_gambar(item, tipe)
|
| 212 |
c.source_items = [i]
|
| 213 |
c.bbox = item.get("bbox")
|
| 214 |
if item.get("img_path"):
|
|
@@ -164,6 +164,10 @@ def main() -> None:
|
|
| 164 |
p.add_argument("--backend", choices=["pipeline", "vlm", "hybrid"],
|
| 165 |
help="pipeline=CPU, vlm=butuh GPU (target resmi)")
|
| 166 |
p.add_argument("--lang", default=None)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
p.add_argument("--no-resume", action="store_true", help="proses ulang semua")
|
| 168 |
p.add_argument("--start-page", type=int, default=None)
|
| 169 |
p.add_argument("--end-page", type=int, default=None)
|
|
@@ -176,6 +180,8 @@ def main() -> None:
|
|
| 176 |
cfg.backend = a.backend
|
| 177 |
if a.lang:
|
| 178 |
cfg.lang = a.lang
|
|
|
|
|
|
|
| 179 |
if a.no_resume:
|
| 180 |
cfg.resume = False
|
| 181 |
if a.start_page is not None:
|
|
|
|
| 164 |
p.add_argument("--backend", choices=["pipeline", "vlm", "hybrid"],
|
| 165 |
help="pipeline=CPU, vlm=butuh GPU (target resmi)")
|
| 166 |
p.add_argument("--lang", default=None)
|
| 167 |
+
# Hanya berlaku untuk `hybrid`. Bukan sekadar cepat vs teliti: MinerU
|
| 168 |
+
# MEMATIKAN PAKSA analisis gambar di `medium` (`is_image_analysis_option_visible`),
|
| 169 |
+
# jadi deskripsi gambar cuma ada di `high` — dan selalu ada di `vlm`.
|
| 170 |
+
p.add_argument("--effort", choices=["medium", "high"], default=None)
|
| 171 |
p.add_argument("--no-resume", action="store_true", help="proses ulang semua")
|
| 172 |
p.add_argument("--start-page", type=int, default=None)
|
| 173 |
p.add_argument("--end-page", type=int, default=None)
|
|
|
|
| 180 |
cfg.backend = a.backend
|
| 181 |
if a.lang:
|
| 182 |
cfg.lang = a.lang
|
| 183 |
+
if a.effort:
|
| 184 |
+
cfg.effort = a.effort
|
| 185 |
if a.no_resume:
|
| 186 |
cfg.resume = False
|
| 187 |
if a.start_page is not None:
|