File size: 10,758 Bytes
f0cf168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d5ed50c
 
 
 
f0cf168
 
d5ed50c
 
 
b3eae0f
d5ed50c
 
 
 
 
 
b3eae0f
d5ed50c
 
b3eae0f
d5ed50c
b3eae0f
 
d5ed50c
 
 
b3eae0f
 
d5ed50c
b3eae0f
d5ed50c
 
 
 
b3eae0f
 
d5ed50c
b3eae0f
d5ed50c
 
 
 
 
 
 
 
 
b3eae0f
d5ed50c
f0cf168
 
b3eae0f
f0cf168
 
b3eae0f
 
d5ed50c
 
b3eae0f
d5ed50c
b3eae0f
d5ed50c
 
 
 
b3eae0f
d5ed50c
 
 
b3eae0f
d5ed50c
 
 
b3eae0f
d5ed50c
b3eae0f
d5ed50c
 
 
 
b3eae0f
d5ed50c
 
b3eae0f
d5ed50c
b3eae0f
d5ed50c
 
 
b3eae0f
 
 
 
 
d5ed50c
 
 
b3eae0f
d5ed50c
b3eae0f
d5ed50c
 
 
 
 
b3eae0f
d5ed50c
b3eae0f
d5ed50c
b3eae0f
 
 
d5ed50c
 
 
 
f0cf168
942f14e
 
 
 
7f47c61
942f14e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0cf168
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f1342ce
 
 
 
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
# 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.

## 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` β€” MinerU wrapper with a content-addressed parse cache
- `normalize.py` β€” MinerU's flat item list β†’ meaningful chunks
- `render.py` β€” LaTeX/HTML β†’ readable prose for `Chunk.text`
- `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

Default **`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.