Repository navigation
Conversation
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>
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 andGEMINI_API_KEYare gone. The default model isqwen3-vl-235b-a22b-instruct, chosen in a bake-off (see evaluation.md). Structured output falls back fromjson_schematojson_objectto 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:
tablessample (cell neighbors and headings)multi_columnsample (reading order)multi_column, as scanstables, as scansold_print; Adobe 83.7%)/ActualTextremains 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.multi_columncategory (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.--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./Nno 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 byscripts/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.pykeeps each page's tagger inputs, andscripts/retag_bench.pyre-tags every page with other tagger code in about a minute. Adobe Auto-Tag and opendataloader-pdf are measured as peers. Ruff runs inscripts/check.shand in CI.Dependencies:
pypdfium2moves to the runtime dependencies (it was already in the image through Docling);scipy,rtree, and the never-usedtenacityare 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
https://tools.ailab.gc.cuny.edu/pdf-accessibility/, and its AI steps now call the CAIL Gateway instead of Google./v1/chat/completionswith the Lab's ownsk-cailkey. This is still the anonymous v1, so all spend lands on that key, as it does on the Gemini key today.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.main).LLM_BASE_URL,LLM_MODELandLLM_API_KEYin.envand deleteGEMINI_API_KEYandUSE_DIRECT_GEMINI_PDF. Without a realLLM_API_KEYthe app refuses to start.git pull && docker compose up -d --build./health/ready, then upload one real PDF through the public URL.df08f67plus doc commits) ran locally with a personal Gateway key and actual-dell's docling-serve. A two-page born-digital PDF uploaded through/api/jobsfinishedcomplete, veraPDF reported it compliant, the alt-text step made 2 Gateway calls (the model refusedjson_schemaand the step fell back tojson_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 throughrun_pipelinewith docling-serve 1.35 in the image built from this branch; the branch's results are a full run ofde2e819(GPU docling-serve), and its tagging layers were compared by re-tagging inputs captured from such runs../scripts/check.shpasses onde2e819(code;9de3605adds docs only): ruff is clean, 607 tests pass, and the frontend lints and builds.docker buildsucceeds and the image imports the changed modules.Known gaps, not addressed here
quota_exceeded) are still absorbed by the AI steps' fallbacks. This is plan step 2.3.🤖 Generated with Claude Code