Skip to content

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

Closed

Description

@aircode610

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:

  1. Upload a scanned image-only PDF via the frontend
  2. Server crashes in extraction.py

Expected: graceful error message telling the user the PDF
couldn't be read.

Activity

  1. aircode610 commented on Jun 18, 2026

    @aircode610
    OwnerAuthor

    🏷️ Auto-Classification

    Field Value
    Component backend — backend/app/services/extraction.py, backend/app/services/pdf_pages.py
    Type Bug
    Priority 🔴 High — 500 crash on the core letter-upload flow
    Assigned to @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:

    1. extract_from_letter_file() in extraction.py (line 218) calls split_to_image_bytes(path, mime).
    2. 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.
    3. The rendered page images are sent to the Qwen vision model for structured extraction (line 260).
    4. When the model receives pages with no recognizable text layer, it may return no tool call, which triggers the unguarded RuntimeError on line 270:
      if not tool_calls:
          raise RuntimeError("Model returned no tool call; check model + prompt compatibility.")
    5. 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.

    🤖 Auto-triaged by issue classifier

  2. aircode610 commented on Jun 22, 2026

    @aircode610
    OwnerAuthor

    Investigated and confirmed the fix on PR #10.

    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.

  3. aircode610 commented on Jun 22, 2026

    @aircode610
    OwnerAuthor

    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):

    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).

  4. aircode610 commented on Jun 22, 2026

    @aircode610
    OwnerAuthor

    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:

    1. 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.
    2. Production POST /api/letters (backend/app/routers/public.py) — the same extraction call, unguarded.
    3. 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.

  5. aircode610 commented on Jun 22, 2026

    @aircode610
    OwnerAuthor

    🤖 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 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):

    Recommendation

    Merge #13 and close #10, #11, #12 as duplicates.

    🤖 Verified by automated issue agent — declined to open a redundant PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingcomponent: backendBackend / FastAPI API layerpriority: highHigh priority — blocks core functionality

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions