You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
500 error on /api/letters upload when PDF has no text layer #8
When uploading a scanned PDF without an OCR text layer, the /api/letters endpoint returns a 500 Internal Server Error instead of a user-friendly message.
Steps to reproduce:
Upload a scanned image-only PDF via the frontend
Server crashes in extraction.py
Expected: graceful error message telling the user the PDF
couldn't be read.
@saintnuno (most recent domain contributor to the extraction pipeline)
🔍 Likely Root Cause
After reviewing the code, the crash most likely occurs in the extraction step (not the upload endpoint itself, which only saves the file). The call chain is:
extract_from_letter_file() in extraction.py (line 218) calls split_to_image_bytes(path, mime).
split_to_image_bytes() in pdf_pages.py (line 31) calls pdf_to_image_bytes(), which uses pdf2image.convert_from_path(). For a scanned image-only PDF, this should still render pages to PNG — so the crash is likely downstream.
The rendered page images are sent to the Qwen vision model for structured extraction (line 260).
When the model receives pages with no recognizable text layer, it may return no tool call, which triggers the unguarded RuntimeError on line 270:
ifnottool_calls:
raiseRuntimeError("Model returned no tool call; check model + prompt compatibility.")
This RuntimeError propagates up as an unhandled 500 in the SSE /process endpoint (the /extract endpoint does wrap it in a try/except → 502, but the SSE path may not).
Suggested Fix
Wrap the split_to_image_bytes → Qwen call pipeline in a try/except within extract_from_letter_file().
When tool_calls is empty or the model returns low-confidence OCR for a scanned PDF, return a graceful error (e.g., EXTRACTION_FAILED with message "Could not extract text from this PDF. It may be a scanned image without readable content.") instead of raising a raw RuntimeError.
Consider adding an empty-page guard after split_to_image_bytes() returns — if no pages are produced, short-circuit with a user-friendly error.
Root cause was two-fold, across both extraction paths:
Sync /extract path (backend/app/services/extraction.py): raw RuntimeError when the vision model returned no tool call for a text-layer-less scan.
SSE /process path (ai/react_agent/ocr.py): the production upload→process flow OCRs page images; blank/unreadable scans and malformed OCR responses could still bubble up as 500s.
Fix (PR #10): both paths now surface a graceful, user-facing error envelope (EXTRACTION_FAILED / PDF_RENDER_FAILED) instead of a 500. Latest commit 00956ed closes the remaining OCR-path gaps (blank single-image scans + malformed OCR responses) with matching regression tests — 6/6 passing.
Pushed the final commit (3b72404) to PR #12, which now fully resolves this issue end-to-end.
State of play (important — there are 3 open PRs for this one issue):
fix: graceful error for scanned PDFs with no text layer (closes #8) #12 (fix/issue-8-pdf-no-text-layer-crash) — ✅ complete fix. Both extraction entry points (sync /extractand production POST /letters) plus the SSE /process path now surface typed EXTRACTION_FAILED / PDF_RENDER_FAILED envelopes instead of a 500. 21/21 regression tests pass, ruff clean.
Note on reproducibility: on current main the literal 500 is already partially masked — both router callers wrap extraction in a broad except Exception → 502 EXTRACTION_FAILED. The remaining gaps #12 closes are: (1) the raw RuntimeError at the source is now a typed ExtractionError, (2) PDF_RENDER_FAILED is distinguished from EXTRACTION_FAILED with actionable copy, and (3) the SSE path renders PDFs to page images before OCR so scanned PDFs fail cleanly (and often succeed).
A scanned, image-only PDF (no OCR text layer) caused a 500 because raw exceptions escaped from the extraction code instead of being mapped to a typed, user-facing error. This happened on three distinct paths:
Sync /extract (backend/app/services/extraction.py) — when the vision model returned no tool_call for a text-layer-less scan, a raw RuntimeError propagated up.
Production POST /api/letters (backend/app/routers/public.py) — the same extraction call, unguarded.
SSE /process (backend/app/pipeline/orchestrator.py → ai/react_agent/ocr.py) — blank/unreadable scans and malformed OCR provider responses raised KeyError/IndexError while parsing the response, and PDF render failures (corrupt file / missing poppler) raised raw pdf2image exceptions.
Fix
Introduced two typed exceptions: ExtractionError (no readable content / malformed model output) and PdfRenderError (corrupt / unrenderable PDF, incl. a zero-pages guard). ai/react_agent/ocr.py now defensively parses the provider response into a typed OcrError.
All three entry points now catch these and surface a graceful envelope instead of a 500:
sync/upload routes → typed 502 with EXTRACTION_FAILED / PDF_RENDER_FAILED and an actionable message
SSE route → an error event carrying the same code + message, and the letter is marked ERROR
Empty-page / blank-scan guards short-circuit early so we don't waste a model call. Raw provider errors are never leaked into user-facing messages.
Verification
21/21 regression tests pass (backend/tests/test_scanned_pdf_graceful.py) covering all three paths, including the exact POST /api/letters endpoint named in this issue.
All fix-touched files are ruff-clean.
Note on the open PRs
There are 4 duplicate PRs for this issue (#10, #11, #12, #13). #13 (fix/issue-8-scanned-pdf-graceful-clean) is the one to merge — its diff is focused (9 files: the 6 extraction-path files + test scaffolding), with no repo-wide ruff reformat noise. The working-tree reformat that bloated the earlier attempts has been discarded. Recommend closing #10, #11, #12 in favour of #13.
I investigated this issue to open a fix PR, but found a complete, working fix already exists. Opening another PR would only add to the 4 duplicates already on this issue, so I'm reporting verification results instead.
Bug is still live on main: the raw RuntimeError at backend/app/services/extraction.py:270 is unchanged (fix commit a7f78f3 is not an ancestor of main).
A verified fix already exists — PR #13 (fix/issue-8-scanned-pdf-graceful-clean):
✅ Covers all three crash paths (sync /extract, POST /api/letters, SSE /process) with typed EXTRACTION_FAILED / PDF_RENDER_FAILED envelopes instead of a 500.
✅ 21/21 regression tests pass — I ran tests/test_scanned_pdf_graceful.py in a clean venv.
✅ MERGEABLE / CLEAN, already contains the current main tip.
Activity
aircode610 commentedon Jun 18, 2026
🏷️ Auto-Classification
backend—backend/app/services/extraction.py,backend/app/services/pdf_pages.py🔍 Likely Root Cause
After reviewing the code, the crash most likely occurs in the extraction step (not the upload endpoint itself, which only saves the file). The call chain is:
extract_from_letter_file()inextraction.py(line 218) callssplit_to_image_bytes(path, mime).split_to_image_bytes()inpdf_pages.py(line 31) callspdf_to_image_bytes(), which usespdf2image.convert_from_path(). For a scanned image-only PDF, this should still render pages to PNG — so the crash is likely downstream.RuntimeErroron line 270:RuntimeErrorpropagates up as an unhandled 500 in the SSE/processendpoint (the/extractendpoint does wrap it in a try/except → 502, but the SSE path may not).Suggested Fix
split_to_image_bytes→ Qwen call pipeline in a try/except withinextract_from_letter_file().tool_callsis empty or the model returns low-confidence OCR for a scanned PDF, return a graceful error (e.g.,EXTRACTION_FAILEDwith message"Could not extract text from this PDF. It may be a scanned image without readable content.") instead of raising a rawRuntimeError.split_to_image_bytes()returns — if no pages are produced, short-circuit with a user-friendly error.🤖 Auto-triaged by issue classifier
test(extraction): regression tests for graceful scanned-PDF handling (#8
fix(ocr): graceful errors for blank single-image scans + malformed OC…
aircode610 commentedon Jun 22, 2026
Investigated and confirmed the fix on PR #10.
Root cause was two-fold, across both extraction paths:
/extractpath (backend/app/services/extraction.py): rawRuntimeErrorwhen the vision model returned no tool call for a text-layer-less scan./processpath (ai/react_agent/ocr.py): the production upload→process flow OCRs page images; blank/unreadable scans and malformed OCR responses could still bubble up as 500s.Fix (PR #10): both paths now surface a graceful, user-facing error envelope (
EXTRACTION_FAILED/PDF_RENDER_FAILED) instead of a 500. Latest commit00956edcloses the remaining OCR-path gaps (blank single-image scans + malformed OCR responses) with matching regression tests — 6/6 passing.style: apply ruff format across repo (issue #8 branch tidy-up)
fix(extraction): precise error mapping on POST /letters for scanned/c…
aircode610 commentedon Jun 22, 2026
Pushed the final commit (
3b72404) to PR #12, which now fully resolves this issue end-to-end.State of play (important — there are 3 open PRs for this one issue):
fix/issue-8-pdf-no-text-layer-crash) — ✅ complete fix. Both extraction entry points (sync/extractand productionPOST /letters) plus the SSE/processpath now surface typedEXTRACTION_FAILED/PDF_RENDER_FAILEDenvelopes instead of a 500. 21/21 regression tests pass,ruffclean.Note on reproducibility: on current
mainthe literal 500 is already partially masked — both router callers wrap extraction in a broadexcept Exception → 502 EXTRACTION_FAILED. The remaining gaps #12 closes are: (1) the rawRuntimeErrorat the source is now a typedExtractionError, (2)PDF_RENDER_FAILEDis distinguished fromEXTRACTION_FAILEDwith actionable copy, and (3) the SSE path renders PDFs to page images before OCR so scanned PDFs fail cleanly (and often succeed).aircode610 commentedon Jun 22, 2026
Investigation complete — fix verified on PR #13
Root cause
A scanned, image-only PDF (no OCR text layer) caused a 500 because raw exceptions escaped from the extraction code instead of being mapped to a typed, user-facing error. This happened on three distinct paths:
/extract(backend/app/services/extraction.py) — when the vision model returned notool_callfor a text-layer-less scan, a rawRuntimeErrorpropagated up.POST /api/letters(backend/app/routers/public.py) — the same extraction call, unguarded./process(backend/app/pipeline/orchestrator.py→ai/react_agent/ocr.py) — blank/unreadable scans and malformed OCR provider responses raisedKeyError/IndexErrorwhile parsing the response, and PDF render failures (corrupt file / missing poppler) raised rawpdf2imageexceptions.Fix
ExtractionError(no readable content / malformed model output) andPdfRenderError(corrupt / unrenderable PDF, incl. a zero-pages guard).ai/react_agent/ocr.pynow defensively parses the provider response into a typedOcrError.EXTRACTION_FAILED/PDF_RENDER_FAILEDand an actionable messageerrorevent carrying the same code + message, and the letter is markedERRORVerification
backend/tests/test_scanned_pdf_graceful.py) covering all three paths, including the exactPOST /api/lettersendpoint named in this issue.ruff-clean.Note on the open PRs
There are 4 duplicate PRs for this issue (#10, #11, #12, #13). #13 (
fix/issue-8-scanned-pdf-graceful-clean) is the one to merge — its diff is focused (9 files: the 6 extraction-path files + test scaffolding), with no repo-wide ruff reformat noise. The working-tree reformat that bloated the earlier attempts has been discarded. Recommend closing #10, #11, #12 in favour of #13.aircode610 commentedon Jun 22, 2026
🤖 Automated verification — no new PR needed
I investigated this issue to open a fix PR, but found a complete, working fix already exists. Opening another PR would only add to the 4 duplicates already on this issue, so I'm reporting verification results instead.
Bug is still live on
main: the rawRuntimeErroratbackend/app/services/extraction.py:270is unchanged (fix commita7f78f3is not an ancestor ofmain).A verified fix already exists — PR #13 (
fix/issue-8-scanned-pdf-graceful-clean):/extract,POST /api/letters, SSE/process) with typedEXTRACTION_FAILED/PDF_RENDER_FAILEDenvelopes instead of a 500.tests/test_scanned_pdf_graceful.pyin a clean venv.MERGEABLE/CLEAN, already contains the currentmaintip.Recommendation
Merge #13 and close #10, #11, #12 as duplicates.
🤖 Verified by automated issue agent — declined to open a redundant PR.
fix(extraction): graceful typed errors for scanned/corrupt PDFs (airc…
fix(form-fill): defensive error handling for malformed/PDF responses