File size: 12,441 Bytes
b68816f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7124acf
 
 
 
 
 
 
 
 
b68816f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# knowledge_parsing

Document parsing β€” the first half of the knowledge pipeline. PDF/DOCX in, a
versioned `ParsedDocument` out.

The second half (`src/knowledge_extraction/`) consumes that artifact and never
the parser itself, which is what keeps MinerU swappable: a Tesseract or Azure
Document Intelligence path emits the same artifact and extraction never learns
which one ran.

Additive and flag-gated. The existing unstructured path (`src/knowledge/`,
Tesseract OCR β†’ chunk β†’ pgvector) is untouched and keeps running as-is.

> **Current (2026-09-02) β€” the default parser is now Mistral OCR.** `--backend mistral` (the config
> default) is an API with **no GPU**: `run --backend mistral` produces the artifact in ~8 s. The
> MinerU backends (`hybrid`/`vlm`/`pipeline`, everything below about GPU) are now the **fallback** β€”
> both emit the identical `ParsedDocument`. Mistral needs `MISTRAL_API_KEY` (read from the repo
> `.env`); its normalizer is `normalize_mistral.py`, chunking **section**-level by default. Recall on
> the BUMA fixture: section **0.878** (`eval/parsing/README.md`).

> **2026-09-24 β€” `paddleocr` backend, v2 document processing only.** `paddle_ocr.py` calls
> Baidu AI Studio's PaddleOCR-VL job API (multipart submit β†’ poll `state` until `done` β†’
> JSONL `result.layoutParsingResults[]`, one per page); figures come back as hosted URLs and are
> downloaded at parse time into `markdown.images_base64`, so the cache is self-contained.
> `normalize_paddle.py` turns the layout blocks into the same `ParsedDocument`. Selected by the
> app setting `DOCUMENT_PARSER_BACKEND`; the default here and the CLI's `--backend` choices are
> unchanged, so offline knowledge runs never reach it. PDF and images only. Needs
> `PADDLEOCR_ACCESS_TOKEN`.

## Files

- `contracts.py` β€” **the seam.** `Chunk`, `ParsedDocument`, and the declared
  `Mention` / `TermRecord` handoff shapes. The one file both halves must agree on
- `config.py` β€” all settings, and the single place the GPU backend flips
- `parse.py` β€” parser wrapper (Mistral or MinerU) with a content-addressed parse cache
- `mistral_ocr.py` β€” the Mistral OCR API call (upload β†’ `ocr.process`); no GPU
- `normalize.py` β€” MinerU's flat item list β†’ meaningful chunks
- `normalize_mistral.py` β€” Mistral's block stream β†’ chunks (the Mistral counterpart of `normalize.py`)
- `render.py` β€” LaTeX/HTML β†’ readable prose for `Chunk.text` (shared by both normalizers)
- `checks.py` β€” quality warnings, including silent-corruption detection
- `manifest.py` / `report.py` β€” per-run record and throughput extrapolation
- `run.py` β€” batch CLI: one folder in, artifacts + manifest out

## Use

```bash
py scripts/parse.py                     # Windows
python3 scripts/parse.py                # Linux
py scripts/parse.py data/other          # a different folder
py scripts/parse.py --backend vlm       # force a specific backend
```

The script sets the env vars that are required and picks a backend. Calling the
module directly works too, but then those env vars are yours to set β€” see
`scripts/parse.py` for the list.

> The wrapper is **Python, not shell**, deliberately. Bash and PowerShell
> versions were written first and inside twenty minutes hit two traps that are
> specific to shells: CRLF line endings making bash fail on its very first line,
> and a trailing backslash on a path swallowing the argument AFTER it.
> `subprocess` takes a list of arguments and never goes through a shell, so
> neither is possible.

This is a development aid. **Production calls the module directly** β€” parsing is
an admin-triggered offline batch, not something a person runs from a terminal.

A successful run looks like this:

```
>> No GPU detected β€” falling back to pipeline
   (no image descriptions; multiplication copied as the letter 'x')
Run 20260826-131546 | backend=pipeline | 2 documents

[1/2] Maintenance Engineering Handbook (1)-1 … ok (cache) | 1 chunk
[2/2] STD_2026_006_MNO … ok 385.5s | 31 chunks | ⚠ 3 warnings

--- summary ---
  ok / failed     : 2 / 0
  total pages     : 10
  ⚠ quality warnings: 3 β€” see manifest.json
```

How to read it:

- **`ok (cache)`** means that document was not re-parsed. The cache key is
  document content + settings + MinerU version, so only what genuinely changed
  gets redone. Cached documents are **excluded** from the seconds/page average.
- **`⚠ n warnings` is NORMAL, not a failure.** The document is still
  processed. Warnings flag quality worth a human look β€” formulas with no
  numbers, empty tables, charts with no context, and most importantly **numbers
  that are absent from the source PDF**. Details land in `manifest.json`.
- **`ok / failed`** is the line to watch. Failed documents are recorded in full,
  with their traces, in `failures.jsonl`, and one failure does not stop the rest.

Throughput numbers and their extrapolation:

```bash
uv run --no-sync python -m src.knowledge_parsing.report --scale 6800
```

### Backend

> These are the **MinerU** backends β€” now the **fallback** (see the Mistral note at the top).
> They still apply whenever `--backend hybrid/vlm/pipeline` is chosen.

Among the MinerU backends the default is **`hybrid` + `effort=high`**, decided 2026-08-24 after all
four were measured on the same GPU over the same 29 pages:

| Backend | s/page | Image descriptions | Multiplication sign |
|---|---|---|---|
| `hybrid/high` ⭐ | 4.85 | **yes** | `\times` |
| `hybrid/medium` | 1.88 | force-disabled by MinerU | `\times` |
| `pipeline` | 2.67 | never | the letter `x` β€” **fuses tokens** |
| `vlm` | 6.96 | yes | `\times` |

`vlm` and `hybrid/high` produce **100% identical** output, but `vlm` is slower.
`pipeline` is the only one that copies the multiplication sign as a literal `x`,
which fuses `MOHH x Qty x PA` into a single token and takes five terms with it.

**Determinism: settled 2026-08-26.** Two genuine re-parses produced 0 differences
across 50 chunks β€” image descriptions included, character for character. Frozen
baselines do not drift. Parse *time* varies (model loading); the output does not.

### Running on a laptop with no GPU

`hybrid` and `vlm` need a GPU. Without one the script falls back to `pipeline` on
its own and says so. That output is **valid but lower quality** β€” no image
descriptions, and the multiplication sign copied as a letter. Do not use it as a
reference measurement.

Calling the module directly without `--backend` stops with a message naming the
command to use instead, rather than an opaque torch error.

### Installing MinerU

MinerU is an **extra**, not a core dependency β€” the agent service never parses a
document during a request, so the deployed Space does not carry it. Importing
this package does not import MinerU or torch; only `parse.py` does, when called.

```bash
UV_CACHE_DIR=D:/uv-cache uv pip install --python .venv mineru==3.4.4
```

⚠️ **Not `uv sync --extra knowledge-parsing`.** That fails: `mineru` needs
`python-docx>=1.2.0` while this project pins `1.1.2`. Locking it means raising
the shared pin, and that is a team decision β€” not taken yet.

So `mineru` is **absent from `uv.lock`**. Two consequences:

- **A plain `uv run` uninstalls MinerU** β€” it syncs the venv to the lock first.
  Use `uv run --no-sync`, this repo's standing convention (see CLAUDE.md);
  `scripts/parse.py` already does.
- If anyone runs `uv sync`, MinerU disappears. Reinstall with the command above;
  `scripts/parse.py` prints that command itself when it happens.

### Where the large files live

All kept off the nearly-full `C:` drive:

| | |
|---|---|
| Model weights | `D:/mineru-models/hf`, `D:/mineru-models/modelscope` |
| Temp files | `D:/mineru-models/tmp` |
| `uv` cache | `D:/uv-cache` |
| Parse cache + run output | `data/knowledge_cache/`, `data/knowledge_runs/` (gitignored) |

## Naming documents β€” a convention, and why it is temporary

**The file name is currently a primary key, not a label.** `doc_id` is the file
name stem; `chunk_id` is built from `doc_id`; and `term_id`, `formula_id`,
`rule_id` and `domain_id` are all digests of `doc_id` (`knowledge_extraction/ids.py`).
Renaming a file after ingestion therefore changes every identifier derived from
it, and an expert's approve/reject decisions are keyed to those identifiers.

Until that changes, name documents like this:

```
<DOC-CODE>_Rev<N.N>.pdf

STD-2026-006-MNO_Rev0.0.pdf
MEH-2019_Ch03.pdf
```

- **Stable prefix, varying suffix.** The doc code stays fixed across revisions so
  they can eventually be recognised as versions of one document; `_Rev1.0` is the
  part that moves, and it is machine-readable rather than only human-readable.
- **No spaces.** They force quoting in every command, and a trailing backslash on
  a quoted path has already swallowed an argument once in this repo.
- **Never `(1)` / `(2)`.** That suffix means "downloaded twice", not "version 2".
  Ingested as-is it becomes a second legitimate document with its own full set of
  identifiers. If the content is identical, delete one β€” `run.py` warns when two
  inputs share a hash. If the content genuinely differs, it is a revision: give it
  a real `_Rev<N>`.
- **Never rename after ingestion.**

The long human title is not lost by any of this β€” it stays inside the document,
and normally appears as the first chunk's `heading`.

### Why this is a stopgap, not a design

The convention holds only because *we* control the file names while parsing is an
admin-triggered batch. The moment this sits behind an upload endpoint β€” which is
planned, after eval and the playground β€” it stops holding: people will upload
`Copy of SOP final FIX (2).pdf`, and permanent knowledge-base identifiers should
never have depended on what someone typed into a Save dialog.

The fix is to stop deriving identity from the name. `ParsedDocument` already
carries `content_hash`, a SHA-256 of the file, so the identifier exists β€” it is
simply not the one being used:

```
doc_id        content_hash      key, stable, machine-facing
source_title  the file name     label, may change freely
```

That is a seam change (`doc_id` reaches `chunk_id`, `Provenance` and `ids.py`), so
it needs agreeing rather than doing. It is also cheapest now: the migration cost
rises with every document ingested and every expert decision recorded, because
re-keying orphans all of them. One consequence to handle when it lands β€”
`Provenance` currently shows `doc_id` to the reviewer, so a display title has to
travel with it or experts will be reading hashes.

`parse._mineru_name` exists for the same reason and is deliberately minimal: once
`doc_id` is a hash, every path is short by construction and that function is
deleted.

## Three things that are easy to get wrong

**`Chunk.text` must stay verbatim.** Extraction's guardrail locates quoted spans
literally inside it. Reflowing or normalising the text makes the lookup fail and
the field goes silently null β€” which presents as a bad model, not as a parser
bug.

**`page_idx` is 0-based**, exactly as MinerU reports it, with no conversion
anywhere in the pipeline. Converting to 1-based is the UI's job, done once at
display time, so the artifact always matches the raw MinerU output kept beside
it in the cache.

**Markup never goes in `text`.** The term filter is an NER model reading prose;
MinerU writes formulas character-spaced (`P u r c h a s i n g ~ c o s t s`) and
tables as HTML, and neither yields a single mention. Measured on one document and
gold set, changing only the parse: raw markup scored recall 0.7561 against 0.8537
for plain text; rendering it back recovered 0.8293. The markup is preserved in
`Chunk.latex` and `Chunk.table_html`, because the formula branch needs that form.

## Output layout

```
data/knowledge_cache/parse/<hash>-<config>-<version>/   untouched MinerU output
data/knowledge_runs/<run_id>/
β”œβ”€β”€ manifest.json     backend, versions, timings, quality warnings
β”œβ”€β”€ failures.jsonl    documents that failed, with traces
└── <doc>/chunks.json the artifact
```

Both are gitignored β€” they are data, not code.

See [`docs/knowledge/KNOWLEDGE_PIPELINE_TODO.md`](../../docs/knowledge/KNOWLEDGE_PIPELINE_TODO.md)
for the task breakdown and the seam discussion, and
[`docs/knowledge/KNOWLEDGE_PIPELINE_CALIBRATION.md`](../../docs/knowledge/KNOWLEDGE_PIPELINE_CALIBRATION.md)
Β§4 for the chunking constants this module follows.