fix(ocr): review follow-ups for #420 — agent seam, cost ceiling, cache key, sequential rescuers - #424

Merged
AndresL230 merged 5 commits into
worktree-gemini-vision-ocrfrom
fix/420-review-followups
Jul 28, 2026
Merged

fix(ocr): review follow-ups for #420 — agent seam, cost ceiling, cache key, sequential rescuers#424
AndresL230 merged 5 commits into
worktree-gemini-vision-ocrfrom
fix/420-review-followups

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Follow-ups from the review of #420. Targets worktree-gemini-vision-ocr so you can take them as a discrete diff rather than having your branch rewritten under you.

Two commits, disjoint file sets.

1. Route vision transcription through a Pydantic AI agent

The backend built a raw genai.Client and called generate_content directly. Three reasons that's wrong here, and the third is the one that matters:

The run is bounded by WORKER_LIMITS, matching the #329/#345 sweep: an unbounded run inside a per-page loop multiplies one runaway page across the whole document.

Latent bug this surfaced._extract_text_or_422 is sync but called from both async def handlers (routes/documents.py:640, :771), so a bare asyncio.run raises there — and _apply_gemini_vision_fallback's per-page except Exception: continue would have swallowed it. Vision OCR would have silently done nothing on the main upload path. _run_from_anywhere hands the coroutine to a worker thread when a loop is already running, copying the context so agent.override and the active span survive. Pinned by a test.

Module contract unchanged: same function name and signature, same GeminiVisionUnavailableError semantics, GEMINI_VISION_OCR_ENABLED still the switch.

2. Cache key, cost ceiling, sequential rescuers, accurate docs

  • Cache key omitted the model._ocr_cache_key claimed to include "every flag that changes the output" but not the vision model, so switching models kept serving the old transcription for the full 30-day TTL. Model and page cap now in the key, mixed in only when vision is enabled so vision-off deployments keep their entries. GOT_OCR_MODEL_PATH has the same pre-existing gap — the docstring now names it rather than overclaiming.
  • No cost ceiling. Nothing upstream bounds the page count: routes/extract.py allows min(max_pages, 50), the upload path has no rate limit at all, and /api/extract/pdf and /api/extract/image are unauthenticated, unbounded OCR endpoints with no rate limit #182's 10 req/60s was sized when a request meant one bounded local OCR run. GEMINI_VISION_OCR_MAX_PAGES caps it per document (default 10) and logs what it skipped.
  • if/elif made the rescuers mutually exclusive. Enabling both meant vision never ran — including on pages GOT-OCR failed to fill, recreating the exact "signal computed then dropped" bug this feature exists to fix. They now run in sequence, GOT-OCR first (local, free), then vision over what it couldn't fill. GOT-OCR's gate is byte-for-byte unchanged.
  • Three false claims..env.example said an unreadable scan "is rejected" — not on this base; the upload paths convert only extraction exceptions to 422, so "" reaches the classify prompt and the model fabricates. That rejection is fix(documents): reject empty text extraction instead of fabricating a summary #419, still open. The module docstring said vision works on "any engine"; it needs Docling to have run and succeeded. And the cache comment cited ADR 0019 (actually the SAPLING_MODEL_MODE test seam) for content-addressed chunk ids, whose dedup claim is untrue on main and becomes true only under fix(rag): content-hash chunk ids — dedup identical uploads per course #352.

Verification

1099 passed, 23 skipped on the full backend suite; ruff check clean. The one error (test_ocr_pipeline.py::test_save_to_db, event-loop teardown in a live-network test) is pre-existing — I reproduced it identically on unmodified fe1fce6.

Worth your call

  • Page cap default of 10 truncates your motivating document (the 13-page handwritten exam gets 10 pages transcribed, 3 left as Docling had them, with a warning). 20 matches routes/documents.py's max_pages if you'd rather nothing truncates in the default window — at the cost of a 2x worse ceiling on /api/extract.
  • Empty model response → "" is now implemented by catching UnexpectedModelBehavior, since output_type=str makes pydantic-ai retry once then raise. Same document-level behaviour as before, but it does swallow that exception class. Quota and network errors still propagate.
  • The prompt moved to system_prompt with the image as the user message — a different wire shape than contents=[image, prompt]. Worth one live-model spot check before enabling in prod.

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 27, 2026 17:28
The vision OCR call built a raw genai.Client and invoked generate_content
directly. Three reasons that is wrong here, the third load-bearing:
- CLAUDE.md: new LLM-driven code belongs in backend/agents/ as a Pydantic
AI agent, not a fresh client.
- ADR-0008 made agents/_providers.py::model_for(task) the one place a model
is chosen. GEMINI_VISION_OCR_MODEL was a competing knob that bypassed it;
the slot is now SAPLING_MODEL_OCR_VISION like every other agent's.
- Cost attribution. Logfire's instrument_pydantic_ai() tags every
pydantic-ai span with tokens and USD; a raw client call is invisible to
it and to the usage capture #118/PR #375 is building. Vision OCR is one
metered call per scanned page — plausibly the largest per-document LLM
spend in the app, and it would have been the one call the new cost
dashboard could not see.
The run is bounded by WORKER_LIMITS: it sits in a per-page loop, where an
unbounded run multiplies a single runaway page across the whole document.
Also fixes a latent bug this refactor surfaced. _extract_text_or_422 is
sync but called from both async handlers (routes/documents.py:640, :771),
so a bare asyncio.run raises there — and _apply_gemini_vision_fallback's
per-page `except Exception: continue` would have swallowed it, silently
turning vision OCR into a no-op on the main upload path. _run_from_anywhere
hands the coroutine to a worker thread when a loop is already running,
copying the context so agent.override and the active span survive.
The module contract is unchanged: same function name and signature, same
GeminiVisionUnavailableError semantics, GEMINI_VISION_OCR_ENABLED still
the switch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four findings from the review of #420.
Cache key omitted the model. _ocr_cache_key claimed to include "every flag
that changes the output" but not the vision model, so switching models kept
serving the old transcription for the full 30-day TTL. Model and page cap
are now in the key, mixed in only when vision is enabled so the vision-off
majority keeps its existing entries. GOT_OCR_MODEL_PATH has the same
pre-existing gap; the docstring now names it instead of overclaiming.
No cost ceiling. Each flagged page is one metered call, and nothing upstream
bounds the count: routes/extract.py allows min(max_pages, 50) and the upload
path has no rate limit at all. The #182 limit (10 req/60s) was sized when a
request meant one bounded local OCR run. GEMINI_VISION_OCR_MAX_PAGES caps it
per document, default 10, and logs how many pages it left behind — a silent
cap reads downstream as a full transcription.
if/elif made the rescuers mutually exclusive. Enabling both meant vision
never ran, including on pages GOT-OCR failed to fill, recreating the exact
"signal computed then dropped" bug this feature exists to fix. They now run
in sequence — GOT-OCR first (local, free), then vision over what it could
not fill. Both share one driver; GOT-OCR's gate is byte-for-byte unchanged.
Three false claims. .env.example said an unreadable scan "is rejected" — it
is not on this base; the upload paths convert only extraction *exceptions*
to 422, so "" reaches the classify prompt and the model fabricates. That
rejection is PR #419, still open. The module docstring said vision applies
to "any engine"; it needs Docling to have run and succeeded. And the cache
comment cited ADR 0019 (actually the SAPLING_MODEL_MODE test seam) for
content-addressed chunk ids, whose dedup claim is untrue on main and becomes
true only under PR #352.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • ^production$
  • ^staging$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7a66d72b-88bc-452f-8d5d-604f038e3c76

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/420-review-followups

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging6a6c555Commit Preview URL

Branch Preview URL
Jul 28 2026, 03:57 AM

The CI pytest step ignored four files. Three genuinely need what
requirements.lock deliberately excludes: transformers
(test_extraction_backends), docling (test_docling_integration), live
network (test_ocr_pipeline).
test_extraction_service.py needs none of them — it stubs every backend it
exercises. It was swept into the list with its heavy neighbours, and the
consequence is that nothing in it has ever gated a PR: not the OCR engine
gating, not the content-addressed cache key (#97), and not the cost ceiling
and rescuer sequencing added alongside this change. #420's own fallback and
cache-key tests were ungated for the same reason.
Verified against the locked (non-OCR) dependency set CI actually installs,
using CI's exact command and env: 1069 passed, 23 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Locked-deps verification

Ran the branch against a throwaway venv built from requirements.lock — the same hash-pinned, non-OCR set CI installs (pydantic-ai-slim 1.107.0, google-genai 2.9.0, fastapi 0.138.0), not the local dev venv, which is 18 minor versions behind on pydantic-ai and a full major behind on google-genai.

The pydantic-ai API surface held.BinaryContent, GoogleModelSettings timeout conversion, usage_limits, and the UnexpectedModelBehavior-on-empty-output path all behave the same at 1.107.0 as at 1.89.1. That was the risk I expected to find and did not.

What it did surface:tests/test_extraction_service.py was on CI's --ignore list, so nothing in it has ever gated a PR — not the OCR engine gating, not the content-addressed cache key (#97), and not #420's own TestGeminiVisionFallback / TestOcrCacheKey. It was swept in with three neighbours that genuinely need excluded deps (transformers / docling / live network); it stubs every backend it exercises and needs none of them. Dropped from the ignore list in 283fceb.

Verified with CI's exact command and env on the locked set: 1069 passed, 23 skipped, 0 failed.

The 4 test_extraction_backends.py failures under locked deps are ModuleNotFoundError: No module named 'transformers', reproduce identically on unmodified fe1fce6, and stay excluded — legitimately.

Still unverified

No real Gemini vision call has been made anywhere. The prompt reshape (system_prompt + image-only user message vs the old contents=[image, prompt]) is untested against a live model, and there's no scanned-PDF fixture in the repo to exercise the path end to end — tests/fixtures/sample_syllabus.pdf is a text PDF that never populates fallback_pages.

Caught by the first real Gemini call anyone has made against this feature.
Moving the instruction to `system_prompt` during the agent refactor changed
what the model produces. Measured on a rasterized syllabus with known ground
truth (231 chars of source text, 0-char text layer):
prompt as system_prompt -> 743 chars: \documentclass{article}, five
\usepackage lines, \begin{document}, a
tabular, \end{document}
prompt in the user turn -> 359 chars: clean Markdown table
Both transcribe the facts correctly — every assignment, date and type
matches. The difference is that as a system prompt, "Use LaTeX for
mathematics" reads as a document-format directive rather than an instruction
about math notation, so the model emits a whole LaTeX file.
The preamble is not cosmetic. extracted_text feeds the classify, summary and
concept prompts and is chunked into course_chunks for RAG, so "amsmath" and
"booktabs" become candidate concepts on a graph shared by every student in
the course — the same pollution this feature exists to prevent, arriving by a
different door.
Restores the wire shape the original raw-client implementation used
(contents=[image, prompt]), verified to produce 358 chars of clean Markdown
on the same fixture. The agent seam, the ADR-0008 model slot and the cost
attribution are all unaffected — only the placement changes.
The test now pins placement in the user turn and asserts the instruction is
absent from any system prompt. Revert-proof: reintroducing system_prompt
fails it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

First real Gemini call found a regression I introduced

Built a scanned-PDF fixture by rasterizing tests/fixtures/sample_syllabus.pdf — image-only, 0-char text layer, so it actually populates fallback_pages — while keeping the original's 231 chars of text as known ground truth.

Then made one real vision call. The facts came back perfect: every assignment, due date and type matched. But the shape did not.

wire shapeoutput
prompt as system_prompt (this PR, before 9252497)743 chars\documentclass{article}, five \usepackage lines, \begin{document}, tabular, \end{document}
prompt in the user turn (original raw-client shape)358 chars — clean Markdown table
prompt in the user turn (after 9252497)359 chars — clean Markdown table

As a system prompt, "Use LaTeX for mathematics" reads as a document-format directive rather than an instruction about math notation, so the model emits an entire LaTeX file. I introduced this in the agent refactor; the original raw-client implementation did not have it.

Why the preamble matters:extracted_text feeds the classify, summary and concept prompts and is chunked into course_chunks for RAG. Ship it and amsmath, booktabs and graphicx become candidate concepts on a graph shared by every student in the course — the same pollution this feature exists to prevent, arriving through a different door.

Fixed in 9252497 by restoring [image, prompt] in the user turn. The agent seam, the ADR-0008 model slot and the cost attribution are untouched — only placement changed. The test now pins placement and asserts the instruction is absent from any system prompt; I verified it's revert-proof by reintroducing system_prompt and watching it fail.

62 passed on locked deps, ruff clean.

This is the concrete argument for keeping a scanned-PDF fixture in the repo — the entire unit suite passed against this bug, because a FunctionModel will happily return whatever you tell it to.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Live stack results — including a correction

Ran the real thing: local Supabase (main's scripts/local-up.sh), backend on :5000, session via POST /api/auth/test-login as the seeded rich-user-active, real POST /api/documents/upload/sync.

Confirmed working

  • The async seam. A real Gemini vision call from inside a running event loop returns 358 chars of clean Markdown. This is the exact path _run_from_anywhere exists for — asyncio.run would have raised here and the per-page except Exception: continue would have swallowed it. Real model, real loop, no mocks.
  • No LaTeX preamble in that output, confirming 9252497 holds against the live model.
  • The upload path is unbroken on this branch: the scanned PDF classified as syllabus with an accurate summary and four correct concepts (Lab 1: Loops, Lab 2: Recursion, Midterm Project, Final Exam).

Correction: that upload did NOT exercise vision OCR

Docling ships RapidOCR and read the rasterized page itself — 341 chars, fallback_pages = []. The correct summary and concepts came from Docling, not Gemini. My fixture was too easy.

Worth knowing precisely when this feature actually spends money. docling_backend.py:104 flags a page when:

ifchar_count<LOW_CHAR_THRESHOLDormath_flag: # threshold = 40

So vision fires only when RapidOCR also fails (<40 chars) or the page has math-shaped content that isn't LaTeX. That matches #419's evidence, where all 13 pages of a handwritten linear-algebra exam were flagged — RapidOCR handles clean rasterized text fine and fails on handwriting. docling>=2.15,<3 is in requirements.txt, so RapidOCR is present in prod too.

Consequence: a plain scanned text PDF costs nothing, which is the intended design — but it also means the fixture needed to regression-test this path must contain handwriting or unLaTeXed math, not just a missing text layer.

Still open

No end-to-end run has pushed a document through the vision branch of the pipeline. Both halves are proven independently (real model + real loop; Docling flagging + rescue sequencing under unit test), but not composed. That needs a genuinely hard scan as a fixture.

…d loop
Found by the live test added here, which is the only thing that could have
found it: every other test in this feature substitutes the model, and a
FunctionModel has no client and no event loop.
Measured against the live API, calling the seam four times in one process:
call 1: OK 302 chars
call 2: RuntimeError: Event loop is closed
call 3: OK 308 chars
call 4: RuntimeError: Event loop is closed
`_providers._provider` is a module-level GoogleProvider, so its async httpx
client binds to the first loop `asyncio.run` creates and dies when that loop
closes. Every `run_agent_sync` caller shares this — it is #354, and the sweep
is still open in PR #358.
Transcription is the only caller that runs in a LOOP, which turns a latent bug
into an unusable feature: a 10-page scan alternates success and failure page by
page, and `_apply_gemini_vision_fallback`'s per-page `except Exception:
continue` keeps Docling's text without a word. Half a document silently
degrades to the mangled OCR this feature exists to replace.
So this path does not wait for #358. `fresh_ocr_vision_model()` builds a
provider per run and is passed as a per-run `model=` override, leaving the
shared `_provider` untouched so it cannot conflict with whatever #358 lands. It
returns None outside SAPLING_MODEL_MODE=real, where the FunctionModel has no
loop affinity and must not be overridden. Four consecutive live calls now pass.
The fixture is an image-only math worksheet. A missing text layer alone is not
enough to reach vision — Docling ships RapidOCR and reads rasterized prose
fine. This page is reached because `_detect_math_without_latex` flags
math-shaped content carrying no LaTeX, the scanned-math case the feature is
for. Docling alone drops problem 3 entirely as `<!-- formula-not-decoded -->`;
with vision it comes back as `$\sqrt{x^2 + 16} \leq 5$`.
Tests live in the `live_llm` lane, not tests/integration/: they need Docling and
a real model, not Postgres, and that lane's conftest mandates a running
Supabase stack. Opt-in via RUN_LIVE_OCR=1 plus a real key; skipped otherwise, so
CI's dummy key is a clean skip. One test guards the premise and fails loudly if
Docling ever stops flagging the fixture, since the other two would then pass
vacuously.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

The live test found a second, worse bug

Ran the composed path for real. It works — and in doing so it exposed something no mocked test could reach.

Calling the vision seam four times in one process:

call 1: OK 302 chars
call 2: RuntimeError: Event loop is closed
call 3: OK 308 chars
call 4: RuntimeError: Event loop is closed

_providers._provider is a module-level GoogleProvider; its async httpx client binds to the first loop asyncio.run creates and dies with it. This is #354 — every run_agent_sync caller shares it (study_guide, course_context_service, flashcard_import_service), and the sweep is still open in #358, not on main.

Transcription is the only caller that runs in a loop, which turns a latent bug into an unusable feature: a 10-page scan alternates success/failure page by page, and _apply_gemini_vision_fallback's per-page except Exception: continue keeps Docling's text silently. Half a document degrades back to the mangled OCR this feature exists to replace, with nothing logged.

Fixed in 6a6c555 with a per-run provider passed as a model= override — scoped to this agent, leaves the shared _provider alone, so it can't conflict with whatever #358 lands. Four consecutive live calls now pass.

The composed path, verified

Same code, same fixture, only the flag differs:

vision OFFvision ON
chars230302
problem 3 (√)dropped<!-- formula-not-decoded -->recovered$\sqrt{x^2 + 16} \leq 5$
∫x^2∫x~2 dxfrom 0 to 3$\int x^2 dx$
∂f/∂xaf/ax$\partial f/\partial x$

Docling losing an entire problem is the point — content it drops never reaches the concept prompt, so it never reaches the student's graph.

A full upload through the async handler classified it assignment, summarized all four problems including the dropped one, and produced concepts with real LaTeX and no \documentclass wrapper.

About the fixture

A missing text layer is not enough to reach vision. Docling ships RapidOCR and reads rasterized prose perfectly — my first fixture (a rasterized syllabus) came back clean with fallback_pages = [], so vision never ran. The committed fixture is reached because _detect_math_without_latex flags math-shaped content carrying no LaTeX, which is the actual scanned/handwritten-math case. Regenerate with tests/fixtures/make_scanned_math_ps4.py.

Tests are in the live_llm lane, not tests/integration/ — they need Docling and a real model, not Postgres, and that lane's conftest mandates a running Supabase stack. Opt-in via RUN_LIVE_OCR=1 plus a real key, so CI's dummy key is a clean skip. One test guards the premise and fails loudly if Docling stops flagging the fixture, since the other two would otherwise pass vacuously.

Verification: live lane 3 passed; full suite on locked deps 1069 passed, 26 skipped, 0 failed; ruff clean.

@AndresL230
AndresL230 merged commit 6a6c555 into worktree-gemini-vision-ocrJul 28, 2026
4 checks passed
@AndresL230
AndresL230 deleted the fix/420-review-followups branch July 28, 2026 03:53
Sign up for freeto 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

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(ocr): review follow-ups for #420 — agent seam, cost ceiling, cache key, sequential rescuers - #424

Merged
AndresL230 merged 5 commits into
worktree-gemini-vision-ocrfrom
fix/420-review-followups
Jul 28, 2026
Merged

fix(ocr): review follow-ups for #420 — agent seam, cost ceiling, cache key, sequential rescuers#424
AndresL230 merged 5 commits into
worktree-gemini-vision-ocrfrom
fix/420-review-followups

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Follow-ups from the review of #420. Targets worktree-gemini-vision-ocr so you can take them as a discrete diff rather than having your branch rewritten under you.

Two commits, disjoint file sets.

1. Route vision transcription through a Pydantic AI agent

The backend built a raw genai.Client and called generate_content directly. Three reasons that's wrong here, and the third is the one that matters:

The run is bounded by WORKER_LIMITS, matching the #329/#345 sweep: an unbounded run inside a per-page loop multiplies one runaway page across the whole document.

Latent bug this surfaced._extract_text_or_422 is sync but called from both async def handlers (routes/documents.py:640, :771), so a bare asyncio.run raises there — and _apply_gemini_vision_fallback's per-page except Exception: continue would have swallowed it. Vision OCR would have silently done nothing on the main upload path. _run_from_anywhere hands the coroutine to a worker thread when a loop is already running, copying the context so agent.override and the active span survive. Pinned by a test.

Module contract unchanged: same function name and signature, same GeminiVisionUnavailableError semantics, GEMINI_VISION_OCR_ENABLED still the switch.

2. Cache key, cost ceiling, sequential rescuers, accurate docs

  • Cache key omitted the model._ocr_cache_key claimed to include "every flag that changes the output" but not the vision model, so switching models kept serving the old transcription for the full 30-day TTL. Model and page cap now in the key, mixed in only when vision is enabled so vision-off deployments keep their entries. GOT_OCR_MODEL_PATH has the same pre-existing gap — the docstring now names it rather than overclaiming.
  • No cost ceiling. Nothing upstream bounds the page count: routes/extract.py allows min(max_pages, 50), the upload path has no rate limit at all, and /api/extract/pdf and /api/extract/image are unauthenticated, unbounded OCR endpoints with no rate limit #182's 10 req/60s was sized when a request meant one bounded local OCR run. GEMINI_VISION_OCR_MAX_PAGES caps it per document (default 10) and logs what it skipped.
  • if/elif made the rescuers mutually exclusive. Enabling both meant vision never ran — including on pages GOT-OCR failed to fill, recreating the exact "signal computed then dropped" bug this feature exists to fix. They now run in sequence, GOT-OCR first (local, free), then vision over what it couldn't fill. GOT-OCR's gate is byte-for-byte unchanged.
  • Three false claims..env.example said an unreadable scan "is rejected" — not on this base; the upload paths convert only extraction exceptions to 422, so "" reaches the classify prompt and the model fabricates. That rejection is fix(documents): reject empty text extraction instead of fabricating a summary #419, still open. The module docstring said vision works on "any engine"; it needs Docling to have run and succeeded. And the cache comment cited ADR 0019 (actually the SAPLING_MODEL_MODE test seam) for content-addressed chunk ids, whose dedup claim is untrue on main and becomes true only under fix(rag): content-hash chunk ids — dedup identical uploads per course #352.

Verification

1099 passed, 23 skipped on the full backend suite; ruff check clean. The one error (test_ocr_pipeline.py::test_save_to_db, event-loop teardown in a live-network test) is pre-existing — I reproduced it identically on unmodified fe1fce6.

Worth your call

  • Page cap default of 10 truncates your motivating document (the 13-page handwritten exam gets 10 pages transcribed, 3 left as Docling had them, with a warning). 20 matches routes/documents.py's max_pages if you'd rather nothing truncates in the default window — at the cost of a 2x worse ceiling on /api/extract.
  • Empty model response → "" is now implemented by catching UnexpectedModelBehavior, since output_type=str makes pydantic-ai retry once then raise. Same document-level behaviour as before, but it does swallow that exception class. Quota and network errors still propagate.
  • The prompt moved to system_prompt with the image as the user message — a different wire shape than contents=[image, prompt]. Worth one live-model spot check before enabling in prod.

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 27, 2026 17:28
The vision OCR call built a raw genai.Client and invoked generate_content
directly. Three reasons that is wrong here, the third load-bearing:
- CLAUDE.md: new LLM-driven code belongs in backend/agents/ as a Pydantic
AI agent, not a fresh client.
- ADR-0008 made agents/_providers.py::model_for(task) the one place a model
is chosen. GEMINI_VISION_OCR_MODEL was a competing knob that bypassed it;
the slot is now SAPLING_MODEL_OCR_VISION like every other agent's.
- Cost attribution. Logfire's instrument_pydantic_ai() tags every
pydantic-ai span with tokens and USD; a raw client call is invisible to
it and to the usage capture #118/PR #375 is building. Vision OCR is one
metered call per scanned page — plausibly the largest per-document LLM
spend in the app, and it would have been the one call the new cost
dashboard could not see.
The run is bounded by WORKER_LIMITS: it sits in a per-page loop, where an
unbounded run multiplies a single runaway page across the whole document.
Also fixes a latent bug this refactor surfaced. _extract_text_or_422 is
sync but called from both async handlers (routes/documents.py:640, :771),
so a bare asyncio.run raises there — and _apply_gemini_vision_fallback's
per-page `except Exception: continue` would have swallowed it, silently
turning vision OCR into a no-op on the main upload path. _run_from_anywhere
hands the coroutine to a worker thread when a loop is already running,
copying the context so agent.override and the active span survive.
The module contract is unchanged: same function name and signature, same
GeminiVisionUnavailableError semantics, GEMINI_VISION_OCR_ENABLED still
the switch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four findings from the review of #420.
Cache key omitted the model. _ocr_cache_key claimed to include "every flag
that changes the output" but not the vision model, so switching models kept
serving the old transcription for the full 30-day TTL. Model and page cap
are now in the key, mixed in only when vision is enabled so the vision-off
majority keeps its existing entries. GOT_OCR_MODEL_PATH has the same
pre-existing gap; the docstring now names it instead of overclaiming.
No cost ceiling. Each flagged page is one metered call, and nothing upstream
bounds the count: routes/extract.py allows min(max_pages, 50) and the upload
path has no rate limit at all. The #182 limit (10 req/60s) was sized when a
request meant one bounded local OCR run. GEMINI_VISION_OCR_MAX_PAGES caps it
per document, default 10, and logs how many pages it left behind — a silent
cap reads downstream as a full transcription.
if/elif made the rescuers mutually exclusive. Enabling both meant vision
never ran, including on pages GOT-OCR failed to fill, recreating the exact
"signal computed then dropped" bug this feature exists to fix. They now run
in sequence — GOT-OCR first (local, free), then vision over what it could
not fill. Both share one driver; GOT-OCR's gate is byte-for-byte unchanged.
Three false claims. .env.example said an unreadable scan "is rejected" — it
is not on this base; the upload paths convert only extraction *exceptions*
to 422, so "" reaches the classify prompt and the model fabricates. That
rejection is PR #419, still open. The module docstring said vision applies
to "any engine"; it needs Docling to have run and succeeded. And the cache
comment cited ADR 0019 (actually the SAPLING_MODEL_MODE test seam) for
content-addressed chunk ids, whose dedup claim is untrue on main and becomes
true only under PR #352.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • ^production$
  • ^staging$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7a66d72b-88bc-452f-8d5d-604f038e3c76

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/420-review-followups

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging6a6c555Commit Preview URL

Branch Preview URL
Jul 28 2026, 03:57 AM

The CI pytest step ignored four files. Three genuinely need what
requirements.lock deliberately excludes: transformers
(test_extraction_backends), docling (test_docling_integration), live
network (test_ocr_pipeline).
test_extraction_service.py needs none of them — it stubs every backend it
exercises. It was swept into the list with its heavy neighbours, and the
consequence is that nothing in it has ever gated a PR: not the OCR engine
gating, not the content-addressed cache key (#97), and not the cost ceiling
and rescuer sequencing added alongside this change. #420's own fallback and
cache-key tests were ungated for the same reason.
Verified against the locked (non-OCR) dependency set CI actually installs,
using CI's exact command and env: 1069 passed, 23 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Locked-deps verification

Ran the branch against a throwaway venv built from requirements.lock — the same hash-pinned, non-OCR set CI installs (pydantic-ai-slim 1.107.0, google-genai 2.9.0, fastapi 0.138.0), not the local dev venv, which is 18 minor versions behind on pydantic-ai and a full major behind on google-genai.

The pydantic-ai API surface held.BinaryContent, GoogleModelSettings timeout conversion, usage_limits, and the UnexpectedModelBehavior-on-empty-output path all behave the same at 1.107.0 as at 1.89.1. That was the risk I expected to find and did not.

What it did surface:tests/test_extraction_service.py was on CI's --ignore list, so nothing in it has ever gated a PR — not the OCR engine gating, not the content-addressed cache key (#97), and not #420's own TestGeminiVisionFallback / TestOcrCacheKey. It was swept in with three neighbours that genuinely need excluded deps (transformers / docling / live network); it stubs every backend it exercises and needs none of them. Dropped from the ignore list in 283fceb.

Verified with CI's exact command and env on the locked set: 1069 passed, 23 skipped, 0 failed.

The 4 test_extraction_backends.py failures under locked deps are ModuleNotFoundError: No module named 'transformers', reproduce identically on unmodified fe1fce6, and stay excluded — legitimately.

Still unverified

No real Gemini vision call has been made anywhere. The prompt reshape (system_prompt + image-only user message vs the old contents=[image, prompt]) is untested against a live model, and there's no scanned-PDF fixture in the repo to exercise the path end to end — tests/fixtures/sample_syllabus.pdf is a text PDF that never populates fallback_pages.

Caught by the first real Gemini call anyone has made against this feature.
Moving the instruction to `system_prompt` during the agent refactor changed
what the model produces. Measured on a rasterized syllabus with known ground
truth (231 chars of source text, 0-char text layer):
prompt as system_prompt -> 743 chars: \documentclass{article}, five
\usepackage lines, \begin{document}, a
tabular, \end{document}
prompt in the user turn -> 359 chars: clean Markdown table
Both transcribe the facts correctly — every assignment, date and type
matches. The difference is that as a system prompt, "Use LaTeX for
mathematics" reads as a document-format directive rather than an instruction
about math notation, so the model emits a whole LaTeX file.
The preamble is not cosmetic. extracted_text feeds the classify, summary and
concept prompts and is chunked into course_chunks for RAG, so "amsmath" and
"booktabs" become candidate concepts on a graph shared by every student in
the course — the same pollution this feature exists to prevent, arriving by a
different door.
Restores the wire shape the original raw-client implementation used
(contents=[image, prompt]), verified to produce 358 chars of clean Markdown
on the same fixture. The agent seam, the ADR-0008 model slot and the cost
attribution are all unaffected — only the placement changes.
The test now pins placement in the user turn and asserts the instruction is
absent from any system prompt. Revert-proof: reintroducing system_prompt
fails it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

First real Gemini call found a regression I introduced

Built a scanned-PDF fixture by rasterizing tests/fixtures/sample_syllabus.pdf — image-only, 0-char text layer, so it actually populates fallback_pages — while keeping the original's 231 chars of text as known ground truth.

Then made one real vision call. The facts came back perfect: every assignment, due date and type matched. But the shape did not.

wire shapeoutput
prompt as system_prompt (this PR, before 9252497)743 chars\documentclass{article}, five \usepackage lines, \begin{document}, tabular, \end{document}
prompt in the user turn (original raw-client shape)358 chars — clean Markdown table
prompt in the user turn (after 9252497)359 chars — clean Markdown table

As a system prompt, "Use LaTeX for mathematics" reads as a document-format directive rather than an instruction about math notation, so the model emits an entire LaTeX file. I introduced this in the agent refactor; the original raw-client implementation did not have it.

Why the preamble matters:extracted_text feeds the classify, summary and concept prompts and is chunked into course_chunks for RAG. Ship it and amsmath, booktabs and graphicx become candidate concepts on a graph shared by every student in the course — the same pollution this feature exists to prevent, arriving through a different door.

Fixed in 9252497 by restoring [image, prompt] in the user turn. The agent seam, the ADR-0008 model slot and the cost attribution are untouched — only placement changed. The test now pins placement and asserts the instruction is absent from any system prompt; I verified it's revert-proof by reintroducing system_prompt and watching it fail.

62 passed on locked deps, ruff clean.

This is the concrete argument for keeping a scanned-PDF fixture in the repo — the entire unit suite passed against this bug, because a FunctionModel will happily return whatever you tell it to.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Live stack results — including a correction

Ran the real thing: local Supabase (main's scripts/local-up.sh), backend on :5000, session via POST /api/auth/test-login as the seeded rich-user-active, real POST /api/documents/upload/sync.

Confirmed working

  • The async seam. A real Gemini vision call from inside a running event loop returns 358 chars of clean Markdown. This is the exact path _run_from_anywhere exists for — asyncio.run would have raised here and the per-page except Exception: continue would have swallowed it. Real model, real loop, no mocks.
  • No LaTeX preamble in that output, confirming 9252497 holds against the live model.
  • The upload path is unbroken on this branch: the scanned PDF classified as syllabus with an accurate summary and four correct concepts (Lab 1: Loops, Lab 2: Recursion, Midterm Project, Final Exam).

Correction: that upload did NOT exercise vision OCR

Docling ships RapidOCR and read the rasterized page itself — 341 chars, fallback_pages = []. The correct summary and concepts came from Docling, not Gemini. My fixture was too easy.

Worth knowing precisely when this feature actually spends money. docling_backend.py:104 flags a page when:

ifchar_count<LOW_CHAR_THRESHOLDormath_flag: # threshold = 40

So vision fires only when RapidOCR also fails (<40 chars) or the page has math-shaped content that isn't LaTeX. That matches #419's evidence, where all 13 pages of a handwritten linear-algebra exam were flagged — RapidOCR handles clean rasterized text fine and fails on handwriting. docling>=2.15,<3 is in requirements.txt, so RapidOCR is present in prod too.

Consequence: a plain scanned text PDF costs nothing, which is the intended design — but it also means the fixture needed to regression-test this path must contain handwriting or unLaTeXed math, not just a missing text layer.

Still open

No end-to-end run has pushed a document through the vision branch of the pipeline. Both halves are proven independently (real model + real loop; Docling flagging + rescue sequencing under unit test), but not composed. That needs a genuinely hard scan as a fixture.

…d loop
Found by the live test added here, which is the only thing that could have
found it: every other test in this feature substitutes the model, and a
FunctionModel has no client and no event loop.
Measured against the live API, calling the seam four times in one process:
call 1: OK 302 chars
call 2: RuntimeError: Event loop is closed
call 3: OK 308 chars
call 4: RuntimeError: Event loop is closed
`_providers._provider` is a module-level GoogleProvider, so its async httpx
client binds to the first loop `asyncio.run` creates and dies when that loop
closes. Every `run_agent_sync` caller shares this — it is #354, and the sweep
is still open in PR #358.
Transcription is the only caller that runs in a LOOP, which turns a latent bug
into an unusable feature: a 10-page scan alternates success and failure page by
page, and `_apply_gemini_vision_fallback`'s per-page `except Exception:
continue` keeps Docling's text without a word. Half a document silently
degrades to the mangled OCR this feature exists to replace.
So this path does not wait for #358. `fresh_ocr_vision_model()` builds a
provider per run and is passed as a per-run `model=` override, leaving the
shared `_provider` untouched so it cannot conflict with whatever #358 lands. It
returns None outside SAPLING_MODEL_MODE=real, where the FunctionModel has no
loop affinity and must not be overridden. Four consecutive live calls now pass.
The fixture is an image-only math worksheet. A missing text layer alone is not
enough to reach vision — Docling ships RapidOCR and reads rasterized prose
fine. This page is reached because `_detect_math_without_latex` flags
math-shaped content carrying no LaTeX, the scanned-math case the feature is
for. Docling alone drops problem 3 entirely as `<!-- formula-not-decoded -->`;
with vision it comes back as `$\sqrt{x^2 + 16} \leq 5$`.
Tests live in the `live_llm` lane, not tests/integration/: they need Docling and
a real model, not Postgres, and that lane's conftest mandates a running
Supabase stack. Opt-in via RUN_LIVE_OCR=1 plus a real key; skipped otherwise, so
CI's dummy key is a clean skip. One test guards the premise and fails loudly if
Docling ever stops flagging the fixture, since the other two would then pass
vacuously.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

The live test found a second, worse bug

Ran the composed path for real. It works — and in doing so it exposed something no mocked test could reach.

Calling the vision seam four times in one process:

call 1: OK 302 chars
call 2: RuntimeError: Event loop is closed
call 3: OK 308 chars
call 4: RuntimeError: Event loop is closed

_providers._provider is a module-level GoogleProvider; its async httpx client binds to the first loop asyncio.run creates and dies with it. This is #354 — every run_agent_sync caller shares it (study_guide, course_context_service, flashcard_import_service), and the sweep is still open in #358, not on main.

Transcription is the only caller that runs in a loop, which turns a latent bug into an unusable feature: a 10-page scan alternates success/failure page by page, and _apply_gemini_vision_fallback's per-page except Exception: continue keeps Docling's text silently. Half a document degrades back to the mangled OCR this feature exists to replace, with nothing logged.

Fixed in 6a6c555 with a per-run provider passed as a model= override — scoped to this agent, leaves the shared _provider alone, so it can't conflict with whatever #358 lands. Four consecutive live calls now pass.

The composed path, verified

Same code, same fixture, only the flag differs:

vision OFFvision ON
chars230302
problem 3 (√)dropped<!-- formula-not-decoded -->recovered$\sqrt{x^2 + 16} \leq 5$
∫x^2∫x~2 dxfrom 0 to 3$\int x^2 dx$
∂f/∂xaf/ax$\partial f/\partial x$

Docling losing an entire problem is the point — content it drops never reaches the concept prompt, so it never reaches the student's graph.

A full upload through the async handler classified it assignment, summarized all four problems including the dropped one, and produced concepts with real LaTeX and no \documentclass wrapper.

About the fixture

A missing text layer is not enough to reach vision. Docling ships RapidOCR and reads rasterized prose perfectly — my first fixture (a rasterized syllabus) came back clean with fallback_pages = [], so vision never ran. The committed fixture is reached because _detect_math_without_latex flags math-shaped content carrying no LaTeX, which is the actual scanned/handwritten-math case. Regenerate with tests/fixtures/make_scanned_math_ps4.py.

Tests are in the live_llm lane, not tests/integration/ — they need Docling and a real model, not Postgres, and that lane's conftest mandates a running Supabase stack. Opt-in via RUN_LIVE_OCR=1 plus a real key, so CI's dummy key is a clean skip. One test guards the premise and fails loudly if Docling stops flagging the fixture, since the other two would otherwise pass vacuously.

Verification: live lane 3 passed; full suite on locked deps 1069 passed, 26 skipped, 0 failed; ruff clean.

@AndresL230
AndresL230 merged commit 6a6c555 into worktree-gemini-vision-ocrJul 28, 2026
4 checks passed
@AndresL230
AndresL230 deleted the fix/420-review-followups branch July 28, 2026 03:53
Sign up for freeto 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

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(ocr): review follow-ups for #420 — agent seam, cost ceiling, cache key, sequential rescuers - #424

Merged
AndresL230 merged 5 commits into
worktree-gemini-vision-ocrfrom
fix/420-review-followups
Jul 28, 2026
Merged

fix(ocr): review follow-ups for #420 — agent seam, cost ceiling, cache key, sequential rescuers#424
AndresL230 merged 5 commits into
worktree-gemini-vision-ocrfrom
fix/420-review-followups

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Follow-ups from the review of #420. Targets worktree-gemini-vision-ocr so you can take them as a discrete diff rather than having your branch rewritten under you.

Two commits, disjoint file sets.

1. Route vision transcription through a Pydantic AI agent

The backend built a raw genai.Client and called generate_content directly. Three reasons that's wrong here, and the third is the one that matters:

The run is bounded by WORKER_LIMITS, matching the #329/#345 sweep: an unbounded run inside a per-page loop multiplies one runaway page across the whole document.

Latent bug this surfaced._extract_text_or_422 is sync but called from both async def handlers (routes/documents.py:640, :771), so a bare asyncio.run raises there — and _apply_gemini_vision_fallback's per-page except Exception: continue would have swallowed it. Vision OCR would have silently done nothing on the main upload path. _run_from_anywhere hands the coroutine to a worker thread when a loop is already running, copying the context so agent.override and the active span survive. Pinned by a test.

Module contract unchanged: same function name and signature, same GeminiVisionUnavailableError semantics, GEMINI_VISION_OCR_ENABLED still the switch.

2. Cache key, cost ceiling, sequential rescuers, accurate docs

  • Cache key omitted the model._ocr_cache_key claimed to include "every flag that changes the output" but not the vision model, so switching models kept serving the old transcription for the full 30-day TTL. Model and page cap now in the key, mixed in only when vision is enabled so vision-off deployments keep their entries. GOT_OCR_MODEL_PATH has the same pre-existing gap — the docstring now names it rather than overclaiming.
  • No cost ceiling. Nothing upstream bounds the page count: routes/extract.py allows min(max_pages, 50), the upload path has no rate limit at all, and /api/extract/pdf and /api/extract/image are unauthenticated, unbounded OCR endpoints with no rate limit #182's 10 req/60s was sized when a request meant one bounded local OCR run. GEMINI_VISION_OCR_MAX_PAGES caps it per document (default 10) and logs what it skipped.
  • if/elif made the rescuers mutually exclusive. Enabling both meant vision never ran — including on pages GOT-OCR failed to fill, recreating the exact "signal computed then dropped" bug this feature exists to fix. They now run in sequence, GOT-OCR first (local, free), then vision over what it couldn't fill. GOT-OCR's gate is byte-for-byte unchanged.
  • Three false claims..env.example said an unreadable scan "is rejected" — not on this base; the upload paths convert only extraction exceptions to 422, so "" reaches the classify prompt and the model fabricates. That rejection is fix(documents): reject empty text extraction instead of fabricating a summary #419, still open. The module docstring said vision works on "any engine"; it needs Docling to have run and succeeded. And the cache comment cited ADR 0019 (actually the SAPLING_MODEL_MODE test seam) for content-addressed chunk ids, whose dedup claim is untrue on main and becomes true only under fix(rag): content-hash chunk ids — dedup identical uploads per course #352.

Verification

1099 passed, 23 skipped on the full backend suite; ruff check clean. The one error (test_ocr_pipeline.py::test_save_to_db, event-loop teardown in a live-network test) is pre-existing — I reproduced it identically on unmodified fe1fce6.

Worth your call

  • Page cap default of 10 truncates your motivating document (the 13-page handwritten exam gets 10 pages transcribed, 3 left as Docling had them, with a warning). 20 matches routes/documents.py's max_pages if you'd rather nothing truncates in the default window — at the cost of a 2x worse ceiling on /api/extract.
  • Empty model response → "" is now implemented by catching UnexpectedModelBehavior, since output_type=str makes pydantic-ai retry once then raise. Same document-level behaviour as before, but it does swallow that exception class. Quota and network errors still propagate.
  • The prompt moved to system_prompt with the image as the user message — a different wire shape than contents=[image, prompt]. Worth one live-model spot check before enabling in prod.

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 27, 2026 17:28
The vision OCR call built a raw genai.Client and invoked generate_content
directly. Three reasons that is wrong here, the third load-bearing:
- CLAUDE.md: new LLM-driven code belongs in backend/agents/ as a Pydantic
AI agent, not a fresh client.
- ADR-0008 made agents/_providers.py::model_for(task) the one place a model
is chosen. GEMINI_VISION_OCR_MODEL was a competing knob that bypassed it;
the slot is now SAPLING_MODEL_OCR_VISION like every other agent's.
- Cost attribution. Logfire's instrument_pydantic_ai() tags every
pydantic-ai span with tokens and USD; a raw client call is invisible to
it and to the usage capture #118/PR #375 is building. Vision OCR is one
metered call per scanned page — plausibly the largest per-document LLM
spend in the app, and it would have been the one call the new cost
dashboard could not see.
The run is bounded by WORKER_LIMITS: it sits in a per-page loop, where an
unbounded run multiplies a single runaway page across the whole document.
Also fixes a latent bug this refactor surfaced. _extract_text_or_422 is
sync but called from both async handlers (routes/documents.py:640, :771),
so a bare asyncio.run raises there — and _apply_gemini_vision_fallback's
per-page `except Exception: continue` would have swallowed it, silently
turning vision OCR into a no-op on the main upload path. _run_from_anywhere
hands the coroutine to a worker thread when a loop is already running,
copying the context so agent.override and the active span survive.
The module contract is unchanged: same function name and signature, same
GeminiVisionUnavailableError semantics, GEMINI_VISION_OCR_ENABLED still
the switch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four findings from the review of #420.
Cache key omitted the model. _ocr_cache_key claimed to include "every flag
that changes the output" but not the vision model, so switching models kept
serving the old transcription for the full 30-day TTL. Model and page cap
are now in the key, mixed in only when vision is enabled so the vision-off
majority keeps its existing entries. GOT_OCR_MODEL_PATH has the same
pre-existing gap; the docstring now names it instead of overclaiming.
No cost ceiling. Each flagged page is one metered call, and nothing upstream
bounds the count: routes/extract.py allows min(max_pages, 50) and the upload
path has no rate limit at all. The #182 limit (10 req/60s) was sized when a
request meant one bounded local OCR run. GEMINI_VISION_OCR_MAX_PAGES caps it
per document, default 10, and logs how many pages it left behind — a silent
cap reads downstream as a full transcription.
if/elif made the rescuers mutually exclusive. Enabling both meant vision
never ran, including on pages GOT-OCR failed to fill, recreating the exact
"signal computed then dropped" bug this feature exists to fix. They now run
in sequence — GOT-OCR first (local, free), then vision over what it could
not fill. Both share one driver; GOT-OCR's gate is byte-for-byte unchanged.
Three false claims. .env.example said an unreadable scan "is rejected" — it
is not on this base; the upload paths convert only extraction *exceptions*
to 422, so "" reaches the classify prompt and the model fabricates. That
rejection is PR #419, still open. The module docstring said vision applies
to "any engine"; it needs Docling to have run and succeeded. And the cache
comment cited ADR 0019 (actually the SAPLING_MODEL_MODE test seam) for
content-addressed chunk ids, whose dedup claim is untrue on main and becomes
true only under PR #352.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • ^production$
  • ^staging$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7a66d72b-88bc-452f-8d5d-604f038e3c76

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/420-review-followups

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging6a6c555Commit Preview URL

Branch Preview URL
Jul 28 2026, 03:57 AM

The CI pytest step ignored four files. Three genuinely need what
requirements.lock deliberately excludes: transformers
(test_extraction_backends), docling (test_docling_integration), live
network (test_ocr_pipeline).
test_extraction_service.py needs none of them — it stubs every backend it
exercises. It was swept into the list with its heavy neighbours, and the
consequence is that nothing in it has ever gated a PR: not the OCR engine
gating, not the content-addressed cache key (#97), and not the cost ceiling
and rescuer sequencing added alongside this change. #420's own fallback and
cache-key tests were ungated for the same reason.
Verified against the locked (non-OCR) dependency set CI actually installs,
using CI's exact command and env: 1069 passed, 23 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Locked-deps verification

Ran the branch against a throwaway venv built from requirements.lock — the same hash-pinned, non-OCR set CI installs (pydantic-ai-slim 1.107.0, google-genai 2.9.0, fastapi 0.138.0), not the local dev venv, which is 18 minor versions behind on pydantic-ai and a full major behind on google-genai.

The pydantic-ai API surface held.BinaryContent, GoogleModelSettings timeout conversion, usage_limits, and the UnexpectedModelBehavior-on-empty-output path all behave the same at 1.107.0 as at 1.89.1. That was the risk I expected to find and did not.

What it did surface:tests/test_extraction_service.py was on CI's --ignore list, so nothing in it has ever gated a PR — not the OCR engine gating, not the content-addressed cache key (#97), and not #420's own TestGeminiVisionFallback / TestOcrCacheKey. It was swept in with three neighbours that genuinely need excluded deps (transformers / docling / live network); it stubs every backend it exercises and needs none of them. Dropped from the ignore list in 283fceb.

Verified with CI's exact command and env on the locked set: 1069 passed, 23 skipped, 0 failed.

The 4 test_extraction_backends.py failures under locked deps are ModuleNotFoundError: No module named 'transformers', reproduce identically on unmodified fe1fce6, and stay excluded — legitimately.

Still unverified

No real Gemini vision call has been made anywhere. The prompt reshape (system_prompt + image-only user message vs the old contents=[image, prompt]) is untested against a live model, and there's no scanned-PDF fixture in the repo to exercise the path end to end — tests/fixtures/sample_syllabus.pdf is a text PDF that never populates fallback_pages.

Caught by the first real Gemini call anyone has made against this feature.
Moving the instruction to `system_prompt` during the agent refactor changed
what the model produces. Measured on a rasterized syllabus with known ground
truth (231 chars of source text, 0-char text layer):
prompt as system_prompt -> 743 chars: \documentclass{article}, five
\usepackage lines, \begin{document}, a
tabular, \end{document}
prompt in the user turn -> 359 chars: clean Markdown table
Both transcribe the facts correctly — every assignment, date and type
matches. The difference is that as a system prompt, "Use LaTeX for
mathematics" reads as a document-format directive rather than an instruction
about math notation, so the model emits a whole LaTeX file.
The preamble is not cosmetic. extracted_text feeds the classify, summary and
concept prompts and is chunked into course_chunks for RAG, so "amsmath" and
"booktabs" become candidate concepts on a graph shared by every student in
the course — the same pollution this feature exists to prevent, arriving by a
different door.
Restores the wire shape the original raw-client implementation used
(contents=[image, prompt]), verified to produce 358 chars of clean Markdown
on the same fixture. The agent seam, the ADR-0008 model slot and the cost
attribution are all unaffected — only the placement changes.
The test now pins placement in the user turn and asserts the instruction is
absent from any system prompt. Revert-proof: reintroducing system_prompt
fails it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

First real Gemini call found a regression I introduced

Built a scanned-PDF fixture by rasterizing tests/fixtures/sample_syllabus.pdf — image-only, 0-char text layer, so it actually populates fallback_pages — while keeping the original's 231 chars of text as known ground truth.

Then made one real vision call. The facts came back perfect: every assignment, due date and type matched. But the shape did not.

wire shapeoutput
prompt as system_prompt (this PR, before 9252497)743 chars\documentclass{article}, five \usepackage lines, \begin{document}, tabular, \end{document}
prompt in the user turn (original raw-client shape)358 chars — clean Markdown table
prompt in the user turn (after 9252497)359 chars — clean Markdown table

As a system prompt, "Use LaTeX for mathematics" reads as a document-format directive rather than an instruction about math notation, so the model emits an entire LaTeX file. I introduced this in the agent refactor; the original raw-client implementation did not have it.

Why the preamble matters:extracted_text feeds the classify, summary and concept prompts and is chunked into course_chunks for RAG. Ship it and amsmath, booktabs and graphicx become candidate concepts on a graph shared by every student in the course — the same pollution this feature exists to prevent, arriving through a different door.

Fixed in 9252497 by restoring [image, prompt] in the user turn. The agent seam, the ADR-0008 model slot and the cost attribution are untouched — only placement changed. The test now pins placement and asserts the instruction is absent from any system prompt; I verified it's revert-proof by reintroducing system_prompt and watching it fail.

62 passed on locked deps, ruff clean.

This is the concrete argument for keeping a scanned-PDF fixture in the repo — the entire unit suite passed against this bug, because a FunctionModel will happily return whatever you tell it to.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Live stack results — including a correction

Ran the real thing: local Supabase (main's scripts/local-up.sh), backend on :5000, session via POST /api/auth/test-login as the seeded rich-user-active, real POST /api/documents/upload/sync.

Confirmed working

  • The async seam. A real Gemini vision call from inside a running event loop returns 358 chars of clean Markdown. This is the exact path _run_from_anywhere exists for — asyncio.run would have raised here and the per-page except Exception: continue would have swallowed it. Real model, real loop, no mocks.
  • No LaTeX preamble in that output, confirming 9252497 holds against the live model.
  • The upload path is unbroken on this branch: the scanned PDF classified as syllabus with an accurate summary and four correct concepts (Lab 1: Loops, Lab 2: Recursion, Midterm Project, Final Exam).

Correction: that upload did NOT exercise vision OCR

Docling ships RapidOCR and read the rasterized page itself — 341 chars, fallback_pages = []. The correct summary and concepts came from Docling, not Gemini. My fixture was too easy.

Worth knowing precisely when this feature actually spends money. docling_backend.py:104 flags a page when:

ifchar_count<LOW_CHAR_THRESHOLDormath_flag: # threshold = 40

So vision fires only when RapidOCR also fails (<40 chars) or the page has math-shaped content that isn't LaTeX. That matches #419's evidence, where all 13 pages of a handwritten linear-algebra exam were flagged — RapidOCR handles clean rasterized text fine and fails on handwriting. docling>=2.15,<3 is in requirements.txt, so RapidOCR is present in prod too.

Consequence: a plain scanned text PDF costs nothing, which is the intended design — but it also means the fixture needed to regression-test this path must contain handwriting or unLaTeXed math, not just a missing text layer.

Still open

No end-to-end run has pushed a document through the vision branch of the pipeline. Both halves are proven independently (real model + real loop; Docling flagging + rescue sequencing under unit test), but not composed. That needs a genuinely hard scan as a fixture.

…d loop
Found by the live test added here, which is the only thing that could have
found it: every other test in this feature substitutes the model, and a
FunctionModel has no client and no event loop.
Measured against the live API, calling the seam four times in one process:
call 1: OK 302 chars
call 2: RuntimeError: Event loop is closed
call 3: OK 308 chars
call 4: RuntimeError: Event loop is closed
`_providers._provider` is a module-level GoogleProvider, so its async httpx
client binds to the first loop `asyncio.run` creates and dies when that loop
closes. Every `run_agent_sync` caller shares this — it is #354, and the sweep
is still open in PR #358.
Transcription is the only caller that runs in a LOOP, which turns a latent bug
into an unusable feature: a 10-page scan alternates success and failure page by
page, and `_apply_gemini_vision_fallback`'s per-page `except Exception:
continue` keeps Docling's text without a word. Half a document silently
degrades to the mangled OCR this feature exists to replace.
So this path does not wait for #358. `fresh_ocr_vision_model()` builds a
provider per run and is passed as a per-run `model=` override, leaving the
shared `_provider` untouched so it cannot conflict with whatever #358 lands. It
returns None outside SAPLING_MODEL_MODE=real, where the FunctionModel has no
loop affinity and must not be overridden. Four consecutive live calls now pass.
The fixture is an image-only math worksheet. A missing text layer alone is not
enough to reach vision — Docling ships RapidOCR and reads rasterized prose
fine. This page is reached because `_detect_math_without_latex` flags
math-shaped content carrying no LaTeX, the scanned-math case the feature is
for. Docling alone drops problem 3 entirely as `<!-- formula-not-decoded -->`;
with vision it comes back as `$\sqrt{x^2 + 16} \leq 5$`.
Tests live in the `live_llm` lane, not tests/integration/: they need Docling and
a real model, not Postgres, and that lane's conftest mandates a running
Supabase stack. Opt-in via RUN_LIVE_OCR=1 plus a real key; skipped otherwise, so
CI's dummy key is a clean skip. One test guards the premise and fails loudly if
Docling ever stops flagging the fixture, since the other two would then pass
vacuously.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

The live test found a second, worse bug

Ran the composed path for real. It works — and in doing so it exposed something no mocked test could reach.

Calling the vision seam four times in one process:

call 1: OK 302 chars
call 2: RuntimeError: Event loop is closed
call 3: OK 308 chars
call 4: RuntimeError: Event loop is closed

_providers._provider is a module-level GoogleProvider; its async httpx client binds to the first loop asyncio.run creates and dies with it. This is #354 — every run_agent_sync caller shares it (study_guide, course_context_service, flashcard_import_service), and the sweep is still open in #358, not on main.

Transcription is the only caller that runs in a loop, which turns a latent bug into an unusable feature: a 10-page scan alternates success/failure page by page, and _apply_gemini_vision_fallback's per-page except Exception: continue keeps Docling's text silently. Half a document degrades back to the mangled OCR this feature exists to replace, with nothing logged.

Fixed in 6a6c555 with a per-run provider passed as a model= override — scoped to this agent, leaves the shared _provider alone, so it can't conflict with whatever #358 lands. Four consecutive live calls now pass.

The composed path, verified

Same code, same fixture, only the flag differs:

vision OFFvision ON
chars230302
problem 3 (√)dropped<!-- formula-not-decoded -->recovered$\sqrt{x^2 + 16} \leq 5$
∫x^2∫x~2 dxfrom 0 to 3$\int x^2 dx$
∂f/∂xaf/ax$\partial f/\partial x$

Docling losing an entire problem is the point — content it drops never reaches the concept prompt, so it never reaches the student's graph.

A full upload through the async handler classified it assignment, summarized all four problems including the dropped one, and produced concepts with real LaTeX and no \documentclass wrapper.

About the fixture

A missing text layer is not enough to reach vision. Docling ships RapidOCR and reads rasterized prose perfectly — my first fixture (a rasterized syllabus) came back clean with fallback_pages = [], so vision never ran. The committed fixture is reached because _detect_math_without_latex flags math-shaped content carrying no LaTeX, which is the actual scanned/handwritten-math case. Regenerate with tests/fixtures/make_scanned_math_ps4.py.

Tests are in the live_llm lane, not tests/integration/ — they need Docling and a real model, not Postgres, and that lane's conftest mandates a running Supabase stack. Opt-in via RUN_LIVE_OCR=1 plus a real key, so CI's dummy key is a clean skip. One test guards the premise and fails loudly if Docling stops flagging the fixture, since the other two would otherwise pass vacuously.

Verification: live lane 3 passed; full suite on locked deps 1069 passed, 26 skipped, 0 failed; ruff clean.

@AndresL230
AndresL230 merged commit 6a6c555 into worktree-gemini-vision-ocrJul 28, 2026
4 checks passed
@AndresL230
AndresL230 deleted the fix/420-review-followups branch July 28, 2026 03:53
Sign up for freeto 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

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(ocr): review follow-ups for #420 — agent seam, cost ceiling, cache key, sequential rescuers - #424

Merged
AndresL230 merged 5 commits into
worktree-gemini-vision-ocrfrom
fix/420-review-followups
Jul 28, 2026
Merged

fix(ocr): review follow-ups for #420 — agent seam, cost ceiling, cache key, sequential rescuers#424
AndresL230 merged 5 commits into
worktree-gemini-vision-ocrfrom
fix/420-review-followups

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Follow-ups from the review of #420. Targets worktree-gemini-vision-ocr so you can take them as a discrete diff rather than having your branch rewritten under you.

Two commits, disjoint file sets.

1. Route vision transcription through a Pydantic AI agent

The backend built a raw genai.Client and called generate_content directly. Three reasons that's wrong here, and the third is the one that matters:

The run is bounded by WORKER_LIMITS, matching the #329/#345 sweep: an unbounded run inside a per-page loop multiplies one runaway page across the whole document.

Latent bug this surfaced._extract_text_or_422 is sync but called from both async def handlers (routes/documents.py:640, :771), so a bare asyncio.run raises there — and _apply_gemini_vision_fallback's per-page except Exception: continue would have swallowed it. Vision OCR would have silently done nothing on the main upload path. _run_from_anywhere hands the coroutine to a worker thread when a loop is already running, copying the context so agent.override and the active span survive. Pinned by a test.

Module contract unchanged: same function name and signature, same GeminiVisionUnavailableError semantics, GEMINI_VISION_OCR_ENABLED still the switch.

2. Cache key, cost ceiling, sequential rescuers, accurate docs

  • Cache key omitted the model._ocr_cache_key claimed to include "every flag that changes the output" but not the vision model, so switching models kept serving the old transcription for the full 30-day TTL. Model and page cap now in the key, mixed in only when vision is enabled so vision-off deployments keep their entries. GOT_OCR_MODEL_PATH has the same pre-existing gap — the docstring now names it rather than overclaiming.
  • No cost ceiling. Nothing upstream bounds the page count: routes/extract.py allows min(max_pages, 50), the upload path has no rate limit at all, and /api/extract/pdf and /api/extract/image are unauthenticated, unbounded OCR endpoints with no rate limit #182's 10 req/60s was sized when a request meant one bounded local OCR run. GEMINI_VISION_OCR_MAX_PAGES caps it per document (default 10) and logs what it skipped.
  • if/elif made the rescuers mutually exclusive. Enabling both meant vision never ran — including on pages GOT-OCR failed to fill, recreating the exact "signal computed then dropped" bug this feature exists to fix. They now run in sequence, GOT-OCR first (local, free), then vision over what it couldn't fill. GOT-OCR's gate is byte-for-byte unchanged.
  • Three false claims..env.example said an unreadable scan "is rejected" — not on this base; the upload paths convert only extraction exceptions to 422, so "" reaches the classify prompt and the model fabricates. That rejection is fix(documents): reject empty text extraction instead of fabricating a summary #419, still open. The module docstring said vision works on "any engine"; it needs Docling to have run and succeeded. And the cache comment cited ADR 0019 (actually the SAPLING_MODEL_MODE test seam) for content-addressed chunk ids, whose dedup claim is untrue on main and becomes true only under fix(rag): content-hash chunk ids — dedup identical uploads per course #352.

Verification

1099 passed, 23 skipped on the full backend suite; ruff check clean. The one error (test_ocr_pipeline.py::test_save_to_db, event-loop teardown in a live-network test) is pre-existing — I reproduced it identically on unmodified fe1fce6.

Worth your call

  • Page cap default of 10 truncates your motivating document (the 13-page handwritten exam gets 10 pages transcribed, 3 left as Docling had them, with a warning). 20 matches routes/documents.py's max_pages if you'd rather nothing truncates in the default window — at the cost of a 2x worse ceiling on /api/extract.
  • Empty model response → "" is now implemented by catching UnexpectedModelBehavior, since output_type=str makes pydantic-ai retry once then raise. Same document-level behaviour as before, but it does swallow that exception class. Quota and network errors still propagate.
  • The prompt moved to system_prompt with the image as the user message — a different wire shape than contents=[image, prompt]. Worth one live-model spot check before enabling in prod.

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 27, 2026 17:28
The vision OCR call built a raw genai.Client and invoked generate_content
directly. Three reasons that is wrong here, the third load-bearing:
- CLAUDE.md: new LLM-driven code belongs in backend/agents/ as a Pydantic
AI agent, not a fresh client.
- ADR-0008 made agents/_providers.py::model_for(task) the one place a model
is chosen. GEMINI_VISION_OCR_MODEL was a competing knob that bypassed it;
the slot is now SAPLING_MODEL_OCR_VISION like every other agent's.
- Cost attribution. Logfire's instrument_pydantic_ai() tags every
pydantic-ai span with tokens and USD; a raw client call is invisible to
it and to the usage capture #118/PR #375 is building. Vision OCR is one
metered call per scanned page — plausibly the largest per-document LLM
spend in the app, and it would have been the one call the new cost
dashboard could not see.
The run is bounded by WORKER_LIMITS: it sits in a per-page loop, where an
unbounded run multiplies a single runaway page across the whole document.
Also fixes a latent bug this refactor surfaced. _extract_text_or_422 is
sync but called from both async handlers (routes/documents.py:640, :771),
so a bare asyncio.run raises there — and _apply_gemini_vision_fallback's
per-page `except Exception: continue` would have swallowed it, silently
turning vision OCR into a no-op on the main upload path. _run_from_anywhere
hands the coroutine to a worker thread when a loop is already running,
copying the context so agent.override and the active span survive.
The module contract is unchanged: same function name and signature, same
GeminiVisionUnavailableError semantics, GEMINI_VISION_OCR_ENABLED still
the switch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four findings from the review of #420.
Cache key omitted the model. _ocr_cache_key claimed to include "every flag
that changes the output" but not the vision model, so switching models kept
serving the old transcription for the full 30-day TTL. Model and page cap
are now in the key, mixed in only when vision is enabled so the vision-off
majority keeps its existing entries. GOT_OCR_MODEL_PATH has the same
pre-existing gap; the docstring now names it instead of overclaiming.
No cost ceiling. Each flagged page is one metered call, and nothing upstream
bounds the count: routes/extract.py allows min(max_pages, 50) and the upload
path has no rate limit at all. The #182 limit (10 req/60s) was sized when a
request meant one bounded local OCR run. GEMINI_VISION_OCR_MAX_PAGES caps it
per document, default 10, and logs how many pages it left behind — a silent
cap reads downstream as a full transcription.
if/elif made the rescuers mutually exclusive. Enabling both meant vision
never ran, including on pages GOT-OCR failed to fill, recreating the exact
"signal computed then dropped" bug this feature exists to fix. They now run
in sequence — GOT-OCR first (local, free), then vision over what it could
not fill. Both share one driver; GOT-OCR's gate is byte-for-byte unchanged.
Three false claims. .env.example said an unreadable scan "is rejected" — it
is not on this base; the upload paths convert only extraction *exceptions*
to 422, so "" reaches the classify prompt and the model fabricates. That
rejection is PR #419, still open. The module docstring said vision applies
to "any engine"; it needs Docling to have run and succeeded. And the cache
comment cited ADR 0019 (actually the SAPLING_MODEL_MODE test seam) for
content-addressed chunk ids, whose dedup claim is untrue on main and becomes
true only under PR #352.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • ^production$
  • ^staging$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7a66d72b-88bc-452f-8d5d-604f038e3c76

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/420-review-followups

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging6a6c555Commit Preview URL

Branch Preview URL
Jul 28 2026, 03:57 AM

The CI pytest step ignored four files. Three genuinely need what
requirements.lock deliberately excludes: transformers
(test_extraction_backends), docling (test_docling_integration), live
network (test_ocr_pipeline).
test_extraction_service.py needs none of them — it stubs every backend it
exercises. It was swept into the list with its heavy neighbours, and the
consequence is that nothing in it has ever gated a PR: not the OCR engine
gating, not the content-addressed cache key (#97), and not the cost ceiling
and rescuer sequencing added alongside this change. #420's own fallback and
cache-key tests were ungated for the same reason.
Verified against the locked (non-OCR) dependency set CI actually installs,
using CI's exact command and env: 1069 passed, 23 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Locked-deps verification

Ran the branch against a throwaway venv built from requirements.lock — the same hash-pinned, non-OCR set CI installs (pydantic-ai-slim 1.107.0, google-genai 2.9.0, fastapi 0.138.0), not the local dev venv, which is 18 minor versions behind on pydantic-ai and a full major behind on google-genai.

The pydantic-ai API surface held.BinaryContent, GoogleModelSettings timeout conversion, usage_limits, and the UnexpectedModelBehavior-on-empty-output path all behave the same at 1.107.0 as at 1.89.1. That was the risk I expected to find and did not.

What it did surface:tests/test_extraction_service.py was on CI's --ignore list, so nothing in it has ever gated a PR — not the OCR engine gating, not the content-addressed cache key (#97), and not #420's own TestGeminiVisionFallback / TestOcrCacheKey. It was swept in with three neighbours that genuinely need excluded deps (transformers / docling / live network); it stubs every backend it exercises and needs none of them. Dropped from the ignore list in 283fceb.

Verified with CI's exact command and env on the locked set: 1069 passed, 23 skipped, 0 failed.

The 4 test_extraction_backends.py failures under locked deps are ModuleNotFoundError: No module named 'transformers', reproduce identically on unmodified fe1fce6, and stay excluded — legitimately.

Still unverified

No real Gemini vision call has been made anywhere. The prompt reshape (system_prompt + image-only user message vs the old contents=[image, prompt]) is untested against a live model, and there's no scanned-PDF fixture in the repo to exercise the path end to end — tests/fixtures/sample_syllabus.pdf is a text PDF that never populates fallback_pages.

Caught by the first real Gemini call anyone has made against this feature.
Moving the instruction to `system_prompt` during the agent refactor changed
what the model produces. Measured on a rasterized syllabus with known ground
truth (231 chars of source text, 0-char text layer):
prompt as system_prompt -> 743 chars: \documentclass{article}, five
\usepackage lines, \begin{document}, a
tabular, \end{document}
prompt in the user turn -> 359 chars: clean Markdown table
Both transcribe the facts correctly — every assignment, date and type
matches. The difference is that as a system prompt, "Use LaTeX for
mathematics" reads as a document-format directive rather than an instruction
about math notation, so the model emits a whole LaTeX file.
The preamble is not cosmetic. extracted_text feeds the classify, summary and
concept prompts and is chunked into course_chunks for RAG, so "amsmath" and
"booktabs" become candidate concepts on a graph shared by every student in
the course — the same pollution this feature exists to prevent, arriving by a
different door.
Restores the wire shape the original raw-client implementation used
(contents=[image, prompt]), verified to produce 358 chars of clean Markdown
on the same fixture. The agent seam, the ADR-0008 model slot and the cost
attribution are all unaffected — only the placement changes.
The test now pins placement in the user turn and asserts the instruction is
absent from any system prompt. Revert-proof: reintroducing system_prompt
fails it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

First real Gemini call found a regression I introduced

Built a scanned-PDF fixture by rasterizing tests/fixtures/sample_syllabus.pdf — image-only, 0-char text layer, so it actually populates fallback_pages — while keeping the original's 231 chars of text as known ground truth.

Then made one real vision call. The facts came back perfect: every assignment, due date and type matched. But the shape did not.

wire shapeoutput
prompt as system_prompt (this PR, before 9252497)743 chars\documentclass{article}, five \usepackage lines, \begin{document}, tabular, \end{document}
prompt in the user turn (original raw-client shape)358 chars — clean Markdown table
prompt in the user turn (after 9252497)359 chars — clean Markdown table

As a system prompt, "Use LaTeX for mathematics" reads as a document-format directive rather than an instruction about math notation, so the model emits an entire LaTeX file. I introduced this in the agent refactor; the original raw-client implementation did not have it.

Why the preamble matters:extracted_text feeds the classify, summary and concept prompts and is chunked into course_chunks for RAG. Ship it and amsmath, booktabs and graphicx become candidate concepts on a graph shared by every student in the course — the same pollution this feature exists to prevent, arriving through a different door.

Fixed in 9252497 by restoring [image, prompt] in the user turn. The agent seam, the ADR-0008 model slot and the cost attribution are untouched — only placement changed. The test now pins placement and asserts the instruction is absent from any system prompt; I verified it's revert-proof by reintroducing system_prompt and watching it fail.

62 passed on locked deps, ruff clean.

This is the concrete argument for keeping a scanned-PDF fixture in the repo — the entire unit suite passed against this bug, because a FunctionModel will happily return whatever you tell it to.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Live stack results — including a correction

Ran the real thing: local Supabase (main's scripts/local-up.sh), backend on :5000, session via POST /api/auth/test-login as the seeded rich-user-active, real POST /api/documents/upload/sync.

Confirmed working

  • The async seam. A real Gemini vision call from inside a running event loop returns 358 chars of clean Markdown. This is the exact path _run_from_anywhere exists for — asyncio.run would have raised here and the per-page except Exception: continue would have swallowed it. Real model, real loop, no mocks.
  • No LaTeX preamble in that output, confirming 9252497 holds against the live model.
  • The upload path is unbroken on this branch: the scanned PDF classified as syllabus with an accurate summary and four correct concepts (Lab 1: Loops, Lab 2: Recursion, Midterm Project, Final Exam).

Correction: that upload did NOT exercise vision OCR

Docling ships RapidOCR and read the rasterized page itself — 341 chars, fallback_pages = []. The correct summary and concepts came from Docling, not Gemini. My fixture was too easy.

Worth knowing precisely when this feature actually spends money. docling_backend.py:104 flags a page when:

ifchar_count<LOW_CHAR_THRESHOLDormath_flag: # threshold = 40

So vision fires only when RapidOCR also fails (<40 chars) or the page has math-shaped content that isn't LaTeX. That matches #419's evidence, where all 13 pages of a handwritten linear-algebra exam were flagged — RapidOCR handles clean rasterized text fine and fails on handwriting. docling>=2.15,<3 is in requirements.txt, so RapidOCR is present in prod too.

Consequence: a plain scanned text PDF costs nothing, which is the intended design — but it also means the fixture needed to regression-test this path must contain handwriting or unLaTeXed math, not just a missing text layer.

Still open

No end-to-end run has pushed a document through the vision branch of the pipeline. Both halves are proven independently (real model + real loop; Docling flagging + rescue sequencing under unit test), but not composed. That needs a genuinely hard scan as a fixture.

…d loop
Found by the live test added here, which is the only thing that could have
found it: every other test in this feature substitutes the model, and a
FunctionModel has no client and no event loop.
Measured against the live API, calling the seam four times in one process:
call 1: OK 302 chars
call 2: RuntimeError: Event loop is closed
call 3: OK 308 chars
call 4: RuntimeError: Event loop is closed
`_providers._provider` is a module-level GoogleProvider, so its async httpx
client binds to the first loop `asyncio.run` creates and dies when that loop
closes. Every `run_agent_sync` caller shares this — it is #354, and the sweep
is still open in PR #358.
Transcription is the only caller that runs in a LOOP, which turns a latent bug
into an unusable feature: a 10-page scan alternates success and failure page by
page, and `_apply_gemini_vision_fallback`'s per-page `except Exception:
continue` keeps Docling's text without a word. Half a document silently
degrades to the mangled OCR this feature exists to replace.
So this path does not wait for #358. `fresh_ocr_vision_model()` builds a
provider per run and is passed as a per-run `model=` override, leaving the
shared `_provider` untouched so it cannot conflict with whatever #358 lands. It
returns None outside SAPLING_MODEL_MODE=real, where the FunctionModel has no
loop affinity and must not be overridden. Four consecutive live calls now pass.
The fixture is an image-only math worksheet. A missing text layer alone is not
enough to reach vision — Docling ships RapidOCR and reads rasterized prose
fine. This page is reached because `_detect_math_without_latex` flags
math-shaped content carrying no LaTeX, the scanned-math case the feature is
for. Docling alone drops problem 3 entirely as `<!-- formula-not-decoded -->`;
with vision it comes back as `$\sqrt{x^2 + 16} \leq 5$`.
Tests live in the `live_llm` lane, not tests/integration/: they need Docling and
a real model, not Postgres, and that lane's conftest mandates a running
Supabase stack. Opt-in via RUN_LIVE_OCR=1 plus a real key; skipped otherwise, so
CI's dummy key is a clean skip. One test guards the premise and fails loudly if
Docling ever stops flagging the fixture, since the other two would then pass
vacuously.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

The live test found a second, worse bug

Ran the composed path for real. It works — and in doing so it exposed something no mocked test could reach.

Calling the vision seam four times in one process:

call 1: OK 302 chars
call 2: RuntimeError: Event loop is closed
call 3: OK 308 chars
call 4: RuntimeError: Event loop is closed

_providers._provider is a module-level GoogleProvider; its async httpx client binds to the first loop asyncio.run creates and dies with it. This is #354 — every run_agent_sync caller shares it (study_guide, course_context_service, flashcard_import_service), and the sweep is still open in #358, not on main.

Transcription is the only caller that runs in a loop, which turns a latent bug into an unusable feature: a 10-page scan alternates success/failure page by page, and _apply_gemini_vision_fallback's per-page except Exception: continue keeps Docling's text silently. Half a document degrades back to the mangled OCR this feature exists to replace, with nothing logged.

Fixed in 6a6c555 with a per-run provider passed as a model= override — scoped to this agent, leaves the shared _provider alone, so it can't conflict with whatever #358 lands. Four consecutive live calls now pass.

The composed path, verified

Same code, same fixture, only the flag differs:

vision OFFvision ON
chars230302
problem 3 (√)dropped<!-- formula-not-decoded -->recovered$\sqrt{x^2 + 16} \leq 5$
∫x^2∫x~2 dxfrom 0 to 3$\int x^2 dx$
∂f/∂xaf/ax$\partial f/\partial x$

Docling losing an entire problem is the point — content it drops never reaches the concept prompt, so it never reaches the student's graph.

A full upload through the async handler classified it assignment, summarized all four problems including the dropped one, and produced concepts with real LaTeX and no \documentclass wrapper.

About the fixture

A missing text layer is not enough to reach vision. Docling ships RapidOCR and reads rasterized prose perfectly — my first fixture (a rasterized syllabus) came back clean with fallback_pages = [], so vision never ran. The committed fixture is reached because _detect_math_without_latex flags math-shaped content carrying no LaTeX, which is the actual scanned/handwritten-math case. Regenerate with tests/fixtures/make_scanned_math_ps4.py.

Tests are in the live_llm lane, not tests/integration/ — they need Docling and a real model, not Postgres, and that lane's conftest mandates a running Supabase stack. Opt-in via RUN_LIVE_OCR=1 plus a real key, so CI's dummy key is a clean skip. One test guards the premise and fails loudly if Docling stops flagging the fixture, since the other two would otherwise pass vacuously.

Verification: live lane 3 passed; full suite on locked deps 1069 passed, 26 skipped, 0 failed; ruff clean.

@AndresL230
AndresL230 merged commit 6a6c555 into worktree-gemini-vision-ocrJul 28, 2026
4 checks passed
@AndresL230
AndresL230 deleted the fix/420-review-followups branch July 28, 2026 03:53
Sign up for freeto 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

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(ocr): review follow-ups for #420 — agent seam, cost ceiling, cache key, sequential rescuers - #424

Merged
AndresL230 merged 5 commits into
worktree-gemini-vision-ocrfrom
fix/420-review-followups
Jul 28, 2026
Merged

fix(ocr): review follow-ups for #420 — agent seam, cost ceiling, cache key, sequential rescuers#424
AndresL230 merged 5 commits into
worktree-gemini-vision-ocrfrom
fix/420-review-followups

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Follow-ups from the review of #420. Targets worktree-gemini-vision-ocr so you can take them as a discrete diff rather than having your branch rewritten under you.

Two commits, disjoint file sets.

1. Route vision transcription through a Pydantic AI agent

The backend built a raw genai.Client and called generate_content directly. Three reasons that's wrong here, and the third is the one that matters:

The run is bounded by WORKER_LIMITS, matching the #329/#345 sweep: an unbounded run inside a per-page loop multiplies one runaway page across the whole document.

Latent bug this surfaced._extract_text_or_422 is sync but called from both async def handlers (routes/documents.py:640, :771), so a bare asyncio.run raises there — and _apply_gemini_vision_fallback's per-page except Exception: continue would have swallowed it. Vision OCR would have silently done nothing on the main upload path. _run_from_anywhere hands the coroutine to a worker thread when a loop is already running, copying the context so agent.override and the active span survive. Pinned by a test.

Module contract unchanged: same function name and signature, same GeminiVisionUnavailableError semantics, GEMINI_VISION_OCR_ENABLED still the switch.

2. Cache key, cost ceiling, sequential rescuers, accurate docs

  • Cache key omitted the model._ocr_cache_key claimed to include "every flag that changes the output" but not the vision model, so switching models kept serving the old transcription for the full 30-day TTL. Model and page cap now in the key, mixed in only when vision is enabled so vision-off deployments keep their entries. GOT_OCR_MODEL_PATH has the same pre-existing gap — the docstring now names it rather than overclaiming.
  • No cost ceiling. Nothing upstream bounds the page count: routes/extract.py allows min(max_pages, 50), the upload path has no rate limit at all, and /api/extract/pdf and /api/extract/image are unauthenticated, unbounded OCR endpoints with no rate limit #182's 10 req/60s was sized when a request meant one bounded local OCR run. GEMINI_VISION_OCR_MAX_PAGES caps it per document (default 10) and logs what it skipped.
  • if/elif made the rescuers mutually exclusive. Enabling both meant vision never ran — including on pages GOT-OCR failed to fill, recreating the exact "signal computed then dropped" bug this feature exists to fix. They now run in sequence, GOT-OCR first (local, free), then vision over what it couldn't fill. GOT-OCR's gate is byte-for-byte unchanged.
  • Three false claims..env.example said an unreadable scan "is rejected" — not on this base; the upload paths convert only extraction exceptions to 422, so "" reaches the classify prompt and the model fabricates. That rejection is fix(documents): reject empty text extraction instead of fabricating a summary #419, still open. The module docstring said vision works on "any engine"; it needs Docling to have run and succeeded. And the cache comment cited ADR 0019 (actually the SAPLING_MODEL_MODE test seam) for content-addressed chunk ids, whose dedup claim is untrue on main and becomes true only under fix(rag): content-hash chunk ids — dedup identical uploads per course #352.

Verification

1099 passed, 23 skipped on the full backend suite; ruff check clean. The one error (test_ocr_pipeline.py::test_save_to_db, event-loop teardown in a live-network test) is pre-existing — I reproduced it identically on unmodified fe1fce6.

Worth your call

  • Page cap default of 10 truncates your motivating document (the 13-page handwritten exam gets 10 pages transcribed, 3 left as Docling had them, with a warning). 20 matches routes/documents.py's max_pages if you'd rather nothing truncates in the default window — at the cost of a 2x worse ceiling on /api/extract.
  • Empty model response → "" is now implemented by catching UnexpectedModelBehavior, since output_type=str makes pydantic-ai retry once then raise. Same document-level behaviour as before, but it does swallow that exception class. Quota and network errors still propagate.
  • The prompt moved to system_prompt with the image as the user message — a different wire shape than contents=[image, prompt]. Worth one live-model spot check before enabling in prod.

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 27, 2026 17:28
The vision OCR call built a raw genai.Client and invoked generate_content
directly. Three reasons that is wrong here, the third load-bearing:
- CLAUDE.md: new LLM-driven code belongs in backend/agents/ as a Pydantic
AI agent, not a fresh client.
- ADR-0008 made agents/_providers.py::model_for(task) the one place a model
is chosen. GEMINI_VISION_OCR_MODEL was a competing knob that bypassed it;
the slot is now SAPLING_MODEL_OCR_VISION like every other agent's.
- Cost attribution. Logfire's instrument_pydantic_ai() tags every
pydantic-ai span with tokens and USD; a raw client call is invisible to
it and to the usage capture #118/PR #375 is building. Vision OCR is one
metered call per scanned page — plausibly the largest per-document LLM
spend in the app, and it would have been the one call the new cost
dashboard could not see.
The run is bounded by WORKER_LIMITS: it sits in a per-page loop, where an
unbounded run multiplies a single runaway page across the whole document.
Also fixes a latent bug this refactor surfaced. _extract_text_or_422 is
sync but called from both async handlers (routes/documents.py:640, :771),
so a bare asyncio.run raises there — and _apply_gemini_vision_fallback's
per-page `except Exception: continue` would have swallowed it, silently
turning vision OCR into a no-op on the main upload path. _run_from_anywhere
hands the coroutine to a worker thread when a loop is already running,
copying the context so agent.override and the active span survive.
The module contract is unchanged: same function name and signature, same
GeminiVisionUnavailableError semantics, GEMINI_VISION_OCR_ENABLED still
the switch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four findings from the review of #420.
Cache key omitted the model. _ocr_cache_key claimed to include "every flag
that changes the output" but not the vision model, so switching models kept
serving the old transcription for the full 30-day TTL. Model and page cap
are now in the key, mixed in only when vision is enabled so the vision-off
majority keeps its existing entries. GOT_OCR_MODEL_PATH has the same
pre-existing gap; the docstring now names it instead of overclaiming.
No cost ceiling. Each flagged page is one metered call, and nothing upstream
bounds the count: routes/extract.py allows min(max_pages, 50) and the upload
path has no rate limit at all. The #182 limit (10 req/60s) was sized when a
request meant one bounded local OCR run. GEMINI_VISION_OCR_MAX_PAGES caps it
per document, default 10, and logs how many pages it left behind — a silent
cap reads downstream as a full transcription.
if/elif made the rescuers mutually exclusive. Enabling both meant vision
never ran, including on pages GOT-OCR failed to fill, recreating the exact
"signal computed then dropped" bug this feature exists to fix. They now run
in sequence — GOT-OCR first (local, free), then vision over what it could
not fill. Both share one driver; GOT-OCR's gate is byte-for-byte unchanged.
Three false claims. .env.example said an unreadable scan "is rejected" — it
is not on this base; the upload paths convert only extraction *exceptions*
to 422, so "" reaches the classify prompt and the model fabricates. That
rejection is PR #419, still open. The module docstring said vision applies
to "any engine"; it needs Docling to have run and succeeded. And the cache
comment cited ADR 0019 (actually the SAPLING_MODEL_MODE test seam) for
content-addressed chunk ids, whose dedup claim is untrue on main and becomes
true only under PR #352.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • ^production$
  • ^staging$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7a66d72b-88bc-452f-8d5d-604f038e3c76

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/420-review-followups

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging6a6c555Commit Preview URL

Branch Preview URL
Jul 28 2026, 03:57 AM

The CI pytest step ignored four files. Three genuinely need what
requirements.lock deliberately excludes: transformers
(test_extraction_backends), docling (test_docling_integration), live
network (test_ocr_pipeline).
test_extraction_service.py needs none of them — it stubs every backend it
exercises. It was swept into the list with its heavy neighbours, and the
consequence is that nothing in it has ever gated a PR: not the OCR engine
gating, not the content-addressed cache key (#97), and not the cost ceiling
and rescuer sequencing added alongside this change. #420's own fallback and
cache-key tests were ungated for the same reason.
Verified against the locked (non-OCR) dependency set CI actually installs,
using CI's exact command and env: 1069 passed, 23 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Locked-deps verification

Ran the branch against a throwaway venv built from requirements.lock — the same hash-pinned, non-OCR set CI installs (pydantic-ai-slim 1.107.0, google-genai 2.9.0, fastapi 0.138.0), not the local dev venv, which is 18 minor versions behind on pydantic-ai and a full major behind on google-genai.

The pydantic-ai API surface held.BinaryContent, GoogleModelSettings timeout conversion, usage_limits, and the UnexpectedModelBehavior-on-empty-output path all behave the same at 1.107.0 as at 1.89.1. That was the risk I expected to find and did not.

What it did surface:tests/test_extraction_service.py was on CI's --ignore list, so nothing in it has ever gated a PR — not the OCR engine gating, not the content-addressed cache key (#97), and not #420's own TestGeminiVisionFallback / TestOcrCacheKey. It was swept in with three neighbours that genuinely need excluded deps (transformers / docling / live network); it stubs every backend it exercises and needs none of them. Dropped from the ignore list in 283fceb.

Verified with CI's exact command and env on the locked set: 1069 passed, 23 skipped, 0 failed.

The 4 test_extraction_backends.py failures under locked deps are ModuleNotFoundError: No module named 'transformers', reproduce identically on unmodified fe1fce6, and stay excluded — legitimately.

Still unverified

No real Gemini vision call has been made anywhere. The prompt reshape (system_prompt + image-only user message vs the old contents=[image, prompt]) is untested against a live model, and there's no scanned-PDF fixture in the repo to exercise the path end to end — tests/fixtures/sample_syllabus.pdf is a text PDF that never populates fallback_pages.

Caught by the first real Gemini call anyone has made against this feature.
Moving the instruction to `system_prompt` during the agent refactor changed
what the model produces. Measured on a rasterized syllabus with known ground
truth (231 chars of source text, 0-char text layer):
prompt as system_prompt -> 743 chars: \documentclass{article}, five
\usepackage lines, \begin{document}, a
tabular, \end{document}
prompt in the user turn -> 359 chars: clean Markdown table
Both transcribe the facts correctly — every assignment, date and type
matches. The difference is that as a system prompt, "Use LaTeX for
mathematics" reads as a document-format directive rather than an instruction
about math notation, so the model emits a whole LaTeX file.
The preamble is not cosmetic. extracted_text feeds the classify, summary and
concept prompts and is chunked into course_chunks for RAG, so "amsmath" and
"booktabs" become candidate concepts on a graph shared by every student in
the course — the same pollution this feature exists to prevent, arriving by a
different door.
Restores the wire shape the original raw-client implementation used
(contents=[image, prompt]), verified to produce 358 chars of clean Markdown
on the same fixture. The agent seam, the ADR-0008 model slot and the cost
attribution are all unaffected — only the placement changes.
The test now pins placement in the user turn and asserts the instruction is
absent from any system prompt. Revert-proof: reintroducing system_prompt
fails it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

First real Gemini call found a regression I introduced

Built a scanned-PDF fixture by rasterizing tests/fixtures/sample_syllabus.pdf — image-only, 0-char text layer, so it actually populates fallback_pages — while keeping the original's 231 chars of text as known ground truth.

Then made one real vision call. The facts came back perfect: every assignment, due date and type matched. But the shape did not.

wire shapeoutput
prompt as system_prompt (this PR, before 9252497)743 chars\documentclass{article}, five \usepackage lines, \begin{document}, tabular, \end{document}
prompt in the user turn (original raw-client shape)358 chars — clean Markdown table
prompt in the user turn (after 9252497)359 chars — clean Markdown table

As a system prompt, "Use LaTeX for mathematics" reads as a document-format directive rather than an instruction about math notation, so the model emits an entire LaTeX file. I introduced this in the agent refactor; the original raw-client implementation did not have it.

Why the preamble matters:extracted_text feeds the classify, summary and concept prompts and is chunked into course_chunks for RAG. Ship it and amsmath, booktabs and graphicx become candidate concepts on a graph shared by every student in the course — the same pollution this feature exists to prevent, arriving through a different door.

Fixed in 9252497 by restoring [image, prompt] in the user turn. The agent seam, the ADR-0008 model slot and the cost attribution are untouched — only placement changed. The test now pins placement and asserts the instruction is absent from any system prompt; I verified it's revert-proof by reintroducing system_prompt and watching it fail.

62 passed on locked deps, ruff clean.

This is the concrete argument for keeping a scanned-PDF fixture in the repo — the entire unit suite passed against this bug, because a FunctionModel will happily return whatever you tell it to.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Live stack results — including a correction

Ran the real thing: local Supabase (main's scripts/local-up.sh), backend on :5000, session via POST /api/auth/test-login as the seeded rich-user-active, real POST /api/documents/upload/sync.

Confirmed working

  • The async seam. A real Gemini vision call from inside a running event loop returns 358 chars of clean Markdown. This is the exact path _run_from_anywhere exists for — asyncio.run would have raised here and the per-page except Exception: continue would have swallowed it. Real model, real loop, no mocks.
  • No LaTeX preamble in that output, confirming 9252497 holds against the live model.
  • The upload path is unbroken on this branch: the scanned PDF classified as syllabus with an accurate summary and four correct concepts (Lab 1: Loops, Lab 2: Recursion, Midterm Project, Final Exam).

Correction: that upload did NOT exercise vision OCR

Docling ships RapidOCR and read the rasterized page itself — 341 chars, fallback_pages = []. The correct summary and concepts came from Docling, not Gemini. My fixture was too easy.

Worth knowing precisely when this feature actually spends money. docling_backend.py:104 flags a page when:

ifchar_count<LOW_CHAR_THRESHOLDormath_flag: # threshold = 40

So vision fires only when RapidOCR also fails (<40 chars) or the page has math-shaped content that isn't LaTeX. That matches #419's evidence, where all 13 pages of a handwritten linear-algebra exam were flagged — RapidOCR handles clean rasterized text fine and fails on handwriting. docling>=2.15,<3 is in requirements.txt, so RapidOCR is present in prod too.

Consequence: a plain scanned text PDF costs nothing, which is the intended design — but it also means the fixture needed to regression-test this path must contain handwriting or unLaTeXed math, not just a missing text layer.

Still open

No end-to-end run has pushed a document through the vision branch of the pipeline. Both halves are proven independently (real model + real loop; Docling flagging + rescue sequencing under unit test), but not composed. That needs a genuinely hard scan as a fixture.

…d loop
Found by the live test added here, which is the only thing that could have
found it: every other test in this feature substitutes the model, and a
FunctionModel has no client and no event loop.
Measured against the live API, calling the seam four times in one process:
call 1: OK 302 chars
call 2: RuntimeError: Event loop is closed
call 3: OK 308 chars
call 4: RuntimeError: Event loop is closed
`_providers._provider` is a module-level GoogleProvider, so its async httpx
client binds to the first loop `asyncio.run` creates and dies when that loop
closes. Every `run_agent_sync` caller shares this — it is #354, and the sweep
is still open in PR #358.
Transcription is the only caller that runs in a LOOP, which turns a latent bug
into an unusable feature: a 10-page scan alternates success and failure page by
page, and `_apply_gemini_vision_fallback`'s per-page `except Exception:
continue` keeps Docling's text without a word. Half a document silently
degrades to the mangled OCR this feature exists to replace.
So this path does not wait for #358. `fresh_ocr_vision_model()` builds a
provider per run and is passed as a per-run `model=` override, leaving the
shared `_provider` untouched so it cannot conflict with whatever #358 lands. It
returns None outside SAPLING_MODEL_MODE=real, where the FunctionModel has no
loop affinity and must not be overridden. Four consecutive live calls now pass.
The fixture is an image-only math worksheet. A missing text layer alone is not
enough to reach vision — Docling ships RapidOCR and reads rasterized prose
fine. This page is reached because `_detect_math_without_latex` flags
math-shaped content carrying no LaTeX, the scanned-math case the feature is
for. Docling alone drops problem 3 entirely as `<!-- formula-not-decoded -->`;
with vision it comes back as `$\sqrt{x^2 + 16} \leq 5$`.
Tests live in the `live_llm` lane, not tests/integration/: they need Docling and
a real model, not Postgres, and that lane's conftest mandates a running
Supabase stack. Opt-in via RUN_LIVE_OCR=1 plus a real key; skipped otherwise, so
CI's dummy key is a clean skip. One test guards the premise and fails loudly if
Docling ever stops flagging the fixture, since the other two would then pass
vacuously.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

The live test found a second, worse bug

Ran the composed path for real. It works — and in doing so it exposed something no mocked test could reach.

Calling the vision seam four times in one process:

call 1: OK 302 chars
call 2: RuntimeError: Event loop is closed
call 3: OK 308 chars
call 4: RuntimeError: Event loop is closed

_providers._provider is a module-level GoogleProvider; its async httpx client binds to the first loop asyncio.run creates and dies with it. This is #354 — every run_agent_sync caller shares it (study_guide, course_context_service, flashcard_import_service), and the sweep is still open in #358, not on main.

Transcription is the only caller that runs in a loop, which turns a latent bug into an unusable feature: a 10-page scan alternates success/failure page by page, and _apply_gemini_vision_fallback's per-page except Exception: continue keeps Docling's text silently. Half a document degrades back to the mangled OCR this feature exists to replace, with nothing logged.

Fixed in 6a6c555 with a per-run provider passed as a model= override — scoped to this agent, leaves the shared _provider alone, so it can't conflict with whatever #358 lands. Four consecutive live calls now pass.

The composed path, verified

Same code, same fixture, only the flag differs:

vision OFFvision ON
chars230302
problem 3 (√)dropped<!-- formula-not-decoded -->recovered$\sqrt{x^2 + 16} \leq 5$
∫x^2∫x~2 dxfrom 0 to 3$\int x^2 dx$
∂f/∂xaf/ax$\partial f/\partial x$

Docling losing an entire problem is the point — content it drops never reaches the concept prompt, so it never reaches the student's graph.

A full upload through the async handler classified it assignment, summarized all four problems including the dropped one, and produced concepts with real LaTeX and no \documentclass wrapper.

About the fixture

A missing text layer is not enough to reach vision. Docling ships RapidOCR and reads rasterized prose perfectly — my first fixture (a rasterized syllabus) came back clean with fallback_pages = [], so vision never ran. The committed fixture is reached because _detect_math_without_latex flags math-shaped content carrying no LaTeX, which is the actual scanned/handwritten-math case. Regenerate with tests/fixtures/make_scanned_math_ps4.py.

Tests are in the live_llm lane, not tests/integration/ — they need Docling and a real model, not Postgres, and that lane's conftest mandates a running Supabase stack. Opt-in via RUN_LIVE_OCR=1 plus a real key, so CI's dummy key is a clean skip. One test guards the premise and fails loudly if Docling stops flagging the fixture, since the other two would otherwise pass vacuously.

Verification: live lane 3 passed; full suite on locked deps 1069 passed, 26 skipped, 0 failed; ruff clean.

@AndresL230
AndresL230 merged commit 6a6c555 into worktree-gemini-vision-ocrJul 28, 2026
4 checks passed
@AndresL230
AndresL230 deleted the fix/420-review-followups branch July 28, 2026 03:53
Sign up for freeto 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

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(ocr): review follow-ups for #420 — agent seam, cost ceiling, cache key, sequential rescuers - #424

Merged
AndresL230 merged 5 commits into
worktree-gemini-vision-ocrfrom
fix/420-review-followups
Jul 28, 2026
Merged

fix(ocr): review follow-ups for #420 — agent seam, cost ceiling, cache key, sequential rescuers#424
AndresL230 merged 5 commits into
worktree-gemini-vision-ocrfrom
fix/420-review-followups

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Follow-ups from the review of #420. Targets worktree-gemini-vision-ocr so you can take them as a discrete diff rather than having your branch rewritten under you.

Two commits, disjoint file sets.

1. Route vision transcription through a Pydantic AI agent

The backend built a raw genai.Client and called generate_content directly. Three reasons that's wrong here, and the third is the one that matters:

The run is bounded by WORKER_LIMITS, matching the #329/#345 sweep: an unbounded run inside a per-page loop multiplies one runaway page across the whole document.

Latent bug this surfaced._extract_text_or_422 is sync but called from both async def handlers (routes/documents.py:640, :771), so a bare asyncio.run raises there — and _apply_gemini_vision_fallback's per-page except Exception: continue would have swallowed it. Vision OCR would have silently done nothing on the main upload path. _run_from_anywhere hands the coroutine to a worker thread when a loop is already running, copying the context so agent.override and the active span survive. Pinned by a test.

Module contract unchanged: same function name and signature, same GeminiVisionUnavailableError semantics, GEMINI_VISION_OCR_ENABLED still the switch.

2. Cache key, cost ceiling, sequential rescuers, accurate docs

  • Cache key omitted the model._ocr_cache_key claimed to include "every flag that changes the output" but not the vision model, so switching models kept serving the old transcription for the full 30-day TTL. Model and page cap now in the key, mixed in only when vision is enabled so vision-off deployments keep their entries. GOT_OCR_MODEL_PATH has the same pre-existing gap — the docstring now names it rather than overclaiming.
  • No cost ceiling. Nothing upstream bounds the page count: routes/extract.py allows min(max_pages, 50), the upload path has no rate limit at all, and /api/extract/pdf and /api/extract/image are unauthenticated, unbounded OCR endpoints with no rate limit #182's 10 req/60s was sized when a request meant one bounded local OCR run. GEMINI_VISION_OCR_MAX_PAGES caps it per document (default 10) and logs what it skipped.
  • if/elif made the rescuers mutually exclusive. Enabling both meant vision never ran — including on pages GOT-OCR failed to fill, recreating the exact "signal computed then dropped" bug this feature exists to fix. They now run in sequence, GOT-OCR first (local, free), then vision over what it couldn't fill. GOT-OCR's gate is byte-for-byte unchanged.
  • Three false claims..env.example said an unreadable scan "is rejected" — not on this base; the upload paths convert only extraction exceptions to 422, so "" reaches the classify prompt and the model fabricates. That rejection is fix(documents): reject empty text extraction instead of fabricating a summary #419, still open. The module docstring said vision works on "any engine"; it needs Docling to have run and succeeded. And the cache comment cited ADR 0019 (actually the SAPLING_MODEL_MODE test seam) for content-addressed chunk ids, whose dedup claim is untrue on main and becomes true only under fix(rag): content-hash chunk ids — dedup identical uploads per course #352.

Verification

1099 passed, 23 skipped on the full backend suite; ruff check clean. The one error (test_ocr_pipeline.py::test_save_to_db, event-loop teardown in a live-network test) is pre-existing — I reproduced it identically on unmodified fe1fce6.

Worth your call

  • Page cap default of 10 truncates your motivating document (the 13-page handwritten exam gets 10 pages transcribed, 3 left as Docling had them, with a warning). 20 matches routes/documents.py's max_pages if you'd rather nothing truncates in the default window — at the cost of a 2x worse ceiling on /api/extract.
  • Empty model response → "" is now implemented by catching UnexpectedModelBehavior, since output_type=str makes pydantic-ai retry once then raise. Same document-level behaviour as before, but it does swallow that exception class. Quota and network errors still propagate.
  • The prompt moved to system_prompt with the image as the user message — a different wire shape than contents=[image, prompt]. Worth one live-model spot check before enabling in prod.

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 27, 2026 17:28
The vision OCR call built a raw genai.Client and invoked generate_content
directly. Three reasons that is wrong here, the third load-bearing:
- CLAUDE.md: new LLM-driven code belongs in backend/agents/ as a Pydantic
AI agent, not a fresh client.
- ADR-0008 made agents/_providers.py::model_for(task) the one place a model
is chosen. GEMINI_VISION_OCR_MODEL was a competing knob that bypassed it;
the slot is now SAPLING_MODEL_OCR_VISION like every other agent's.
- Cost attribution. Logfire's instrument_pydantic_ai() tags every
pydantic-ai span with tokens and USD; a raw client call is invisible to
it and to the usage capture #118/PR #375 is building. Vision OCR is one
metered call per scanned page — plausibly the largest per-document LLM
spend in the app, and it would have been the one call the new cost
dashboard could not see.
The run is bounded by WORKER_LIMITS: it sits in a per-page loop, where an
unbounded run multiplies a single runaway page across the whole document.
Also fixes a latent bug this refactor surfaced. _extract_text_or_422 is
sync but called from both async handlers (routes/documents.py:640, :771),
so a bare asyncio.run raises there — and _apply_gemini_vision_fallback's
per-page `except Exception: continue` would have swallowed it, silently
turning vision OCR into a no-op on the main upload path. _run_from_anywhere
hands the coroutine to a worker thread when a loop is already running,
copying the context so agent.override and the active span survive.
The module contract is unchanged: same function name and signature, same
GeminiVisionUnavailableError semantics, GEMINI_VISION_OCR_ENABLED still
the switch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four findings from the review of #420.
Cache key omitted the model. _ocr_cache_key claimed to include "every flag
that changes the output" but not the vision model, so switching models kept
serving the old transcription for the full 30-day TTL. Model and page cap
are now in the key, mixed in only when vision is enabled so the vision-off
majority keeps its existing entries. GOT_OCR_MODEL_PATH has the same
pre-existing gap; the docstring now names it instead of overclaiming.
No cost ceiling. Each flagged page is one metered call, and nothing upstream
bounds the count: routes/extract.py allows min(max_pages, 50) and the upload
path has no rate limit at all. The #182 limit (10 req/60s) was sized when a
request meant one bounded local OCR run. GEMINI_VISION_OCR_MAX_PAGES caps it
per document, default 10, and logs how many pages it left behind — a silent
cap reads downstream as a full transcription.
if/elif made the rescuers mutually exclusive. Enabling both meant vision
never ran, including on pages GOT-OCR failed to fill, recreating the exact
"signal computed then dropped" bug this feature exists to fix. They now run
in sequence — GOT-OCR first (local, free), then vision over what it could
not fill. Both share one driver; GOT-OCR's gate is byte-for-byte unchanged.
Three false claims. .env.example said an unreadable scan "is rejected" — it
is not on this base; the upload paths convert only extraction *exceptions*
to 422, so "" reaches the classify prompt and the model fabricates. That
rejection is PR #419, still open. The module docstring said vision applies
to "any engine"; it needs Docling to have run and succeeded. And the cache
comment cited ADR 0019 (actually the SAPLING_MODEL_MODE test seam) for
content-addressed chunk ids, whose dedup claim is untrue on main and becomes
true only under PR #352.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • ^production$
  • ^staging$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7a66d72b-88bc-452f-8d5d-604f038e3c76

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/420-review-followups

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging6a6c555Commit Preview URL

Branch Preview URL
Jul 28 2026, 03:57 AM

The CI pytest step ignored four files. Three genuinely need what
requirements.lock deliberately excludes: transformers
(test_extraction_backends), docling (test_docling_integration), live
network (test_ocr_pipeline).
test_extraction_service.py needs none of them — it stubs every backend it
exercises. It was swept into the list with its heavy neighbours, and the
consequence is that nothing in it has ever gated a PR: not the OCR engine
gating, not the content-addressed cache key (#97), and not the cost ceiling
and rescuer sequencing added alongside this change. #420's own fallback and
cache-key tests were ungated for the same reason.
Verified against the locked (non-OCR) dependency set CI actually installs,
using CI's exact command and env: 1069 passed, 23 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Locked-deps verification

Ran the branch against a throwaway venv built from requirements.lock — the same hash-pinned, non-OCR set CI installs (pydantic-ai-slim 1.107.0, google-genai 2.9.0, fastapi 0.138.0), not the local dev venv, which is 18 minor versions behind on pydantic-ai and a full major behind on google-genai.

The pydantic-ai API surface held.BinaryContent, GoogleModelSettings timeout conversion, usage_limits, and the UnexpectedModelBehavior-on-empty-output path all behave the same at 1.107.0 as at 1.89.1. That was the risk I expected to find and did not.

What it did surface:tests/test_extraction_service.py was on CI's --ignore list, so nothing in it has ever gated a PR — not the OCR engine gating, not the content-addressed cache key (#97), and not #420's own TestGeminiVisionFallback / TestOcrCacheKey. It was swept in with three neighbours that genuinely need excluded deps (transformers / docling / live network); it stubs every backend it exercises and needs none of them. Dropped from the ignore list in 283fceb.

Verified with CI's exact command and env on the locked set: 1069 passed, 23 skipped, 0 failed.

The 4 test_extraction_backends.py failures under locked deps are ModuleNotFoundError: No module named 'transformers', reproduce identically on unmodified fe1fce6, and stay excluded — legitimately.

Still unverified

No real Gemini vision call has been made anywhere. The prompt reshape (system_prompt + image-only user message vs the old contents=[image, prompt]) is untested against a live model, and there's no scanned-PDF fixture in the repo to exercise the path end to end — tests/fixtures/sample_syllabus.pdf is a text PDF that never populates fallback_pages.

Caught by the first real Gemini call anyone has made against this feature.
Moving the instruction to `system_prompt` during the agent refactor changed
what the model produces. Measured on a rasterized syllabus with known ground
truth (231 chars of source text, 0-char text layer):
prompt as system_prompt -> 743 chars: \documentclass{article}, five
\usepackage lines, \begin{document}, a
tabular, \end{document}
prompt in the user turn -> 359 chars: clean Markdown table
Both transcribe the facts correctly — every assignment, date and type
matches. The difference is that as a system prompt, "Use LaTeX for
mathematics" reads as a document-format directive rather than an instruction
about math notation, so the model emits a whole LaTeX file.
The preamble is not cosmetic. extracted_text feeds the classify, summary and
concept prompts and is chunked into course_chunks for RAG, so "amsmath" and
"booktabs" become candidate concepts on a graph shared by every student in
the course — the same pollution this feature exists to prevent, arriving by a
different door.
Restores the wire shape the original raw-client implementation used
(contents=[image, prompt]), verified to produce 358 chars of clean Markdown
on the same fixture. The agent seam, the ADR-0008 model slot and the cost
attribution are all unaffected — only the placement changes.
The test now pins placement in the user turn and asserts the instruction is
absent from any system prompt. Revert-proof: reintroducing system_prompt
fails it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

First real Gemini call found a regression I introduced

Built a scanned-PDF fixture by rasterizing tests/fixtures/sample_syllabus.pdf — image-only, 0-char text layer, so it actually populates fallback_pages — while keeping the original's 231 chars of text as known ground truth.

Then made one real vision call. The facts came back perfect: every assignment, due date and type matched. But the shape did not.

wire shapeoutput
prompt as system_prompt (this PR, before 9252497)743 chars\documentclass{article}, five \usepackage lines, \begin{document}, tabular, \end{document}
prompt in the user turn (original raw-client shape)358 chars — clean Markdown table
prompt in the user turn (after 9252497)359 chars — clean Markdown table

As a system prompt, "Use LaTeX for mathematics" reads as a document-format directive rather than an instruction about math notation, so the model emits an entire LaTeX file. I introduced this in the agent refactor; the original raw-client implementation did not have it.

Why the preamble matters:extracted_text feeds the classify, summary and concept prompts and is chunked into course_chunks for RAG. Ship it and amsmath, booktabs and graphicx become candidate concepts on a graph shared by every student in the course — the same pollution this feature exists to prevent, arriving through a different door.

Fixed in 9252497 by restoring [image, prompt] in the user turn. The agent seam, the ADR-0008 model slot and the cost attribution are untouched — only placement changed. The test now pins placement and asserts the instruction is absent from any system prompt; I verified it's revert-proof by reintroducing system_prompt and watching it fail.

62 passed on locked deps, ruff clean.

This is the concrete argument for keeping a scanned-PDF fixture in the repo — the entire unit suite passed against this bug, because a FunctionModel will happily return whatever you tell it to.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Live stack results — including a correction

Ran the real thing: local Supabase (main's scripts/local-up.sh), backend on :5000, session via POST /api/auth/test-login as the seeded rich-user-active, real POST /api/documents/upload/sync.

Confirmed working

  • The async seam. A real Gemini vision call from inside a running event loop returns 358 chars of clean Markdown. This is the exact path _run_from_anywhere exists for — asyncio.run would have raised here and the per-page except Exception: continue would have swallowed it. Real model, real loop, no mocks.
  • No LaTeX preamble in that output, confirming 9252497 holds against the live model.
  • The upload path is unbroken on this branch: the scanned PDF classified as syllabus with an accurate summary and four correct concepts (Lab 1: Loops, Lab 2: Recursion, Midterm Project, Final Exam).

Correction: that upload did NOT exercise vision OCR

Docling ships RapidOCR and read the rasterized page itself — 341 chars, fallback_pages = []. The correct summary and concepts came from Docling, not Gemini. My fixture was too easy.

Worth knowing precisely when this feature actually spends money. docling_backend.py:104 flags a page when:

ifchar_count<LOW_CHAR_THRESHOLDormath_flag: # threshold = 40

So vision fires only when RapidOCR also fails (<40 chars) or the page has math-shaped content that isn't LaTeX. That matches #419's evidence, where all 13 pages of a handwritten linear-algebra exam were flagged — RapidOCR handles clean rasterized text fine and fails on handwriting. docling>=2.15,<3 is in requirements.txt, so RapidOCR is present in prod too.

Consequence: a plain scanned text PDF costs nothing, which is the intended design — but it also means the fixture needed to regression-test this path must contain handwriting or unLaTeXed math, not just a missing text layer.

Still open

No end-to-end run has pushed a document through the vision branch of the pipeline. Both halves are proven independently (real model + real loop; Docling flagging + rescue sequencing under unit test), but not composed. That needs a genuinely hard scan as a fixture.

…d loop
Found by the live test added here, which is the only thing that could have
found it: every other test in this feature substitutes the model, and a
FunctionModel has no client and no event loop.
Measured against the live API, calling the seam four times in one process:
call 1: OK 302 chars
call 2: RuntimeError: Event loop is closed
call 3: OK 308 chars
call 4: RuntimeError: Event loop is closed
`_providers._provider` is a module-level GoogleProvider, so its async httpx
client binds to the first loop `asyncio.run` creates and dies when that loop
closes. Every `run_agent_sync` caller shares this — it is #354, and the sweep
is still open in PR #358.
Transcription is the only caller that runs in a LOOP, which turns a latent bug
into an unusable feature: a 10-page scan alternates success and failure page by
page, and `_apply_gemini_vision_fallback`'s per-page `except Exception:
continue` keeps Docling's text without a word. Half a document silently
degrades to the mangled OCR this feature exists to replace.
So this path does not wait for #358. `fresh_ocr_vision_model()` builds a
provider per run and is passed as a per-run `model=` override, leaving the
shared `_provider` untouched so it cannot conflict with whatever #358 lands. It
returns None outside SAPLING_MODEL_MODE=real, where the FunctionModel has no
loop affinity and must not be overridden. Four consecutive live calls now pass.
The fixture is an image-only math worksheet. A missing text layer alone is not
enough to reach vision — Docling ships RapidOCR and reads rasterized prose
fine. This page is reached because `_detect_math_without_latex` flags
math-shaped content carrying no LaTeX, the scanned-math case the feature is
for. Docling alone drops problem 3 entirely as `<!-- formula-not-decoded -->`;
with vision it comes back as `$\sqrt{x^2 + 16} \leq 5$`.
Tests live in the `live_llm` lane, not tests/integration/: they need Docling and
a real model, not Postgres, and that lane's conftest mandates a running
Supabase stack. Opt-in via RUN_LIVE_OCR=1 plus a real key; skipped otherwise, so
CI's dummy key is a clean skip. One test guards the premise and fails loudly if
Docling ever stops flagging the fixture, since the other two would then pass
vacuously.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

The live test found a second, worse bug

Ran the composed path for real. It works — and in doing so it exposed something no mocked test could reach.

Calling the vision seam four times in one process:

call 1: OK 302 chars
call 2: RuntimeError: Event loop is closed
call 3: OK 308 chars
call 4: RuntimeError: Event loop is closed

_providers._provider is a module-level GoogleProvider; its async httpx client binds to the first loop asyncio.run creates and dies with it. This is #354 — every run_agent_sync caller shares it (study_guide, course_context_service, flashcard_import_service), and the sweep is still open in #358, not on main.

Transcription is the only caller that runs in a loop, which turns a latent bug into an unusable feature: a 10-page scan alternates success/failure page by page, and _apply_gemini_vision_fallback's per-page except Exception: continue keeps Docling's text silently. Half a document degrades back to the mangled OCR this feature exists to replace, with nothing logged.

Fixed in 6a6c555 with a per-run provider passed as a model= override — scoped to this agent, leaves the shared _provider alone, so it can't conflict with whatever #358 lands. Four consecutive live calls now pass.

The composed path, verified

Same code, same fixture, only the flag differs:

vision OFFvision ON
chars230302
problem 3 (√)dropped<!-- formula-not-decoded -->recovered$\sqrt{x^2 + 16} \leq 5$
∫x^2∫x~2 dxfrom 0 to 3$\int x^2 dx$
∂f/∂xaf/ax$\partial f/\partial x$

Docling losing an entire problem is the point — content it drops never reaches the concept prompt, so it never reaches the student's graph.

A full upload through the async handler classified it assignment, summarized all four problems including the dropped one, and produced concepts with real LaTeX and no \documentclass wrapper.

About the fixture

A missing text layer is not enough to reach vision. Docling ships RapidOCR and reads rasterized prose perfectly — my first fixture (a rasterized syllabus) came back clean with fallback_pages = [], so vision never ran. The committed fixture is reached because _detect_math_without_latex flags math-shaped content carrying no LaTeX, which is the actual scanned/handwritten-math case. Regenerate with tests/fixtures/make_scanned_math_ps4.py.

Tests are in the live_llm lane, not tests/integration/ — they need Docling and a real model, not Postgres, and that lane's conftest mandates a running Supabase stack. Opt-in via RUN_LIVE_OCR=1 plus a real key, so CI's dummy key is a clean skip. One test guards the premise and fails loudly if Docling stops flagging the fixture, since the other two would otherwise pass vacuously.

Verification: live lane 3 passed; full suite on locked deps 1069 passed, 26 skipped, 0 failed; ruff clean.

@AndresL230
AndresL230 merged commit 6a6c555 into worktree-gemini-vision-ocrJul 28, 2026
4 checks passed
@AndresL230
AndresL230 deleted the fix/420-review-followups branch July 28, 2026 03:53
Sign up for freeto 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

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(ocr): review follow-ups for #420 — agent seam, cost ceiling, cache key, sequential rescuers - #424

Merged
AndresL230 merged 5 commits into
worktree-gemini-vision-ocrfrom
fix/420-review-followups
Jul 28, 2026
Merged

fix(ocr): review follow-ups for #420 — agent seam, cost ceiling, cache key, sequential rescuers#424
AndresL230 merged 5 commits into
worktree-gemini-vision-ocrfrom
fix/420-review-followups

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Follow-ups from the review of #420. Targets worktree-gemini-vision-ocr so you can take them as a discrete diff rather than having your branch rewritten under you.

Two commits, disjoint file sets.

1. Route vision transcription through a Pydantic AI agent

The backend built a raw genai.Client and called generate_content directly. Three reasons that's wrong here, and the third is the one that matters:

The run is bounded by WORKER_LIMITS, matching the #329/#345 sweep: an unbounded run inside a per-page loop multiplies one runaway page across the whole document.

Latent bug this surfaced._extract_text_or_422 is sync but called from both async def handlers (routes/documents.py:640, :771), so a bare asyncio.run raises there — and _apply_gemini_vision_fallback's per-page except Exception: continue would have swallowed it. Vision OCR would have silently done nothing on the main upload path. _run_from_anywhere hands the coroutine to a worker thread when a loop is already running, copying the context so agent.override and the active span survive. Pinned by a test.

Module contract unchanged: same function name and signature, same GeminiVisionUnavailableError semantics, GEMINI_VISION_OCR_ENABLED still the switch.

2. Cache key, cost ceiling, sequential rescuers, accurate docs

  • Cache key omitted the model._ocr_cache_key claimed to include "every flag that changes the output" but not the vision model, so switching models kept serving the old transcription for the full 30-day TTL. Model and page cap now in the key, mixed in only when vision is enabled so vision-off deployments keep their entries. GOT_OCR_MODEL_PATH has the same pre-existing gap — the docstring now names it rather than overclaiming.
  • No cost ceiling. Nothing upstream bounds the page count: routes/extract.py allows min(max_pages, 50), the upload path has no rate limit at all, and /api/extract/pdf and /api/extract/image are unauthenticated, unbounded OCR endpoints with no rate limit #182's 10 req/60s was sized when a request meant one bounded local OCR run. GEMINI_VISION_OCR_MAX_PAGES caps it per document (default 10) and logs what it skipped.
  • if/elif made the rescuers mutually exclusive. Enabling both meant vision never ran — including on pages GOT-OCR failed to fill, recreating the exact "signal computed then dropped" bug this feature exists to fix. They now run in sequence, GOT-OCR first (local, free), then vision over what it couldn't fill. GOT-OCR's gate is byte-for-byte unchanged.
  • Three false claims..env.example said an unreadable scan "is rejected" — not on this base; the upload paths convert only extraction exceptions to 422, so "" reaches the classify prompt and the model fabricates. That rejection is fix(documents): reject empty text extraction instead of fabricating a summary #419, still open. The module docstring said vision works on "any engine"; it needs Docling to have run and succeeded. And the cache comment cited ADR 0019 (actually the SAPLING_MODEL_MODE test seam) for content-addressed chunk ids, whose dedup claim is untrue on main and becomes true only under fix(rag): content-hash chunk ids — dedup identical uploads per course #352.

Verification

1099 passed, 23 skipped on the full backend suite; ruff check clean. The one error (test_ocr_pipeline.py::test_save_to_db, event-loop teardown in a live-network test) is pre-existing — I reproduced it identically on unmodified fe1fce6.

Worth your call

  • Page cap default of 10 truncates your motivating document (the 13-page handwritten exam gets 10 pages transcribed, 3 left as Docling had them, with a warning). 20 matches routes/documents.py's max_pages if you'd rather nothing truncates in the default window — at the cost of a 2x worse ceiling on /api/extract.
  • Empty model response → "" is now implemented by catching UnexpectedModelBehavior, since output_type=str makes pydantic-ai retry once then raise. Same document-level behaviour as before, but it does swallow that exception class. Quota and network errors still propagate.
  • The prompt moved to system_prompt with the image as the user message — a different wire shape than contents=[image, prompt]. Worth one live-model spot check before enabling in prod.

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 27, 2026 17:28
The vision OCR call built a raw genai.Client and invoked generate_content
directly. Three reasons that is wrong here, the third load-bearing:
- CLAUDE.md: new LLM-driven code belongs in backend/agents/ as a Pydantic
AI agent, not a fresh client.
- ADR-0008 made agents/_providers.py::model_for(task) the one place a model
is chosen. GEMINI_VISION_OCR_MODEL was a competing knob that bypassed it;
the slot is now SAPLING_MODEL_OCR_VISION like every other agent's.
- Cost attribution. Logfire's instrument_pydantic_ai() tags every
pydantic-ai span with tokens and USD; a raw client call is invisible to
it and to the usage capture #118/PR #375 is building. Vision OCR is one
metered call per scanned page — plausibly the largest per-document LLM
spend in the app, and it would have been the one call the new cost
dashboard could not see.
The run is bounded by WORKER_LIMITS: it sits in a per-page loop, where an
unbounded run multiplies a single runaway page across the whole document.
Also fixes a latent bug this refactor surfaced. _extract_text_or_422 is
sync but called from both async handlers (routes/documents.py:640, :771),
so a bare asyncio.run raises there — and _apply_gemini_vision_fallback's
per-page `except Exception: continue` would have swallowed it, silently
turning vision OCR into a no-op on the main upload path. _run_from_anywhere
hands the coroutine to a worker thread when a loop is already running,
copying the context so agent.override and the active span survive.
The module contract is unchanged: same function name and signature, same
GeminiVisionUnavailableError semantics, GEMINI_VISION_OCR_ENABLED still
the switch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four findings from the review of #420.
Cache key omitted the model. _ocr_cache_key claimed to include "every flag
that changes the output" but not the vision model, so switching models kept
serving the old transcription for the full 30-day TTL. Model and page cap
are now in the key, mixed in only when vision is enabled so the vision-off
majority keeps its existing entries. GOT_OCR_MODEL_PATH has the same
pre-existing gap; the docstring now names it instead of overclaiming.
No cost ceiling. Each flagged page is one metered call, and nothing upstream
bounds the count: routes/extract.py allows min(max_pages, 50) and the upload
path has no rate limit at all. The #182 limit (10 req/60s) was sized when a
request meant one bounded local OCR run. GEMINI_VISION_OCR_MAX_PAGES caps it
per document, default 10, and logs how many pages it left behind — a silent
cap reads downstream as a full transcription.
if/elif made the rescuers mutually exclusive. Enabling both meant vision
never ran, including on pages GOT-OCR failed to fill, recreating the exact
"signal computed then dropped" bug this feature exists to fix. They now run
in sequence — GOT-OCR first (local, free), then vision over what it could
not fill. Both share one driver; GOT-OCR's gate is byte-for-byte unchanged.
Three false claims. .env.example said an unreadable scan "is rejected" — it
is not on this base; the upload paths convert only extraction *exceptions*
to 422, so "" reaches the classify prompt and the model fabricates. That
rejection is PR #419, still open. The module docstring said vision applies
to "any engine"; it needs Docling to have run and succeeded. And the cache
comment cited ADR 0019 (actually the SAPLING_MODEL_MODE test seam) for
content-addressed chunk ids, whose dedup claim is untrue on main and becomes
true only under PR #352.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • ^production$
  • ^staging$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7a66d72b-88bc-452f-8d5d-604f038e3c76

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/420-review-followups

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging6a6c555Commit Preview URL

Branch Preview URL
Jul 28 2026, 03:57 AM

The CI pytest step ignored four files. Three genuinely need what
requirements.lock deliberately excludes: transformers
(test_extraction_backends), docling (test_docling_integration), live
network (test_ocr_pipeline).
test_extraction_service.py needs none of them — it stubs every backend it
exercises. It was swept into the list with its heavy neighbours, and the
consequence is that nothing in it has ever gated a PR: not the OCR engine
gating, not the content-addressed cache key (#97), and not the cost ceiling
and rescuer sequencing added alongside this change. #420's own fallback and
cache-key tests were ungated for the same reason.
Verified against the locked (non-OCR) dependency set CI actually installs,
using CI's exact command and env: 1069 passed, 23 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Locked-deps verification

Ran the branch against a throwaway venv built from requirements.lock — the same hash-pinned, non-OCR set CI installs (pydantic-ai-slim 1.107.0, google-genai 2.9.0, fastapi 0.138.0), not the local dev venv, which is 18 minor versions behind on pydantic-ai and a full major behind on google-genai.

The pydantic-ai API surface held.BinaryContent, GoogleModelSettings timeout conversion, usage_limits, and the UnexpectedModelBehavior-on-empty-output path all behave the same at 1.107.0 as at 1.89.1. That was the risk I expected to find and did not.

What it did surface:tests/test_extraction_service.py was on CI's --ignore list, so nothing in it has ever gated a PR — not the OCR engine gating, not the content-addressed cache key (#97), and not #420's own TestGeminiVisionFallback / TestOcrCacheKey. It was swept in with three neighbours that genuinely need excluded deps (transformers / docling / live network); it stubs every backend it exercises and needs none of them. Dropped from the ignore list in 283fceb.

Verified with CI's exact command and env on the locked set: 1069 passed, 23 skipped, 0 failed.

The 4 test_extraction_backends.py failures under locked deps are ModuleNotFoundError: No module named 'transformers', reproduce identically on unmodified fe1fce6, and stay excluded — legitimately.

Still unverified

No real Gemini vision call has been made anywhere. The prompt reshape (system_prompt + image-only user message vs the old contents=[image, prompt]) is untested against a live model, and there's no scanned-PDF fixture in the repo to exercise the path end to end — tests/fixtures/sample_syllabus.pdf is a text PDF that never populates fallback_pages.

Caught by the first real Gemini call anyone has made against this feature.
Moving the instruction to `system_prompt` during the agent refactor changed
what the model produces. Measured on a rasterized syllabus with known ground
truth (231 chars of source text, 0-char text layer):
prompt as system_prompt -> 743 chars: \documentclass{article}, five
\usepackage lines, \begin{document}, a
tabular, \end{document}
prompt in the user turn -> 359 chars: clean Markdown table
Both transcribe the facts correctly — every assignment, date and type
matches. The difference is that as a system prompt, "Use LaTeX for
mathematics" reads as a document-format directive rather than an instruction
about math notation, so the model emits a whole LaTeX file.
The preamble is not cosmetic. extracted_text feeds the classify, summary and
concept prompts and is chunked into course_chunks for RAG, so "amsmath" and
"booktabs" become candidate concepts on a graph shared by every student in
the course — the same pollution this feature exists to prevent, arriving by a
different door.
Restores the wire shape the original raw-client implementation used
(contents=[image, prompt]), verified to produce 358 chars of clean Markdown
on the same fixture. The agent seam, the ADR-0008 model slot and the cost
attribution are all unaffected — only the placement changes.
The test now pins placement in the user turn and asserts the instruction is
absent from any system prompt. Revert-proof: reintroducing system_prompt
fails it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

First real Gemini call found a regression I introduced

Built a scanned-PDF fixture by rasterizing tests/fixtures/sample_syllabus.pdf — image-only, 0-char text layer, so it actually populates fallback_pages — while keeping the original's 231 chars of text as known ground truth.

Then made one real vision call. The facts came back perfect: every assignment, due date and type matched. But the shape did not.

wire shapeoutput
prompt as system_prompt (this PR, before 9252497)743 chars\documentclass{article}, five \usepackage lines, \begin{document}, tabular, \end{document}
prompt in the user turn (original raw-client shape)358 chars — clean Markdown table
prompt in the user turn (after 9252497)359 chars — clean Markdown table

As a system prompt, "Use LaTeX for mathematics" reads as a document-format directive rather than an instruction about math notation, so the model emits an entire LaTeX file. I introduced this in the agent refactor; the original raw-client implementation did not have it.

Why the preamble matters:extracted_text feeds the classify, summary and concept prompts and is chunked into course_chunks for RAG. Ship it and amsmath, booktabs and graphicx become candidate concepts on a graph shared by every student in the course — the same pollution this feature exists to prevent, arriving through a different door.

Fixed in 9252497 by restoring [image, prompt] in the user turn. The agent seam, the ADR-0008 model slot and the cost attribution are untouched — only placement changed. The test now pins placement and asserts the instruction is absent from any system prompt; I verified it's revert-proof by reintroducing system_prompt and watching it fail.

62 passed on locked deps, ruff clean.

This is the concrete argument for keeping a scanned-PDF fixture in the repo — the entire unit suite passed against this bug, because a FunctionModel will happily return whatever you tell it to.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Live stack results — including a correction

Ran the real thing: local Supabase (main's scripts/local-up.sh), backend on :5000, session via POST /api/auth/test-login as the seeded rich-user-active, real POST /api/documents/upload/sync.

Confirmed working

  • The async seam. A real Gemini vision call from inside a running event loop returns 358 chars of clean Markdown. This is the exact path _run_from_anywhere exists for — asyncio.run would have raised here and the per-page except Exception: continue would have swallowed it. Real model, real loop, no mocks.
  • No LaTeX preamble in that output, confirming 9252497 holds against the live model.
  • The upload path is unbroken on this branch: the scanned PDF classified as syllabus with an accurate summary and four correct concepts (Lab 1: Loops, Lab 2: Recursion, Midterm Project, Final Exam).

Correction: that upload did NOT exercise vision OCR

Docling ships RapidOCR and read the rasterized page itself — 341 chars, fallback_pages = []. The correct summary and concepts came from Docling, not Gemini. My fixture was too easy.

Worth knowing precisely when this feature actually spends money. docling_backend.py:104 flags a page when:

ifchar_count<LOW_CHAR_THRESHOLDormath_flag: # threshold = 40

So vision fires only when RapidOCR also fails (<40 chars) or the page has math-shaped content that isn't LaTeX. That matches #419's evidence, where all 13 pages of a handwritten linear-algebra exam were flagged — RapidOCR handles clean rasterized text fine and fails on handwriting. docling>=2.15,<3 is in requirements.txt, so RapidOCR is present in prod too.

Consequence: a plain scanned text PDF costs nothing, which is the intended design — but it also means the fixture needed to regression-test this path must contain handwriting or unLaTeXed math, not just a missing text layer.

Still open

No end-to-end run has pushed a document through the vision branch of the pipeline. Both halves are proven independently (real model + real loop; Docling flagging + rescue sequencing under unit test), but not composed. That needs a genuinely hard scan as a fixture.

…d loop
Found by the live test added here, which is the only thing that could have
found it: every other test in this feature substitutes the model, and a
FunctionModel has no client and no event loop.
Measured against the live API, calling the seam four times in one process:
call 1: OK 302 chars
call 2: RuntimeError: Event loop is closed
call 3: OK 308 chars
call 4: RuntimeError: Event loop is closed
`_providers._provider` is a module-level GoogleProvider, so its async httpx
client binds to the first loop `asyncio.run` creates and dies when that loop
closes. Every `run_agent_sync` caller shares this — it is #354, and the sweep
is still open in PR #358.
Transcription is the only caller that runs in a LOOP, which turns a latent bug
into an unusable feature: a 10-page scan alternates success and failure page by
page, and `_apply_gemini_vision_fallback`'s per-page `except Exception:
continue` keeps Docling's text without a word. Half a document silently
degrades to the mangled OCR this feature exists to replace.
So this path does not wait for #358. `fresh_ocr_vision_model()` builds a
provider per run and is passed as a per-run `model=` override, leaving the
shared `_provider` untouched so it cannot conflict with whatever #358 lands. It
returns None outside SAPLING_MODEL_MODE=real, where the FunctionModel has no
loop affinity and must not be overridden. Four consecutive live calls now pass.
The fixture is an image-only math worksheet. A missing text layer alone is not
enough to reach vision — Docling ships RapidOCR and reads rasterized prose
fine. This page is reached because `_detect_math_without_latex` flags
math-shaped content carrying no LaTeX, the scanned-math case the feature is
for. Docling alone drops problem 3 entirely as `<!-- formula-not-decoded -->`;
with vision it comes back as `$\sqrt{x^2 + 16} \leq 5$`.
Tests live in the `live_llm` lane, not tests/integration/: they need Docling and
a real model, not Postgres, and that lane's conftest mandates a running
Supabase stack. Opt-in via RUN_LIVE_OCR=1 plus a real key; skipped otherwise, so
CI's dummy key is a clean skip. One test guards the premise and fails loudly if
Docling ever stops flagging the fixture, since the other two would then pass
vacuously.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

The live test found a second, worse bug

Ran the composed path for real. It works — and in doing so it exposed something no mocked test could reach.

Calling the vision seam four times in one process:

call 1: OK 302 chars
call 2: RuntimeError: Event loop is closed
call 3: OK 308 chars
call 4: RuntimeError: Event loop is closed

_providers._provider is a module-level GoogleProvider; its async httpx client binds to the first loop asyncio.run creates and dies with it. This is #354 — every run_agent_sync caller shares it (study_guide, course_context_service, flashcard_import_service), and the sweep is still open in #358, not on main.

Transcription is the only caller that runs in a loop, which turns a latent bug into an unusable feature: a 10-page scan alternates success/failure page by page, and _apply_gemini_vision_fallback's per-page except Exception: continue keeps Docling's text silently. Half a document degrades back to the mangled OCR this feature exists to replace, with nothing logged.

Fixed in 6a6c555 with a per-run provider passed as a model= override — scoped to this agent, leaves the shared _provider alone, so it can't conflict with whatever #358 lands. Four consecutive live calls now pass.

The composed path, verified

Same code, same fixture, only the flag differs:

vision OFFvision ON
chars230302
problem 3 (√)dropped<!-- formula-not-decoded -->recovered$\sqrt{x^2 + 16} \leq 5$
∫x^2∫x~2 dxfrom 0 to 3$\int x^2 dx$
∂f/∂xaf/ax$\partial f/\partial x$

Docling losing an entire problem is the point — content it drops never reaches the concept prompt, so it never reaches the student's graph.

A full upload through the async handler classified it assignment, summarized all four problems including the dropped one, and produced concepts with real LaTeX and no \documentclass wrapper.

About the fixture

A missing text layer is not enough to reach vision. Docling ships RapidOCR and reads rasterized prose perfectly — my first fixture (a rasterized syllabus) came back clean with fallback_pages = [], so vision never ran. The committed fixture is reached because _detect_math_without_latex flags math-shaped content carrying no LaTeX, which is the actual scanned/handwritten-math case. Regenerate with tests/fixtures/make_scanned_math_ps4.py.

Tests are in the live_llm lane, not tests/integration/ — they need Docling and a real model, not Postgres, and that lane's conftest mandates a running Supabase stack. Opt-in via RUN_LIVE_OCR=1 plus a real key, so CI's dummy key is a clean skip. One test guards the premise and fails loudly if Docling stops flagging the fixture, since the other two would otherwise pass vacuously.

Verification: live lane 3 passed; full suite on locked deps 1069 passed, 26 skipped, 0 failed; ruff clean.

@AndresL230
AndresL230 merged commit 6a6c555 into worktree-gemini-vision-ocrJul 28, 2026
4 checks passed
@AndresL230
AndresL230 deleted the fix/420-review-followups branch July 28, 2026 03:53
Sign up for freeto 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

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(ocr): review follow-ups for #420 — agent seam, cost ceiling, cache key, sequential rescuers - #424

Merged
AndresL230 merged 5 commits into
worktree-gemini-vision-ocrfrom
fix/420-review-followups
Jul 28, 2026
Merged

fix(ocr): review follow-ups for #420 — agent seam, cost ceiling, cache key, sequential rescuers#424
AndresL230 merged 5 commits into
worktree-gemini-vision-ocrfrom
fix/420-review-followups

Conversation

@AndresL230

Copy link
Copy Markdown
Collaborator

Follow-ups from the review of #420. Targets worktree-gemini-vision-ocr so you can take them as a discrete diff rather than having your branch rewritten under you.

Two commits, disjoint file sets.

1. Route vision transcription through a Pydantic AI agent

The backend built a raw genai.Client and called generate_content directly. Three reasons that's wrong here, and the third is the one that matters:

The run is bounded by WORKER_LIMITS, matching the #329/#345 sweep: an unbounded run inside a per-page loop multiplies one runaway page across the whole document.

Latent bug this surfaced._extract_text_or_422 is sync but called from both async def handlers (routes/documents.py:640, :771), so a bare asyncio.run raises there — and _apply_gemini_vision_fallback's per-page except Exception: continue would have swallowed it. Vision OCR would have silently done nothing on the main upload path. _run_from_anywhere hands the coroutine to a worker thread when a loop is already running, copying the context so agent.override and the active span survive. Pinned by a test.

Module contract unchanged: same function name and signature, same GeminiVisionUnavailableError semantics, GEMINI_VISION_OCR_ENABLED still the switch.

2. Cache key, cost ceiling, sequential rescuers, accurate docs

  • Cache key omitted the model._ocr_cache_key claimed to include "every flag that changes the output" but not the vision model, so switching models kept serving the old transcription for the full 30-day TTL. Model and page cap now in the key, mixed in only when vision is enabled so vision-off deployments keep their entries. GOT_OCR_MODEL_PATH has the same pre-existing gap — the docstring now names it rather than overclaiming.
  • No cost ceiling. Nothing upstream bounds the page count: routes/extract.py allows min(max_pages, 50), the upload path has no rate limit at all, and /api/extract/pdf and /api/extract/image are unauthenticated, unbounded OCR endpoints with no rate limit #182's 10 req/60s was sized when a request meant one bounded local OCR run. GEMINI_VISION_OCR_MAX_PAGES caps it per document (default 10) and logs what it skipped.
  • if/elif made the rescuers mutually exclusive. Enabling both meant vision never ran — including on pages GOT-OCR failed to fill, recreating the exact "signal computed then dropped" bug this feature exists to fix. They now run in sequence, GOT-OCR first (local, free), then vision over what it couldn't fill. GOT-OCR's gate is byte-for-byte unchanged.
  • Three false claims..env.example said an unreadable scan "is rejected" — not on this base; the upload paths convert only extraction exceptions to 422, so "" reaches the classify prompt and the model fabricates. That rejection is fix(documents): reject empty text extraction instead of fabricating a summary #419, still open. The module docstring said vision works on "any engine"; it needs Docling to have run and succeeded. And the cache comment cited ADR 0019 (actually the SAPLING_MODEL_MODE test seam) for content-addressed chunk ids, whose dedup claim is untrue on main and becomes true only under fix(rag): content-hash chunk ids — dedup identical uploads per course #352.

Verification

1099 passed, 23 skipped on the full backend suite; ruff check clean. The one error (test_ocr_pipeline.py::test_save_to_db, event-loop teardown in a live-network test) is pre-existing — I reproduced it identically on unmodified fe1fce6.

Worth your call

  • Page cap default of 10 truncates your motivating document (the 13-page handwritten exam gets 10 pages transcribed, 3 left as Docling had them, with a warning). 20 matches routes/documents.py's max_pages if you'd rather nothing truncates in the default window — at the cost of a 2x worse ceiling on /api/extract.
  • Empty model response → "" is now implemented by catching UnexpectedModelBehavior, since output_type=str makes pydantic-ai retry once then raise. Same document-level behaviour as before, but it does swallow that exception class. Quota and network errors still propagate.
  • The prompt moved to system_prompt with the image as the user message — a different wire shape than contents=[image, prompt]. Worth one live-model spot check before enabling in prod.

🤖 Generated with Claude Code

AndresL230and others added 2 commits July 27, 2026 17:28
The vision OCR call built a raw genai.Client and invoked generate_content
directly. Three reasons that is wrong here, the third load-bearing:
- CLAUDE.md: new LLM-driven code belongs in backend/agents/ as a Pydantic
AI agent, not a fresh client.
- ADR-0008 made agents/_providers.py::model_for(task) the one place a model
is chosen. GEMINI_VISION_OCR_MODEL was a competing knob that bypassed it;
the slot is now SAPLING_MODEL_OCR_VISION like every other agent's.
- Cost attribution. Logfire's instrument_pydantic_ai() tags every
pydantic-ai span with tokens and USD; a raw client call is invisible to
it and to the usage capture #118/PR #375 is building. Vision OCR is one
metered call per scanned page — plausibly the largest per-document LLM
spend in the app, and it would have been the one call the new cost
dashboard could not see.
The run is bounded by WORKER_LIMITS: it sits in a per-page loop, where an
unbounded run multiplies a single runaway page across the whole document.
Also fixes a latent bug this refactor surfaced. _extract_text_or_422 is
sync but called from both async handlers (routes/documents.py:640, :771),
so a bare asyncio.run raises there — and _apply_gemini_vision_fallback's
per-page `except Exception: continue` would have swallowed it, silently
turning vision OCR into a no-op on the main upload path. _run_from_anywhere
hands the coroutine to a worker thread when a loop is already running,
copying the context so agent.override and the active span survive.
The module contract is unchanged: same function name and signature, same
GeminiVisionUnavailableError semantics, GEMINI_VISION_OCR_ENABLED still
the switch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four findings from the review of #420.
Cache key omitted the model. _ocr_cache_key claimed to include "every flag
that changes the output" but not the vision model, so switching models kept
serving the old transcription for the full 30-day TTL. Model and page cap
are now in the key, mixed in only when vision is enabled so the vision-off
majority keeps its existing entries. GOT_OCR_MODEL_PATH has the same
pre-existing gap; the docstring now names it instead of overclaiming.
No cost ceiling. Each flagged page is one metered call, and nothing upstream
bounds the count: routes/extract.py allows min(max_pages, 50) and the upload
path has no rate limit at all. The #182 limit (10 req/60s) was sized when a
request meant one bounded local OCR run. GEMINI_VISION_OCR_MAX_PAGES caps it
per document, default 10, and logs how many pages it left behind — a silent
cap reads downstream as a full transcription.
if/elif made the rescuers mutually exclusive. Enabling both meant vision
never ran, including on pages GOT-OCR failed to fill, recreating the exact
"signal computed then dropped" bug this feature exists to fix. They now run
in sequence — GOT-OCR first (local, free), then vision over what it could
not fill. Both share one driver; GOT-OCR's gate is byte-for-byte unchanged.
Three false claims. .env.example said an unreadable scan "is rejected" — it
is not on this base; the upload paths convert only extraction *exceptions*
to 422, so "" reaches the classify prompt and the model fabricates. That
rejection is PR #419, still open. The module docstring said vision applies
to "any engine"; it needs Docling to have run and succeeded. And the cache
comment cited ADR 0019 (actually the SAPLING_MODEL_MODE test seam) for
content-addressed chunk ids, whose dedup claim is untrue on main and becomes
true only under PR #352.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • ^production$
  • ^staging$

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7a66d72b-88bc-452f-8d5d-604f038e3c76

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/420-review-followups

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Jul 28, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-staging6a6c555Commit Preview URL

Branch Preview URL
Jul 28 2026, 03:57 AM

The CI pytest step ignored four files. Three genuinely need what
requirements.lock deliberately excludes: transformers
(test_extraction_backends), docling (test_docling_integration), live
network (test_ocr_pipeline).
test_extraction_service.py needs none of them — it stubs every backend it
exercises. It was swept into the list with its heavy neighbours, and the
consequence is that nothing in it has ever gated a PR: not the OCR engine
gating, not the content-addressed cache key (#97), and not the cost ceiling
and rescuer sequencing added alongside this change. #420's own fallback and
cache-key tests were ungated for the same reason.
Verified against the locked (non-OCR) dependency set CI actually installs,
using CI's exact command and env: 1069 passed, 23 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Locked-deps verification

Ran the branch against a throwaway venv built from requirements.lock — the same hash-pinned, non-OCR set CI installs (pydantic-ai-slim 1.107.0, google-genai 2.9.0, fastapi 0.138.0), not the local dev venv, which is 18 minor versions behind on pydantic-ai and a full major behind on google-genai.

The pydantic-ai API surface held.BinaryContent, GoogleModelSettings timeout conversion, usage_limits, and the UnexpectedModelBehavior-on-empty-output path all behave the same at 1.107.0 as at 1.89.1. That was the risk I expected to find and did not.

What it did surface:tests/test_extraction_service.py was on CI's --ignore list, so nothing in it has ever gated a PR — not the OCR engine gating, not the content-addressed cache key (#97), and not #420's own TestGeminiVisionFallback / TestOcrCacheKey. It was swept in with three neighbours that genuinely need excluded deps (transformers / docling / live network); it stubs every backend it exercises and needs none of them. Dropped from the ignore list in 283fceb.

Verified with CI's exact command and env on the locked set: 1069 passed, 23 skipped, 0 failed.

The 4 test_extraction_backends.py failures under locked deps are ModuleNotFoundError: No module named 'transformers', reproduce identically on unmodified fe1fce6, and stay excluded — legitimately.

Still unverified

No real Gemini vision call has been made anywhere. The prompt reshape (system_prompt + image-only user message vs the old contents=[image, prompt]) is untested against a live model, and there's no scanned-PDF fixture in the repo to exercise the path end to end — tests/fixtures/sample_syllabus.pdf is a text PDF that never populates fallback_pages.

Caught by the first real Gemini call anyone has made against this feature.
Moving the instruction to `system_prompt` during the agent refactor changed
what the model produces. Measured on a rasterized syllabus with known ground
truth (231 chars of source text, 0-char text layer):
prompt as system_prompt -> 743 chars: \documentclass{article}, five
\usepackage lines, \begin{document}, a
tabular, \end{document}
prompt in the user turn -> 359 chars: clean Markdown table
Both transcribe the facts correctly — every assignment, date and type
matches. The difference is that as a system prompt, "Use LaTeX for
mathematics" reads as a document-format directive rather than an instruction
about math notation, so the model emits a whole LaTeX file.
The preamble is not cosmetic. extracted_text feeds the classify, summary and
concept prompts and is chunked into course_chunks for RAG, so "amsmath" and
"booktabs" become candidate concepts on a graph shared by every student in
the course — the same pollution this feature exists to prevent, arriving by a
different door.
Restores the wire shape the original raw-client implementation used
(contents=[image, prompt]), verified to produce 358 chars of clean Markdown
on the same fixture. The agent seam, the ADR-0008 model slot and the cost
attribution are all unaffected — only the placement changes.
The test now pins placement in the user turn and asserts the instruction is
absent from any system prompt. Revert-proof: reintroducing system_prompt
fails it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

First real Gemini call found a regression I introduced

Built a scanned-PDF fixture by rasterizing tests/fixtures/sample_syllabus.pdf — image-only, 0-char text layer, so it actually populates fallback_pages — while keeping the original's 231 chars of text as known ground truth.

Then made one real vision call. The facts came back perfect: every assignment, due date and type matched. But the shape did not.

wire shapeoutput
prompt as system_prompt (this PR, before 9252497)743 chars\documentclass{article}, five \usepackage lines, \begin{document}, tabular, \end{document}
prompt in the user turn (original raw-client shape)358 chars — clean Markdown table
prompt in the user turn (after 9252497)359 chars — clean Markdown table

As a system prompt, "Use LaTeX for mathematics" reads as a document-format directive rather than an instruction about math notation, so the model emits an entire LaTeX file. I introduced this in the agent refactor; the original raw-client implementation did not have it.

Why the preamble matters:extracted_text feeds the classify, summary and concept prompts and is chunked into course_chunks for RAG. Ship it and amsmath, booktabs and graphicx become candidate concepts on a graph shared by every student in the course — the same pollution this feature exists to prevent, arriving through a different door.

Fixed in 9252497 by restoring [image, prompt] in the user turn. The agent seam, the ADR-0008 model slot and the cost attribution are untouched — only placement changed. The test now pins placement and asserts the instruction is absent from any system prompt; I verified it's revert-proof by reintroducing system_prompt and watching it fail.

62 passed on locked deps, ruff clean.

This is the concrete argument for keeping a scanned-PDF fixture in the repo — the entire unit suite passed against this bug, because a FunctionModel will happily return whatever you tell it to.

@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

Live stack results — including a correction

Ran the real thing: local Supabase (main's scripts/local-up.sh), backend on :5000, session via POST /api/auth/test-login as the seeded rich-user-active, real POST /api/documents/upload/sync.

Confirmed working

  • The async seam. A real Gemini vision call from inside a running event loop returns 358 chars of clean Markdown. This is the exact path _run_from_anywhere exists for — asyncio.run would have raised here and the per-page except Exception: continue would have swallowed it. Real model, real loop, no mocks.
  • No LaTeX preamble in that output, confirming 9252497 holds against the live model.
  • The upload path is unbroken on this branch: the scanned PDF classified as syllabus with an accurate summary and four correct concepts (Lab 1: Loops, Lab 2: Recursion, Midterm Project, Final Exam).

Correction: that upload did NOT exercise vision OCR

Docling ships RapidOCR and read the rasterized page itself — 341 chars, fallback_pages = []. The correct summary and concepts came from Docling, not Gemini. My fixture was too easy.

Worth knowing precisely when this feature actually spends money. docling_backend.py:104 flags a page when:

ifchar_count<LOW_CHAR_THRESHOLDormath_flag: # threshold = 40

So vision fires only when RapidOCR also fails (<40 chars) or the page has math-shaped content that isn't LaTeX. That matches #419's evidence, where all 13 pages of a handwritten linear-algebra exam were flagged — RapidOCR handles clean rasterized text fine and fails on handwriting. docling>=2.15,<3 is in requirements.txt, so RapidOCR is present in prod too.

Consequence: a plain scanned text PDF costs nothing, which is the intended design — but it also means the fixture needed to regression-test this path must contain handwriting or unLaTeXed math, not just a missing text layer.

Still open

No end-to-end run has pushed a document through the vision branch of the pipeline. Both halves are proven independently (real model + real loop; Docling flagging + rescue sequencing under unit test), but not composed. That needs a genuinely hard scan as a fixture.

…d loop
Found by the live test added here, which is the only thing that could have
found it: every other test in this feature substitutes the model, and a
FunctionModel has no client and no event loop.
Measured against the live API, calling the seam four times in one process:
call 1: OK 302 chars
call 2: RuntimeError: Event loop is closed
call 3: OK 308 chars
call 4: RuntimeError: Event loop is closed
`_providers._provider` is a module-level GoogleProvider, so its async httpx
client binds to the first loop `asyncio.run` creates and dies when that loop
closes. Every `run_agent_sync` caller shares this — it is #354, and the sweep
is still open in PR #358.
Transcription is the only caller that runs in a LOOP, which turns a latent bug
into an unusable feature: a 10-page scan alternates success and failure page by
page, and `_apply_gemini_vision_fallback`'s per-page `except Exception:
continue` keeps Docling's text without a word. Half a document silently
degrades to the mangled OCR this feature exists to replace.
So this path does not wait for #358. `fresh_ocr_vision_model()` builds a
provider per run and is passed as a per-run `model=` override, leaving the
shared `_provider` untouched so it cannot conflict with whatever #358 lands. It
returns None outside SAPLING_MODEL_MODE=real, where the FunctionModel has no
loop affinity and must not be overridden. Four consecutive live calls now pass.
The fixture is an image-only math worksheet. A missing text layer alone is not
enough to reach vision — Docling ships RapidOCR and reads rasterized prose
fine. This page is reached because `_detect_math_without_latex` flags
math-shaped content carrying no LaTeX, the scanned-math case the feature is
for. Docling alone drops problem 3 entirely as `<!-- formula-not-decoded -->`;
with vision it comes back as `$\sqrt{x^2 + 16} \leq 5$`.
Tests live in the `live_llm` lane, not tests/integration/: they need Docling and
a real model, not Postgres, and that lane's conftest mandates a running
Supabase stack. Opt-in via RUN_LIVE_OCR=1 plus a real key; skipped otherwise, so
CI's dummy key is a clean skip. One test guards the premise and fails loudly if
Docling ever stops flagging the fixture, since the other two would then pass
vacuously.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@AndresL230

Copy link
Copy Markdown
CollaboratorAuthor

The live test found a second, worse bug

Ran the composed path for real. It works — and in doing so it exposed something no mocked test could reach.

Calling the vision seam four times in one process:

call 1: OK 302 chars
call 2: RuntimeError: Event loop is closed
call 3: OK 308 chars
call 4: RuntimeError: Event loop is closed

_providers._provider is a module-level GoogleProvider; its async httpx client binds to the first loop asyncio.run creates and dies with it. This is #354 — every run_agent_sync caller shares it (study_guide, course_context_service, flashcard_import_service), and the sweep is still open in #358, not on main.

Transcription is the only caller that runs in a loop, which turns a latent bug into an unusable feature: a 10-page scan alternates success/failure page by page, and _apply_gemini_vision_fallback's per-page except Exception: continue keeps Docling's text silently. Half a document degrades back to the mangled OCR this feature exists to replace, with nothing logged.

Fixed in 6a6c555 with a per-run provider passed as a model= override — scoped to this agent, leaves the shared _provider alone, so it can't conflict with whatever #358 lands. Four consecutive live calls now pass.

The composed path, verified

Same code, same fixture, only the flag differs:

vision OFFvision ON
chars230302
problem 3 (√)dropped<!-- formula-not-decoded -->recovered$\sqrt{x^2 + 16} \leq 5$
∫x^2∫x~2 dxfrom 0 to 3$\int x^2 dx$
∂f/∂xaf/ax$\partial f/\partial x$

Docling losing an entire problem is the point — content it drops never reaches the concept prompt, so it never reaches the student's graph.

A full upload through the async handler classified it assignment, summarized all four problems including the dropped one, and produced concepts with real LaTeX and no \documentclass wrapper.

About the fixture

A missing text layer is not enough to reach vision. Docling ships RapidOCR and reads rasterized prose perfectly — my first fixture (a rasterized syllabus) came back clean with fallback_pages = [], so vision never ran. The committed fixture is reached because _detect_math_without_latex flags math-shaped content carrying no LaTeX, which is the actual scanned/handwritten-math case. Regenerate with tests/fixtures/make_scanned_math_ps4.py.

Tests are in the live_llm lane, not tests/integration/ — they need Docling and a real model, not Postgres, and that lane's conftest mandates a running Supabase stack. Opt-in via RUN_LIVE_OCR=1 plus a real key, so CI's dummy key is a clean skip. One test guards the premise and fails loudly if Docling stops flagging the fixture, since the other two would otherwise pass vacuously.

Verification: live lane 3 passed; full suite on locked deps 1069 passed, 26 skipped, 0 failed; ruff clean.

@AndresL230
AndresL230 merged commit 6a6c555 into worktree-gemini-vision-ocrJul 28, 2026
4 checks passed
@AndresL230
AndresL230 deleted the fix/420-review-followups branch July 28, 2026 03:53
Sign up for freeto 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

@AndresL230