Skip to content

Run every AI step through the CAIL Gateway and improve remediation quality - #12

Open
szweibel wants to merge 95 commits into
mainfrom
agent/cail-integration-plan
Open

szweibel wants to merge 95 commits into
mainfrom
agent/cail-integration-plan

Conversation

@szweibel

@szweibel szweibel commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Phase 2 of the CAIL integration plan: every AI step runs through the CAIL Gateway with an open-weight vision model, and the remediation fixes found with the evaluation suite come with it. Merging this and deploying it on NML gives v1 users these gains before the Worker version (v2) exists.

What changes

  • One model lane. Every AI step (alt text, title, front matter, table of contents, tables, forms, reading order) sends rendered page images to chat completions at LLM_BASE_URL (the Gateway). The direct Gemini path, its PDF input, the separate local-semantic lane and GEMINI_API_KEY are gone. The default model is qwen3-vl-235b-a22b-instruct, chosen in a bake-off (see evaluation.md). Structured output falls back from json_schema to json_object to plain JSON. A timed-out call is never repeated, because it may already have been paid for.

  • Remediation quality. Scored on what a screen reader that follows the tags hears, each marked-content sequence read as Chrome's PDF engine (pdfium) extracts text; AI steps off. Full tables in evaluation.md:

    Measure v1 This branch
    olmOCR-Bench tables sample (cell neighbors and headings) 10.4% 69.9%
    olmOCR-Bench multi_column sample (reading order) 41.6% 73.5%
    multi_column, as scans 51.6% 67.6%
    tables, as scans 23.0% 56.7%
    Printed books 1794–1925 (our old_print; Adobe 83.7%) 92.1% 93.9%
    Gold round trip, NID / TEDS / MHS-L 0.791 / 0.300 / 0.339 0.864 / 0.544 / 0.436
    CUNY round trip, NID / TEDS / MHS-L 0.877 / 0.512 / 0.350 0.963 / 0.683 / 0.397
    • One rule decides which element content belongs to. Each word goes to the element or table cell whose box holds it, the rule element text uses, judged in the direction the text runs. A text operator crossing elements is split, inside a string between glyphs where needed, as opendataloader-pdf does; the page renders identically. This replaced a per-page choice between Hungarian matching and run scoring (640 lines, and the scipy and rtree dependencies).
    • Elements are read from their own glyphs. /ActualText remains only for corrections, formulas, and composed accents, as the Tagged PDF Best Practice Guide advises; the element-level overlays are gone at a cost of at most 0.3 points on any whole olmOCR-Bench category.
    • The structure tree follows Docling's reading order, not drawing order: +3.5 points on the whole multi_column category (231 pages), no change on scans or CUNY documents, at a cost on the gold documents (NID 0.887 → 0.862) that are drawn in reading order already.
    • Text is recognized once. Docling reads the OCR step's text layer instead of recognizing scans again with RapidOCR (scanned multi-column sample 63.5% → 68.5%).
    • A paragraph continued across a page break is one structure element.
    • Text positions come from pdfminer's measurement of each text operator; Docling's boxes are moved into PDF user space before tagging (rotated pages and offset MediaBoxes were mistagged).
    • Element and table-cell text is the words pdfium reads inside the element's boxes, when they match Docling's letters.
    • Heading levels come from Docling's hierarchy stage, survive tagging, and sibling headings stay siblings.
    • Captions and table footnotes are tagged as text, not artifacts.
    • Pictured text on born-digital pages is OCR'd with --redo-ocr --pages, unless the page already carries an OCR layer; a picture page with no text in an otherwise born-digital document is OCR'd.
    • Every OCR line reaches the structure tree; language detection works (lingua is a dependency); ICC profiles that lack /N no longer break text extraction.
  • Optional Gateway OCR (OCR_ENGINE=gateway). It helps handwriting but not printed books, so Tesseract stays the default.

  • Evaluation corpus, harness and scorers. The corpus is defined in backend/eval/ and rebuilt byte for byte by scripts/fetch_eval_corpus.py: olmOCR-Bench samples and whole categories, a printed-book set with our own tests, gold and CUNY Academic Works round trips, and synthetic scans of each. Files that cannot be re-downloaded live in the private CUNY-AI-Lab/pdf-accessibility-eval-data. scripts/pipeline_bench.py keeps each page's tagger inputs, and scripts/retag_bench.py re-tags every page with other tagger code in about a minute. Adobe Auto-Tag and opendataloader-pdf are measured as peers. Ruff runs in scripts/check.sh and in CI.

  • Dependencies: pypdfium2 moves to the runtime dependencies (it was already in the image through Docling); scipy, rtree, and the never-used tenacity are dropped.

  • Merged from main: PR Bound docling-serve load from public readiness checks #11's readiness probe limit.

  • Docs: the plan records the 2026-10-04 decisions (actual-dell as the pipeline host, the Gateway token staying on Cloudflare) and the quality work done and next. The README's NML settings name the Gateway and actual-dell's docling-serve.

Cross-system record

  • Task and user action: someone uploads a PDF to https://tools.ailab.gc.cuny.edu/pdf-accessibility/, and its AI steps now call the CAIL Gateway instead of Google.
  • Caller → receiver: the PDF app on NML calls the CAIL Gateway /v1/chat/completions with the Lab's own sk-cail key. This is still the anonymous v1, so all spend lands on that key, as it does on the Gemini key today.
  • Deployment order:
    1. Get or mint the app's Gateway key.
    2. Upgrade actual-dell's docling-serve to the version the evaluation used: 1.35.0, with Docling 2.130. Production runs 1.12.0 with Docling 2.72. The branch asks for do_pdf_heading_hierarchy, and against 1.12 every heading came back H1. Every result in evaluation.md was measured on 1.35, the v1 column included, so v1 as deployed today already works against it.
    3. Merge this PR (CI on main).
    4. On NML, set LLM_BASE_URL, LLM_MODEL and LLM_API_KEY in .env and delete GEMINI_API_KEY and USE_DIRECT_GEMINI_PDF. Without a real LLM_API_KEY the app refuses to start.
    5. git pull && docker compose up -d --build.
    6. Check /health/ready, then upload one real PDF through the public URL.
  • Boundary exercised (2026-10-04): the image built from this branch (df08f67 plus doc commits) ran locally with a personal Gateway key and actual-dell's docling-serve. A two-page born-digital PDF uploaded through /api/jobs finished complete, veraPDF reported it compliant, the alt-text step made 2 Gateway calls (the model refused json_schema and the step fell back to json_object, as designed), the chart got Gateway-written alt text, the table was tagged with TH/TD, and title and language were set. The quality commits since change only the deterministic pipeline. Every pipeline result went through run_pipeline with docling-serve 1.35 in the image built from this branch; the branch's results are a full run of de2e819 (GPU docling-serve), and its tagging layers were compared by re-tagging inputs captured from such runs.
  • Local gate: ./scripts/check.sh passes on de2e819 (code; 9de3605 adds docs only): ruff is clean, 607 tests pass, and the frontend lints and builds. docker build succeeds and the image imports the changed modules.
  • Still missing: the deployed NML path with the Lab key, which needs the key and Stephen's go-ahead to deploy.

Known gaps, not addressed here

  • Credential refusals (401, 403, 429 quota_exceeded) are still absorbed by the AI steps' fallbacks. This is plan step 2.3.
  • Reading order is the weakest measure on multi-column and printed pages, and Docling's order misreads some layouts that well-made documents draw in order.
  • Word spaces the PDF makes by position are not written as space characters (ISO 32000-1, 14.8.2.5), so readers that do not infer spaces from geometry may run words together.
  • Manuscripts fell from 25.9% to 24.1%: Docling's RapidOCR read some handwriting better than Tesseract.

🤖 Generated with Claude Code

szweibel and others added 30 commits September 23, 2026 17:48
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Runs the app's own OCRmyPDF command in the production image on
olmOCR-Bench pages and records scores for production Tesseract, best
models, unpaper cleanup, and a Gateway vision model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OCRmyPDF draws each line as its own text object. Region matching pairs each
content region with at most one element, so a multi-line paragraph kept one
line and the rest were written as artifacts that screen readers skip. The
switch to per-run anchoring counted matched elements rather than tagged text,
so it never fired: on a typed 1914 letter, 138 recognized words became 54
heard.

- Use per-run anchoring whenever region matching would tag fewer text runs.
- Put an element's ActualText on the structure element, covering all of its
  fragments once, instead of on the first fragment's marked content, where
  the later fragments' glyphs were read a second time.
- Move the screen-reader text reader into app/services/structure_text.py and
  test both cases through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Runs Adobe PDF Services the way Acrobat Pro remediates a scan (OCR, then
Auto-Tag), scores the tagged output on what a screen reader hears, and
records both transactions in the existing local usage ledger. The credential
loader also reads a Developer Console project download; credentials live in
the git-ignored .secrets/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OCR_ENGINE=gateway loads app.pipeline.ocr_vlm_plugin into OCRmyPDF. Each page
image goes to OCR_MODEL (qwen3-vl-235b-a22b-instruct) over the LLM connection;
the model's line boxes become an OcrElement tree and OCRmyPDF renders the
invisible text layer as before, so structure, tagging and validation are
unchanged. Word boxes are spread over each line by length because the renderer
skips lines without words. Orientation and skew still come from Tesseract.
Gateway pages run eight at a time since they wait on the network, and a failed
call is never repeated.

The OCR bench retries failed pages on the next run instead of caching empty
text, and the pipeline runner can run a snapshot of the worktree and the
Gateway engine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Gateway call is repeated only after 429, 502, 503 or 504, when no inference
ran; any other failure (a timeout, an error status, an unreadable answer) gives
that page Tesseract's text instead of failing the whole OCR step. Answers are
capped at 16k tokens so no provider default cuts a dense page short.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ling-serve

screen_reader_text can count text inside a Figure that has no alt text (the
reading some screen readers give it) and keep Table tags as an HTML table, so
table structure can be scored against olmOCR-Bench's cell tests.

The pipeline bench now runs any subset in the prod image against a
docling-serve on the host, as production does, sharded across containers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without one, pdfminer decoded the bare Type1 font as raw CIDs once other tests
had run, so the tests passed alone and failed in the suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Printed and born-digital documents are the main case; handwriting is a side
case. Adds the old_print set, the Gateway OCR engine's OCR-stage score, and the
Adobe comparison on the 44 pages its free tier covered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Concurrent runs of one candidate on different subsets shared a snapshot that
each run deleted and recopied at start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The direct Gemini path fell back to LLM_API_KEY when GEMINI_API_KEY was unset.
With LLM_API_KEY holding a CAIL Gateway key, the title step sent that key to
Google's Gemini API. The Gemini path now uses GEMINI_API_KEY alone and is off
without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Following olmOCR's pipeline, an answer cut off at the token cap (usually a
repetition loop on dot leaders or tables) or with no readable lines is asked
for again at temperature 0.4, then 0.7, before the page falls back to
Tesseract. Lines are decoded one item at a time, so one malformed item loses
only that line. The cap drops to 12k tokens, an empty array is accepted for a
blank page, and token usage is logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_normalize_heading_hierarchy set any heading after an H1 to H1, and the first
heading is always H1, so every tagged document got a flat outline whatever
levels Docling or the LLM step inferred. Levels now only shift to start at H1
and close skipped levels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docling-serve now infers section-header levels (do_pdf_heading_hierarchy:
bookmarks, then section numbering, then font style); without it every heading
came back as level 1. On the PDF/UA reference book chapter, section headings
now get the reference's H2 and H3 levels.

The tagger shifts levels by the shallowest heading, not the first, so a deep
chapter label at the top no longer squeezes the section levels after it.

The screen-reader reader resolves role-mapped structure types and can render
the structure as Markdown (headings by level, list items, tables as HTML) for
structure benchmarks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pdfminer fails on a whole page when an ICCBased profile stream lacks its
required /N, which some real PDFs omit. The component count is restored from
the profile's own header: the screen-reader reader repairs a copy before
parsing, and the tagger repairs its output before saving, so the files we
produce are valid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lingua was an optional import that the production image never installed, so
detect_language always returned None: digital PDFs never had their language
detected from their text, and every document got the default /Lang. lingua is
now required and detects among the languages the app maps; the Norwegian and
Slovenian names now match lingua's (Bokmål is tagged no).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tagger's own content-stream reading guessed where text is: it ignored the
text matrix's scale on Td, font widths, and TJ offsets, and read TJ offsets as
text. On born-digital pages its runs landed far from the text they draw, and a
table row drawn by one TJ was tagged as one cell.

text_geometry measures every text-showing operator with pdfminer (full text
state, font widths, encodings) per content stream, and the tagger uses those
positions and that text for its runs and regions. A TJ whose glyphs fall in
more than one table cell or text element is split into consecutive TJs at
array element boundaries, which renders identically, so each cell is tagged
on its own. Tables keep Docling's cell boxes instead of an even grid, and a
page whose table cells have text takes the fragmented path, the only one that
fills cells.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reading (what the benchmarks score as heard):
- Marked content carrying /ActualText now reads as that text, as screen
  readers read it (ISO 32000-1, 14.9.4); the tagger writes element text there,
  so the reader had been hearing raw glyphs instead.
- MCIDs inside Form XObjects are keyed by the form, not the page.

Glyph measurement (app/pipeline/page_glyphs.py, replacing text_geometry.py):
- One pdfminer pass per page serves both the tagger and the reader.
- Character spacing (Tc) is kept at TJ element boundaries; drawing one element
  at a time had dropped it.
- Pages are read one at a time (bounded memory), a failure is confined to its
  page, and page labels, which pdfminer can fail on, are not computed.
- Forms are keyed by the names they are drawn under, so the ICC repair copy's
  renumbered objects no longer break the lookup.

Tagging:
- Splitting a TJ moves the passthrough regions computed before the split; the
  old indices dropped graphics state and wrapped OCR forms in artifacts.
- The region path counts only runs it tags as their own element, so a TJ split
  across two paragraphs no longer loses one of them.
- Dead branches and unused defaults removed from the fragmented-path gate.

Language: OCR uses a detected language only when its Tesseract pack is
installed; otherwise OCR failed outright.

Tests: glyph positions checked against pdfminer's own layout with Tc set;
rendering compared with PDFium (pypdfium2, now a dev dependency); OCR lines
checked to be tagged, never artifacts, and referenced once; shared fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A paragraph's struct-level /ActualText replaced its whole subtree, so a /Link
nested in it was never announced. Attaching a link now removes the parent's
ActualText; the paragraph reads from its glyphs, which are measured.

The reader joined consecutive marked content without a space, so one OCR
line ran into the next. It now applies the same word-break rule across marked
content as within it (a new line, or a gap wider than 0.15 em).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
szweibel and others added 13 commits October 5, 2026 11:58
An element has a glyph_text only when its glyphs account for its text,
and the readable form of Docling's text is applied where the text is
used, so one key carries one meaning instead of a key plus a trust flag.
Remove accounts_for's special case for a reference that read fake-bold
text twice: overdrawn glyphs are already dropped, and such an element
simply keeps Docling's text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scorer joined each marked content's glyphs into words with a fixed
0.15 em gap rule of our own. Screen readers get page text from a PDF
engine's text extraction instead, which infers spaces from the font's
own space width, expands ligatures, drops fake-bold duplicates, and
marks line-end hyphens. Read each marked-content sequence through pdfium
(Chrome's engine): every character carries the marked content of the
text object that drew it, /ActualText applies to the characters under
it, and a word break falls where pdfium inferred one or other text
intervenes. Forms are matched to pdfium's form objects in drawing order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Our glyph text (word breaks from a measured letter gap, fake-bold
removal, line-end dehyphenation, ligatures) reimplemented what a PDF
engine's text extraction does. pdfium, Chrome's engine, does it all and
agreed with poppler on 95.7% of 5,862 Docling elements against our
code's 91.8%. The tagger now reads pdfium's characters whose centers lie
in an element's (or fragment's, or table cell's) glyph boxes, joined the
same way the scorer reads marked content; element_text keeps only the
check against Docling's text and the rule that puts TeX's separate
accent glyphs on their letters, which pdfium leaves apart.

pypdfium2 moves from the dev extras to the runtime dependencies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pdfium already joins a word hyphenated at a line end within a run, so
each fragment but an element's last just ends with its word break.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Matches page_glyphs, so a repeated form's marked content is not read
twice; every form object still advances the pairing with Do operators.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pdfium's mark API does not decode a PDF text string reliably: a UTF-16
/ActualText came back as one character per byte, or with characters
past U+00FF dropped ("Fc_103–130" as "Fc_103130"). That corrupted every
candidate whose marked content carries /ActualText, v1 most. pdfium now
only tells which characters lie under /ActualText; the text comes from
page_glyphs' parse of the content stream, in order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured: carrying glyph text on each fragment's marked content (so a
Link nested in a paragraph is not hidden) and picking pdfium's
characters by glyph box both cost multi-column pages (68.9% to 62.1%,
then 60.7%): pdfium's boxes for spaces and ligature parts are
degenerate, so selection by box lost them. A paragraph whose /ActualText
a Link removes is now read from its glyphs as a PDF engine reads them,
so the fragment machinery is not needed.

Each text element and table cell now gets pdfium's get_text_bounded text
inside its boxes, the use measured to agree with poppler on 95.7% of
Docling elements, when it accounts for Docling's text. This removes the
fragment texts, the per-instruction and per-cell glyph plumbing, the
geometric glyph collection, and the character grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pdfium's bounded text keeps each character whose box meets the given
box, so a box that stops short of a word clips it. Docling's table cell
boxes on scans often do (every "Improvement" in one table ends at x=957.5,
its cells at 947.7 to 956.3), and the clipped text ("Improvemen",
"Alabama (199") was within the check against Docling's text, so scanned
tables lost cells. A word now belongs to each box it overlaps, as Docling
assigns words to cells and poppler selects text in an area: on that table
the selection matches Docling's letters for 94% of cells against 78%, and
on 5,862 born-digital elements it changes nothing the check accepts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A born-digital page whose images cover a quarter of it with little
native text was OCR'd with --redo-ocr, which strips invisible text and
recognizes the page again. On a press clipping that already carried an
OCR layer over its scanned article, Tesseract's text replaced a better
one ("piedadepedro(QOgmail.com", "políti- cas") and the clipping
service's banner became readable text. Such a page is no longer picked;
in the evaluation corpus it is the only selected page with invisible
text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Docling and docling-parse place everything on the displayed page: turned
by /Rotate, with the origin at the visible page box's corner. The tagger
compared those boxes with the content stream's glyphs and images,
annotation rectangles, and pdfium's text, all in PDF user space, with no
conversion. On a rotated page or one whose MediaBox does not start at
the origin, glyphs were assigned to the wrong elements and element text
was read from the wrong place. On 16 such documents in the corpus,
pdfium's text in a Docling box matched Docling's letters for 3% of 1,015
elements; in the box moved into user space, for 92%.

tag_pdf now works on copies of the elements, and of docling-parse's
lines, words, links, and widgets, with every box in user space, so each
comparison and the artifact /BBox it writes are in one frame. The
structure itself, whose boxes figure crops use, is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Taking every word a box overlaps pulled whole words of the next line or
column into an element whose box grazed them, and a word hyphenated at a
line end counted as one word on two lines. On a dense scanned page the
glyph text then failed the check against Docling's text for 44% of
elements, and Docling's own OCR text (words run together, "(d×1)") was
heard instead. A box now holds a word when at least half of the word's
box is inside it, as Docling assigns a word to the region holding the
largest share of it, and a line-end hyphen ends a word.

Glyph text accounts for Docling's on 79% of that page's elements (any
overlap: 56%, characters: 73%), on 93% of a scanned table's cells (95%,
94%), and on 97.3% of 5,862 born-digital elements (97.1%, 97.2%); it
agrees with poppler on 96.0% of those (95.8%, 95.7%).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
evaluation.md is rewritten around one table measured on 2026-10-05 with
the reader that reads marked content as pdfium does: v1, the September
branch, this branch, opendataloader-pdf, and Adobe. It records each
October change, the number rule tried and dropped, why scans' element text
is Tesseract's, the /ActualText decision with its measurements, the
opendataloader-pdf comparison, and Docling's own table scores (default and
TableFormer v2, born digital and scanned). The plan's Phase 7 lists what
is done and what is next, with reading order corrected: the branch now
orders printed pages better than Adobe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@szweibel szweibel changed the title Run every AI step through the CAIL Gateway; tag tables and reading order correctly Run every AI step through the CAIL Gateway and improve remediation quality Oct 6, 2026
szweibel and others added 16 commits October 6, 2026 15:30
Docling gives a paragraph that continues in the next column one box per
part, but the tagger assigned the page's text runs and content regions,
split text operators, and matched link annotations by each element's
first box only. The continuation's text belonged to no element and became
an artifact: on an A&A page, 48 of the 50 runs in a paragraph's second
column, so the paragraph stopped at "increasing the strength of" and a
screen reader never heard the rest. Every geometric match now uses all of
an element's boxes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Docling recognized every page again with its own RapidOCR, even after the
OCR step had given the page Tesseract's text layer, and where the two
disagreed its reading (words run together, "(d×1)") could be what a
screen reader heard. Docling now reads the text layer (do_ocr false), so
an element's text and the PDF's own text are the same. Scanned
multi-column pages: 63.5% → 68.5% of olmOCR-Bench tests; scanned tables
unchanged at 57.0%.

The OCR step is now the only one that recognizes text, so a document
whose pages are over 90% text but include a picture page with no text at
all (a scanned insert) is no longer treated as born digital: it is OCR'd,
and --skip-text recognizes just that page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tagger matched content to elements two ways, whole text objects one
to one by a weighted Hungarian assignment (refined by docling-parse
lines) or text runs scored one by one, and chose between them per page.
Now there is one path. Each text-showing unit (a TJ string, or a whole
Tj) belongs to the text element, artifact element, or table cell whose
box holds the middle of its height and at least half of its width, the
smallest when boxes nest (line numbers inside a paragraph's box); glyph
boxes run from the font's descent to its ascent, so height is judged by
the middle. TJs split where the owner changes. An image belongs to the
figure it overlaps by half the smaller of the two. Everything else that
paints is an artifact, a path object marked whole from construction to
paint, and OCR form calls pass through unmarked to be tagged inside the
form.

Running heads and footers keep their Pagination artifact type on every
page, a figure made of several images gets all of them, and tables no
longer leave structure elements pointing at marked content that was
never written. The tagger is 650 lines shorter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Structure elements joined /Document in the order their content is drawn,
so a screen reader followed the content stream, in whatever order its
producer wrote it, and Docling's reading order was never used. Each page's
new elements are now sorted by their position in Docling's element list
once the page's content is tagged; a list, table of contents, or table
takes the position of its first item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every text element and table cell carried its text as /ActualText, the
pdfium reading of its boxes or Docling's text, because the tagger's
assignment of glyphs to elements was not reliable enough to stand alone.
With one assignment rule, the glyphs tagged to an element are its text,
and the Tagged PDF Best Practice Guide asks that extractable text be
extracted rather than replaced. /ActualText remains where it says
something the glyphs cannot: a correction the structure or AI steps made
(a letter-spaced heading read as a word) and a formula's text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 60-page samples give confidence intervals 5 to 6 points wide, wider
than most changes being measured. The whole multi-column, headers and
footers, and tables categories (231, 266, and 188 pages; 2,666 tests) are
now subsets too, each with an image-only scan variant. Building a whole
category also gives its tests the subset's PDF paths, which before held
only because old_scans keeps its category name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The single path gave each text-showing unit (a TJ string or a Tj) to the
box holding it. A unit is often part of a word: a "%" drawn as its own
string just outside a table cell's box became an artifact while the cell
read "3", where element text, which takes whole words, read "3%". Words
are now formed from the measured glyphs in drawing order, broken at white
space, a line change, or a gap wider than a tenth of a glyph's size
(pdfminer's word_margin rule); each word goes to the box holding it, and
each unit to the owner of most of its words.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two defects showed once elements were read from their glyphs. A PDF that
draws each word space as a space glyph lost its spaces: white space had
no owner, so each space string became an artifact and the words around
it ran together ("participantswouldshift"). A space now goes with the
word before it. And an accent drawn as its own glyph after its letter
reads as "Joa˜o"; where the element's glyphs read differently from their
readable text, that text is the element's /ActualText, a correction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When one marked-content sequence ends with a space glyph and the next
starts right after it, the scorer joined the words: each sequence's text
drops its trailing space, and the break check looked only at spaces
pdfium infers. A space the PDF draws on either side of the boundary is a
word break, as Chrome, reading pdfium's characters, hears it. Elements
read through /ActualText hid this; elements read from their glyphs ran
words together ("participantswould").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Docling gives a paragraph that runs over a page break provenance boxes on
both pages, but the structure step kept only the first page's, so the
text on the next page belonged to no element and became an artifact. It
was heard only because the element's /ActualText carried Docling's whole
text; read from glyphs, half the paragraph was lost (a CUNY conference
paper's paragraph had 552 of its 1,094 characters in its box). The
structure step now adds a continuation element on each later page with
that page's boxes, sharing the element's structure element through
continued_ref, so the paragraph is one P with content on both pages.
Continuations are left out of heading levels and bookmarks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Some producers draw a table row as one string, spaces and all. The whole
string went to the first cell and the others were heard empty. The glyph
reader now records each glyph's byte span in its string, and the tagger
cuts a string there when the owner changes, as opendataloader-pdf does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A page drawn sideways and shown upright by /Rotate runs its text up the
page. Word breaks and the owner rule compared boxes as if every line ran
left to right, so each glyph of sideways text was a word of its own, and a
cell box stopping short of a word's last letters lost them. Each glyph now
records the direction of its text matrix, and boxes are turned to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
scipy and rtree served the Hungarian matching the single tagging path
replaced; tenacity was never used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pipeline_bench.py now keeps what the tagger was given for each page, and
retag_bench.py tags every page again with other tagger code in about a
minute, without OCR and Docling. A page whose tagged PDF the pipeline
rewrote afterwards (font repairs) keeps its last tagger call's inputs,
marked, since the rewrite is not replayed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
evaluation.md: every comparison re-measured under the current reader; the
branch's numbers from a full pipeline run of the head; the tagging layers
on olmOCR-Bench's whole categories; the reading-order trade-off on the gold
documents; what established tools do; re-tagging as a method and its limit.
The plan records the October 6–7 work and what comes next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant