fix(extraction): graceful error for scanned PDFs with no text layer#10
Closed
aircode610 wants to merge 11 commits into
Closed
fix(extraction): graceful error for scanned PDFs with no text layer#10aircode610 wants to merge 11 commits into
aircode610 wants to merge 11 commits into
Conversation
Emphasize pixel-perfect preservation of original document. The model should only ADD red handwritten overlays on blank fields, never modify or regenerate any existing content. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two-step deterministic approach: 1. Qwen-VL detects blank form fields + coordinates (% of image) 2. Pillow draws red placeholder boxes + English text at those positions No image regeneration — original document preserved pixel-perfect. Robust JSON parsing handles LLM quirks (single quotes, trailing commas, malformed coordinate pairs). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ction Two-step approach: 1. Qwen-VL-Max detects blank form fields → bbox_2d [x1,y1,x2,y2] in 0-1000 range 2. Pillow draws red highlight boxes + English placeholder text at exact positions Tested on tax form (data/fields.png): all 8 form fields detected and annotated. Original image preserved pixel-perfect — only red overlays added. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…lution Qwen-VL returns bbox_2d in its internal processing resolution, not a fixed 0-1000 range. Now derives scale factors from the max coordinate values in the response, mapping accurately to actual image pixels. Tested on data/fields.png: all 8 form fields correctly overlaid. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Boxes now measured from the actual text width instead of the full detected bbox — no more covering existing printed labels. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…d/ and ai/ Remove unused imports (json, re, datetime, os, sys, timedelta, List, Dict, Optional, Any, RagHit, generate_reply_text), replace bare except with except Exception, move module-level imports to top of file, remove unused variable assignments (classification_data, action_titles), strip extraneous f-string prefixes, and apply ruff formatting throughout. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
extract_text_from_image read raw file bytes and sent them to the Qwen-VL-OCR model labeled as image/jpeg. For PDFs this handed the model undecodable bytes, causing the /process SSE pipeline to fail (notably for scanned image-only PDFs). Now PDFs are rendered to one PNG per page via poppler/pdf2image and each page is OCR'd and concatenated. Added an OcrError with user-facing messages for corrupt/empty/scanned PDFs, surfaced through the SSE error event as PDF_RENDER_FAILED instead of a raw 500. Fixes #8
A scanned, image-only PDF (no OCR text layer) made the Qwen vision model
return no structured tool call, which hit an unguarded RuntimeError in
extract_from_letter_file() and surfaced as a 500/generic 502 instead of a
helpful message.
- Add ExtractionError carrying a user-facing message + Klar ErrorCode
(mirrors ai.react_agent.ocr.OcrError).
- Replace the raw RuntimeError on the empty-tool-call path with an
ExtractionError explaining the document may be a scanned image without
readable text.
- Guard split_to_image_bytes() (corrupt/password-protected PDF, missing
poppler) and add an empty-page guard, both as PDF_RENDER_FAILED.
- Surface ExtractionError.message + .code in the /api/letters/{id}/extract
and POST /letters endpoints instead of a generic EXTRACTION_FAILED.
Fixes #8
) Lock in the issue #8 fix: a scanned, image-only PDF (no text layer) must surface a user-facing error instead of a raw 500. - Sync path: extract_from_letter_file raises ExtractionError with EXTRACTION_FAILED when the vision model returns no tool call, and PDF_RENDER_FAILED when rendering yields no pages / crashes. - SSE/OCR path: extract_text_from_image raises OcrError on a blank scan. Pure stdlib unittest (no pytest dep) and ruff-clean.
…R responses Closes the remaining gaps in the scanned-PDF/no-text-layer fix (#8): - The single-image (non-PDF) OCR branch lacked the blank-content guard the PDF branch already had — a scanned/blank image returned empty text silently and fed it to the downstream agent. Now raises a graceful OcrError, matching the PDF branch. - _ocr_image_bytes() blindly indexed result["choices"][0]["message"]["content"]; a 200 OK with an unexpected JSON shape (provider error body, null content) would surface as a raw KeyError/IndexError 500. Now wrapped in OcrError. Adds regression tests for both paths.
Owner
Author
🛡️ Migration safety review — automatedI reviewed this PR specifically for new database migrations and their safety against production data. ✅ Result: No migration present — schema is unchanged
Checklist
Verdict: safe to run on production data — no schema migration is introduced by this PR.
🤖 Automated migration-safety check |
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
Fixes #8 — uploading a scanned, image-only PDF (no OCR text layer) returned a 500 / generic error instead of a user-friendly message.
Root cause
Traced the upload → extraction path:
POST /letters(and/api/letters/{id}/extract) callextract_from_letter_file()inbackend/app/services/extraction.py.split_to_image_bytes), which works fine even for scanned PDFs.RuntimeError("Model returned no tool call; ...")atextraction.py:270, which bubbled up as a 500 (or a generic, unhelpful 502).Fix
ExtractionErrorcarrying a user-facingmessage+ a machine-readable KlarErrorCode— mirrors the existingai.react_agent.ocr.OcrErrorpattern.RuntimeErroron the empty-tool-call path with anExtractionErrorexplaining the document may be a scanned image without readable text.split_to_image_bytes()(corrupt / password-protected PDF, missing poppler) and add an empty-page guard, both surfaced asPDF_RENDER_FAILED.ExtractionError.message+.codein both the/api/letters/{id}/extractandPOST /lettersendpoints, instead of the genericEXTRACTION_FAILED.Now a scanned PDF returns a clean error envelope, e.g.:
{ "code": "EXTRACTION_FAILED", "message": "Could not extract text from this document. It may be a scanned image without readable content — try a clearer photo or PDF." }Testing
ruff check app/— passesruff format --check— passesapp.errorsimport inextraction.py)Closes #8
Follow-up hardening (commit
00956ed)While verifying the fix end-to-end, I found two remaining gaps in the SSE
/processpipeline OCR path (ai/react_agent/ocr.py) — the path the production upload flow actually uses (the sync/extractpath usesextraction.py, fixed above):extract_text_from_image()raisedOcrErrorwhen OCR yielded no readable text, but the single-image branch did not — a scanned/blank photo (.jpg/.png) silently returned empty text and fed it to the downstream agent. Now both branches behave symmetrically._ocr_image_bytes()blindly indexedresult["choices"][0]["message"]["content"]. A 200 OK with an unexpected JSON shape (provider error body,nullcontent) raised a rawKeyError/IndexError→ 500. Now wrapped in a gracefulOcrError.Added regression tests for both (
test_blank_image_raises_ocr_error,test_malformed_ocr_response_raises_ocr_error). Full suite: 6/6 passing;ruff check+ruff format --checkclean.