fix: graceful error for scanned PDFs with no text layer (closes #8)#12
Closed
aircode610 wants to merge 3 commits into
Closed
fix: graceful error for scanned PDFs with no text layer (closes #8)#12aircode610 wants to merge 3 commits into
aircode610 wants to merge 3 commits into
Conversation
) Scanned, image-only PDFs (no OCR text layer) crashed the letter pipeline with an unhandled 500 instead of a user-friendly message. Root causes, across both extraction paths: - Production SSE /process path: the orchestrator called the image-only ai.react_agent.ocr.extract_text_from_image() directly on the PDF path, shipping raw %PDF bytes to the image OCR model as image/jpeg. It never produced text, and a blank/malformed response raised KeyError/IndexError that bubbled up as a 500. - Sync /extract path: extraction.extract_from_letter_file() raised a raw RuntimeError when the vision model returned no tool call, and pdf_to_image_bytes() let raw pdf2image exceptions escape. Fix: - pdf_pages: wrap pdf2image failures + zero-page renders in a typed PdfRenderError. - extraction: add an empty-page guard and replace the raw RuntimeError / malformed-JSON crash with a typed, user-facing ExtractionError. - ai/react_agent/ocr: parse the OCR response defensively; missing choices / null / blank content now raise a typed OcrError instead of KeyError. - orchestrator: new _ocr_letter_file() renders PDFs to page images before OCR (fixing scanned PDFs properly) and surfaces blank scans as a friendly EXTRACTION_FAILED; PdfRenderError maps to PDF_RENDER_FAILED. - letters /extract: map the typed errors to friendly 502 envelopes. Adds 18 regression tests (6 entry points + 2 full-pipeline SSE checks).
…orrupt PDFs (#8) The production /api/letters upload route (public.post_letter) lumped every extraction failure into a single generic EXTRACTION_FAILED 502. Scanned, image-only PDFs and corrupt/unrenderable PDFs now surface their typed, user-facing messages — PdfRenderError -> PDF_RENDER_FAILED ("try uploading it as an image instead") and ExtractionError -> EXTRACTION_FAILED — matching the /extract path. Adds regression tests for all three cases (PDF render failure, scanned no-text-layer, and an unexpected error staying generic so provider details never leak).
This was referenced Jun 22, 2026
Owner
Author
|
Closing in favour of #13 which has been merged. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #8 — uploading a scanned, image-only PDF (no OCR text layer) returned a 500 Internal Server Error instead of a user-friendly message. This hardens every extraction entry point so an unreadable PDF always produces a typed, graceful error envelope.
Root cause
The crash had two faces across the two extraction paths:
/processpath (backend/app/pipeline/orchestrator.py): the orchestrator called the image-onlyai.react_agent.ocr.extract_text_from_image()directly on the PDF path. For a PDF that means base64-encoding raw%PDFbytes and shipping them to the image OCR model labelledimage/jpeg— it never yields text, and a blank/malformed model response raisedKeyError/IndexError(fromresult["choices"][0]["message"]["content"]) that bubbled up as a 500./extractpath (backend/app/services/extraction.py): raised a rawRuntimeErrorwhen the vision model returned no tool call (the expected outcome for a text-layer-less scan), andpdf_to_image_bytes()let rawpdf2imageexceptions escape.Fix
pdf_pages.py— wrap pdf2image failures and zero-page renders in a typedPdfRenderError.extraction.py— add an empty-page guard and replace the rawRuntimeError/ malformed-JSON crash with a typed, user-facingExtractionError.ai/react_agent/ocr.py— parse the OCR response defensively (_parse_ocr_response); missingchoices/ null message / blank content now raise a typedOcrErrorinstead ofKeyError/IndexError.orchestrator.py— new_ocr_letter_file()renders PDFs to page images before OCR (so scanned PDFs actually work, not just fail cleanly), skips individually unreadable pages, and surfaces a fully-blank scan as a friendlyEXTRACTION_FAILED.PdfRenderErrornow maps toPDF_RENDER_FAILED.routers/letters.py— map the typed errors to friendly502envelopes on the sync/extractroute.No new error codes were needed —
EXTRACTION_FAILEDandPDF_RENDER_FAILEDalready existed inapp/errors.py.Tests
Adds
backend/tests/test_scanned_pdf_graceful.py(+conftest.py,pytest.ini) — 18 regression tests covering:_parse_ocr_responseon malformed/blank responsespdf_to_image_bytesrender failures + zero-page outputextract_from_letter_filewith no tool call / empty pages_ocr_letter_filefor blank images, all-blank PDFs, render errors, and multi-page joiningprocess_letter_streamregression: a scanned PDF now emits a gracefulerrorevent (EXTRACTION_FAILED/PDF_RENDER_FAILED) and marks the letterERROR— no unhandled 500.No new lint findings introduced (verified with
ruff).Update —
POST /letters(production upload) now maps errors preciselyThe earlier revision only mapped typed errors on the sync
/extractroute. The production frontend upload flow (POST /letters→routers/public.py::post_letter) still lumped every extraction failure into a single genericEXTRACTION_FAILED502.Latest commit (
3b72404) closes that gap —post_letternow distinguishes:PdfRenderError→PDF_RENDER_FAILED("try uploading it as an image instead")ExtractionError→EXTRACTION_FAILED(scanned/no-text-layer or malformed model output)EXTRACTION_FAILED(provider details never leak)…matching the
/extractpath exactly. Regression tests extended accordingly.Test suite now:
21 passed(ruff format --check+ruff checkboth clean).