Skip to content

fix: graceful error for scanned PDFs with no text layer (closes #8)#12

Closed
aircode610 wants to merge 3 commits into
mainfrom
fix/issue-8-pdf-no-text-layer-crash
Closed

fix: graceful error for scanned PDFs with no text layer (closes #8)#12
aircode610 wants to merge 3 commits into
mainfrom
fix/issue-8-pdf-no-text-layer-crash

Conversation

@aircode610

@aircode610 aircode610 commented Jun 22, 2026

Copy link
Copy Markdown
Owner

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:

  1. Production SSE /process path (backend/app/pipeline/orchestrator.py): the orchestrator called the image-only ai.react_agent.ocr.extract_text_from_image() directly on the PDF path. For a PDF that means base64-encoding raw %PDF bytes and shipping them to the image OCR model labelled image/jpeg — it never yields text, and a blank/malformed model response raised KeyError/IndexError (from result["choices"][0]["message"]["content"]) that bubbled up as a 500.
  2. Sync /extract path (backend/app/services/extraction.py): raised a raw RuntimeError when the vision model returned no tool call (the expected outcome for a text-layer-less scan), and pdf_to_image_bytes() let raw pdf2image exceptions escape.

Fix

  • pdf_pages.py — wrap pdf2image failures and zero-page renders in a typed PdfRenderError.
  • extraction.py — add an empty-page guard and replace the raw RuntimeError / malformed-JSON crash with a typed, user-facing ExtractionError.
  • ai/react_agent/ocr.py — parse the OCR response defensively (_parse_ocr_response); missing choices / null message / blank content now raise a typed OcrError instead of KeyError/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 friendly EXTRACTION_FAILED. PdfRenderError now maps to PDF_RENDER_FAILED.
  • routers/letters.py — map the typed errors to friendly 502 envelopes on the sync /extract route.

No new error codes were needed — EXTRACTION_FAILED and PDF_RENDER_FAILED already existed in app/errors.py.

Tests

Adds backend/tests/test_scanned_pdf_graceful.py (+ conftest.py, pytest.ini) — 18 regression tests covering:

  • _parse_ocr_response on malformed/blank responses
  • pdf_to_image_bytes render failures + zero-page output
  • extract_from_letter_file with no tool call / empty pages
  • _ocr_letter_file for blank images, all-blank PDFs, render errors, and multi-page joining
  • Full SSE process_letter_stream regression: a scanned PDF now emits a graceful error event (EXTRACTION_FAILED / PDF_RENDER_FAILED) and marks the letter ERRORno unhandled 500.
18 passed in 0.91s

No new lint findings introduced (verified with ruff).

Update — POST /letters (production upload) now maps errors precisely

The earlier revision only mapped typed errors on the sync /extract route. The production frontend upload flow (POST /lettersrouters/public.py::post_letter) still lumped every extraction failure into a single generic EXTRACTION_FAILED 502.

Latest commit (3b72404) closes that gap — post_letter now distinguishes:

  • PdfRenderErrorPDF_RENDER_FAILED ("try uploading it as an image instead")
  • ExtractionErrorEXTRACTION_FAILED (scanned/no-text-layer or malformed model output)
  • any other exception → generic EXTRACTION_FAILED (provider details never leak)

…matching the /extract path exactly. Regression tests extended accordingly.

Test suite now: 21 passed (ruff format --check + ruff check both clean).

ℹ️ This PR supersedes the duplicate fix attempts in #10 and #11 (both branched from the same work and carry repo-wide reformatting churn). Recommend closing #10 and #11 in favour of this branch.

aircode610 and others added 3 commits June 22, 2026 10:59
)

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).
Pure formatting/import-ordering normalization (ruff format). No behavior
change. The graceful scanned-PDF handling for #8 was already committed in
d5d68a0; verified here by the 18 passing regression tests in
backend/tests/test_scanned_pdf_graceful.py.
…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).
@aircode610

Copy link
Copy Markdown
Owner Author

Closing in favour of #13 which has been merged.

@aircode610 aircode610 closed this Jun 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

500 error on /api/letters upload when PDF has no text layer

1 participant