Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 49 additions & 5 deletions ai/react_agent/ocr.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import httpx
import base64
import os

import httpx

from ai.prompts import OCR_PROMPT

DASHSCOPE_API_KEY = os.environ.get("DASHSCOPE_API_KEY", "")
QWEN_API_BASE = os.environ.get(
"QWEN_API_BASE", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
Expand All @@ -10,11 +13,53 @@
# Dedicated OCR model — faster and more accurate for text extraction
QWEN_OCR_MODEL = os.environ.get("QWEN_OCR_MODEL", "qwen-vl-ocr")

from ai.prompts import OCR_PROMPT

class OcrError(Exception):
"""Raised when OCR can't produce usable text from an image.

Covers a malformed provider response (missing `choices`/`content`) and a
blank result (an image-only scan with nothing readable). The backend maps
this to a friendly `EXTRACTION_FAILED` instead of crashing with a raw
KeyError/IndexError (which previously surfaced as a 500).
"""


def _parse_ocr_response(result: object) -> str:
"""Defensively pull the text content out of a chat-completions response.

The happy path is `result["choices"][0]["message"]["content"]`, but blank
scans and provider hiccups can return no choices, a null message, or null
content. Any of those previously raised KeyError/IndexError/TypeError and
bubbled up as a 500 — here they become a typed `OcrError`.
"""
if not isinstance(result, dict):
raise OcrError("The document reader returned an unexpected response.")

choices = result.get("choices")
if not isinstance(choices, list) or not choices:
raise OcrError(
"Could not read any text from this document. It may be a scanned "
"image without readable content."
)

message = (choices[0] or {}).get("message") if isinstance(choices[0], dict) else None
content = (message or {}).get("content") if isinstance(message, dict) else None

if not content or not str(content).strip():
raise OcrError(
"Could not read any text from this document. It may be a scanned "
"image without readable content."
)

return str(content)


async def extract_text_from_image(image_path: str) -> str:
"""Send image to Qwen-VL-OCR and return extracted text."""
"""Send image to Qwen-VL-OCR and return extracted text.

Raises `OcrError` if the provider returns a malformed or empty response
(e.g. a blank/unreadable scan) so the caller can surface a graceful error.
"""
with open(image_path, "rb") as f:
image_bytes = f.read()

Expand Down Expand Up @@ -53,5 +98,4 @@ async def extract_text_from_image(image_path: str) -> str:
},
)
response.raise_for_status()
result = response.json()
return result["choices"][0]["message"]["content"]
return _parse_ocr_response(response.json())
107 changes: 104 additions & 3 deletions backend/app/pipeline/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import logging
import os
import re
import tempfile
from datetime import date, datetime
from typing import AsyncIterator
from uuid import UUID
Expand All @@ -56,11 +57,15 @@
)
from app.services import ai_bridge
from app.services.amounts import primary_outstanding_amount
from app.services.extraction import normalize_lang
from app.services.extraction import ExtractionError, normalize_lang
from app.services.pdf_pages import PdfRenderError, pdf_to_image_bytes
from app.services.risk import compute_risk

logger = logging.getLogger("klar.pipeline")

# Cap how many PDF pages we OCR — bounds cost/latency for very long documents.
_MAX_OCR_PAGES = 5


# ---------- helpers ----------

Expand Down Expand Up @@ -93,6 +98,68 @@ def _mark_error(letter_id: UUID, message: str) -> None:
pass


async def _ocr_letter_file(path: str) -> str:
"""OCR any uploaded letter file, transparently handling PDFs.

The AI team's `extract_text_from_image` only understands image files — it
base64-encodes the raw bytes and ships them to the image OCR model. For a
PDF that means sending raw `%PDF` bytes labelled `image/jpeg`, which never
yields text (and for a scanned, image-only PDF used to crash the pipeline).

Here we render PDFs to one PNG per page (via poppler/pdf2image), OCR each
page, and concatenate the text. Non-PDF files are OCR'd directly.

Raises:
PdfRenderError: the PDF couldn't be rendered (corrupt / poppler missing).
ExtractionError: OCR produced no readable text (e.g. a blank scan).
"""
from ai.react_agent.ocr import OcrError, extract_text_from_image

is_pdf = path.lower().endswith(".pdf")

if not is_pdf:
try:
text = await extract_text_from_image(path)
except OcrError as exc:
raise ExtractionError(str(exc)) from exc
if not text or not text.strip():
raise ExtractionError(
"Could not read any text from this document. It may be a scanned "
"image without readable content."
)
return text

# PDF: render pages to PNGs, OCR each, then join. PdfRenderError propagates.
page_images = pdf_to_image_bytes(path, max_pages=_MAX_OCR_PAGES)

page_texts: list[str] = []
for index, png_bytes in enumerate(page_images):
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
tmp.write(png_bytes)
tmp_path = tmp.name
try:
page_text = await extract_text_from_image(tmp_path)
except OcrError:
# One unreadable page shouldn't sink a multi-page document — skip it.
logger.info("OCR returned no text for PDF page %d of %s", index + 1, path)
page_text = ""
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
if page_text and page_text.strip():
page_texts.append(page_text.strip())

combined = "\n\n".join(page_texts).strip()
if not combined:
raise ExtractionError(
"Could not extract text from this PDF. It may be a scanned image "
"without readable content."
)
return combined


# ---------- German-date regex fallback ----------
# The AI agent occasionally fails to surface an explicit deadline even when
# the OCR text plainly contains one (especially for past dates). We scan the
Expand Down Expand Up @@ -194,7 +261,10 @@ async def process_letter_stream(letter_id: UUID, lang: str) -> AsyncIterator[str
# BEFORE its first yield — that race causes the browser to see
# ERR_INCOMPLETE_CHUNKED_ENCODING and infinitely reconnect.
try:
from ai.react_agent.ocr import extract_text_from_image
# OCR is invoked via `_ocr_letter_file` (which lazily imports the OCR
# module); we still import the agent here so a missing TAVILY_API_KEY /
# Tavily-tool instantiation failure surfaces as an SSE error before the
# first yield rather than mid-stream.
from ai.react_agent.agent import run_react_agent
except Exception as exc:
logger.exception(
Expand Down Expand Up @@ -231,8 +301,10 @@ async def process_letter_stream(letter_id: UUID, lang: str) -> AsyncIterator[str
try:
# ============================================================
# STAGE 1 — OCR (qwen-vl-ocr, ~3s)
# PDFs are rendered to page images first; blank/unreadable scans
# raise a typed error mapped to a friendly SSE event below.
# ============================================================
ocr_text = await extract_text_from_image(letter.original_file)
ocr_text = await _ocr_letter_file(letter.original_file)
letter.ocr_text = ocr_text
db.add(letter)
db.commit()
Expand Down Expand Up @@ -454,6 +526,35 @@ async def process_letter_stream(letter_id: UUID, lang: str) -> AsyncIterator[str
{"letter_id": str(letter.id), "letter": public.model_dump(mode="json")},
)

except PdfRenderError as exc:
# PDF couldn't be rendered to images (corrupt / poppler missing).
logger.warning("PDF render failed for letter %s: %s", letter_id, exc)
try:
letter.status = LetterStatus.ERROR
db.add(letter)
db.commit()
except Exception:
_mark_error(letter_id, str(exc))
yield sse_event(
"error",
sse_error_payload(ErrorCode.PDF_RENDER_FAILED, message=str(exc)),
)

except ExtractionError as exc:
# Scanned image-only PDF (no text layer) or blank/unreadable scan —
# surface the typed, user-friendly message instead of a raw error.
logger.info("Extraction produced no text for letter %s: %s", letter_id, exc)
try:
letter.status = LetterStatus.ERROR
db.add(letter)
db.commit()
except Exception:
_mark_error(letter_id, str(exc))
yield sse_event(
"error",
sse_error_payload(ErrorCode.EXTRACTION_FAILED, message=str(exc)),
)

except Exception as exc: # noqa: BLE001 — last-resort SSE error event
logger.exception("Pipeline failed for letter %s: %s", letter_id, exc)
try:
Expand Down
21 changes: 20 additions & 1 deletion backend/app/routers/letters.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@
utcnow,
)
from app.schemas import ErrorResponse, LetterListItem, LetterResponse, LetterUploadResponse
from app.services.extraction import extract_from_letter_file, normalize_lang
from app.services.extraction import (
ExtractionError,
extract_from_letter_file,
normalize_lang,
)
from app.services.pdf_pages import PdfRenderError
from app.services.persistence import persist_extraction
from app.services.storage import detect_magic_mime, save_letter_file

Expand Down Expand Up @@ -215,6 +220,20 @@ async def extract_letter(
extracted = await extract_from_letter_file(
letter.original_file, mime, lang=letter.language
)
except PdfRenderError as exc:
letter.status = LetterStatus.ERROR
db.add(letter)
db.commit()
# PDF couldn't be rendered (corrupt / poppler missing) — distinct,
# actionable message ("try uploading it as an image instead").
raise KlarHTTPException(502, ErrorCode.PDF_RENDER_FAILED, message=str(exc))
except ExtractionError as exc:
letter.status = LetterStatus.ERROR
db.add(letter)
db.commit()
# Scanned image-only PDF (no text layer) or malformed model output —
# surface the typed, user-friendly message instead of a raw 500.
raise KlarHTTPException(502, ErrorCode.EXTRACTION_FAILED, message=str(exc))
except Exception:
letter.status = LetterStatus.ERROR
db.add(letter)
Expand Down
20 changes: 20 additions & 0 deletions backend/app/routers/public.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,12 @@
)
from app.services import ai_bridge
from app.services.extraction import (
ExtractionError,
extract_from_letter_file,
generate_reply_text,
normalize_lang,
)
from app.services.pdf_pages import PdfRenderError
from app.services.persistence import persist_extraction
from app.services.storage import detect_magic_mime, save_letter_file

Expand Down Expand Up @@ -257,6 +259,24 @@ async def post_letter(
extracted = await extract_from_letter_file(
saved_path, actual_mime, lang=out_lang
)
except PdfRenderError as exc:
# PDF couldn't be rendered to page images (corrupt / password-protected
# / poppler missing). Surface the distinct, actionable message
# ("try uploading it as an image instead") instead of a generic 500.
logger.info("PDF render failed for letter %s: %s", letter.id, exc)
letter.status = LetterStatus.ERROR
db.add(letter)
db.commit()
raise KlarHTTPException(502, ErrorCode.PDF_RENDER_FAILED, message=str(exc))
except ExtractionError as exc:
# Scanned, image-only PDF with no readable text layer, or malformed
# model output — the issue-#8 crash. Return the typed, user-friendly
# message rather than letting a raw exception become a 500.
logger.info("Extraction produced no text for letter %s: %s", letter.id, exc)
letter.status = LetterStatus.ERROR
db.add(letter)
db.commit()
raise KlarHTTPException(502, ErrorCode.EXTRACTION_FAILED, message=str(exc))
except Exception as exc:
# Log the real Qwen error to the server console so we can diagnose.
# The 502 response stays generic on the wire to avoid leaking provider
Expand Down
32 changes: 29 additions & 3 deletions backend/app/services/extraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@

DOCUMENT_CATEGORIES: list[str] = [c.value for c in DocumentCategory]


class ExtractionError(Exception):
"""Raised when the vision model can't produce usable structured output.

Most common cause: a scanned, image-only PDF with no readable text layer,
where the model returns no tool call. Callers map this to a friendly
`EXTRACTION_FAILED` error instead of letting a raw exception become a 500.
"""

# ISO 639-1 codes — matches the frontend's docs/06-frontend-integration-contract.md.
# Qwen3.7-Plus handles all of these out of the box. Quality bar:
# en/de: production-grade, the wedge languages
Expand Down Expand Up @@ -226,6 +235,13 @@ async def extract_from_letter_file(
sent as a separate image_url content part so the model sees the whole doc.
"""
pages = split_to_image_bytes(path, mime)
# Empty-page guard: a PDF that renders to zero pages (or an empty file)
# would otherwise send the model an image-less prompt and waste a call.
if not pages:
raise ExtractionError(
"Could not read this document — it produced no pages. It may be a "
"blank or unreadable scan."
)
image_parts = list(iter_data_urls(pages))

rag_hits = store.search(
Expand Down Expand Up @@ -267,10 +283,20 @@ async def extract_from_letter_file(

tool_calls = response.choices[0].message.tool_calls or []
if not tool_calls:
raise RuntimeError(
"Model returned no tool call; check model + prompt compatibility."
# The model couldn't find structured content to extract. For a scanned,
# image-only PDF with no readable text this is the expected outcome —
# surface it as a typed error the caller turns into a friendly message
# rather than a raw 500.
raise ExtractionError(
"Could not extract text from this document. It may be a scanned "
"image without readable content — try a clearer photo or PDF."
)
payload = json.loads(tool_calls[0].function.arguments)
try:
payload = json.loads(tool_calls[0].function.arguments)
except (json.JSONDecodeError, TypeError) as exc:
raise ExtractionError(
"The document reader returned a malformed response. Please try again."
) from exc

# Defensive: models can emit `actions` as
# - a proper list of dicts (happy path)
Expand Down
Loading