sofhiaazzhr Claude Opus 5 commited on
Commit
c113bf8
·
1 Parent(s): 6e78f1e

[NOTICKET] feat(knowledge-parsing): carry image descriptions, apart from the text

Browse files

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

src/knowledge_parsing/contracts.py CHANGED
@@ -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.2.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.
src/knowledge_parsing/normalize.py CHANGED
@@ -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 _teks_chart(item: dict[str, Any]) -> str:
109
- bagian = list(item.get("chart_caption") or [])
110
- if (item.get("content") or "").strip():
111
- bagian.append(item["content"])
112
- bagian += list(item.get("chart_footnote") or [])
113
- return "\n\n".join(b for b in bagian if b)
 
 
 
 
 
 
 
 
 
 
114
 
115
 
116
  def normalisasi(
@@ -190,10 +200,15 @@ def normalisasi(
190
  chunks.append(c)
191
  continue
192
 
193
- if tipe == "chart":
 
 
 
 
 
194
  tutup()
195
- c = buka("chart", page)
196
- c.text = _teks_chart(item)
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"):
src/knowledge_parsing/run.py CHANGED
@@ -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: