The re-extract button ran a pipeline that could not read the page
TLDR: Our re-extract button sent every page through the local vector-geometry pipeline. For backend-OCR-extracted pages that pipeline had no substrate to read, so it produced a degraded page that dropped the images the OCR service had already extracted. The wrong assumption was that re-processing is an idempotent re-run. The fix was a per-page guard that stops the re-run before it starts.
The assumption that seemed reasonable
We added a backend OCR service for scanned documents. Extraction got dramatically better: real image regions, inline image data, layout zones. The re-extract button predated the backend by months and was built for the local pipeline. When the backend became primary for scanned documents, nobody changed the re-extract path. The assumption: re-extraction is a re-run of "the extractor," and the extractor is one thing.
When it failed
A user reported that after re-extraction, images that were extracted no longer showed in the HTML. Reproduced in a real browser with a scanned PDF: page 1 had 3 inline images before re-extract, 1 after, and the page markup was rewritten into a worse layout.
The mechanism was precise. The re-extract button posted a "reprocess" message to the geometry worker. The worker decides which reprocess handler runs by checking a cache keyed by page number. The local OCR bridge fills that cache when IT extracts a scanned page. The backend OCR service never does. So a backend-extracted page missed the cache, fell through to the vector reprocess handler, and that handler re-read the PDF looking for vector geometry that a scanned page does not have.
What was actually wrong
The cache did not store a page flag, and the reprocess dispatch used the cache as a proxy for "was this page extracted by the local OCR bridge." A page not in the cache meant "vector page," which is only true when the backend did not extract it. The backend path broke the proxy silently: its pages were in no cache and had no vector substrate, and the vector reprocess handler confidently produced garbage.
The deeper wrong: the reprocess path assumed every page has the substrate its pipeline needs. A scanned page's substrate is pixels, not vectors. Rendering it through the vector classifier does not fail loudly. It succeeds with a low-confidence, image-poor result.
What got deleted
No code was deleted. The behavior of the reprocess entry point was narrowed: for pages flagged scanned-without-local-OCR, the re-extract path now returns early, keeps the current page, and explains why. The worker still receives reprocess messages for vector pages and for local-OCR pages, whose cached synthetic inputs make a re-run valid.
What replaced it
A guard at the entry point, keyed on per-page flags produced by pre-flight classification:
if (pgInfo?.scanned && !pgInfo?.ocrLayer) {
// Backend-OCR page: the local pipeline cannot re-run it.
// Keep the page, explain, return.
}
The discriminator was chosen deliberately. scanned alone would also block the local OCR bridge, whose reprocess path is legitimate. ocrLayer distinguishes pages whose synthetic inputs are cached in the worker. Two flags, three behaviors, one rule: re-process only when the pipeline owns the inputs.
The generalizable lesson
A reprocess cache is an ownership record, not a performance cache. If the entry point dispatches on "is this page re-runnable by me," the dispatch must be based on which engine produced the page, not on a cache that only one engine fills. When a second engine joins an extraction pipeline, audit every dispatch that routes work by cache membership. The empty cache does not mean "not cached." It means "no one cached it," and the reason matters.