feat(T03): OCR-Erfassung via OpenRouter Qwen2.5-VL + OCR UI with drag-and-drop

This commit is contained in:
2026-07-17 00:45:14 +02:00
parent b128ea6b39
commit 149ac04dc1
20 changed files with 2348 additions and 182 deletions
+4
View File
@@ -33,6 +33,10 @@ class Settings(BaseSettings):
APP_ENV: str = "development"
MOBILE_DE_API_KEY: str = ""
MOBILE_DE_SELLER_ID: str = ""
OPENROUTER_API_KEY: str = ""
OPENROUTER_BASE_URL: str = "https://openrouter.ai/api/v1"
OPENROUTER_OCR_MODEL: str = "qwen/qwen2.5-vl-72b-instruct"
MAX_FILE_SIZE_MB: int = 50
@property
def cors_origins_list(self) -> list[str]:
+2 -1
View File
@@ -9,7 +9,7 @@ from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, contacts, users, vehicles
from app.routers import auth, contacts, ocr, users, vehicles
@asynccontextmanager
@@ -42,6 +42,7 @@ api_v1_router.include_router(auth.router)
api_v1_router.include_router(users.router)
api_v1_router.include_router(vehicles.router)
api_v1_router.include_router(contacts.router)
api_v1_router.include_router(ocr.router)
# Health endpoint (no auth required)
@api_v1_router.get("/health", tags=["health"])
+90
View File
@@ -0,0 +1,90 @@
"""SQLAlchemy model for OCR results."""
import enum
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, Enum, Float, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class OCRStatus(str, enum.Enum):
pending = "pending"
processing = "processing"
completed = "completed"
failed = "failed"
manual_review = "manual_review"
class OCRResult(Base):
"""OCR result entity linked to a vehicle (optional) and an uploaded scan file."""
__tablename__ = "ocr_results"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
vehicle_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("vehicles.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
file_path: Mapped[str] = mapped_column(String(512), nullable=False)
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
mime_type: Mapped[str] = mapped_column(String(100), nullable=False, default="image/png")
status: Mapped[str] = mapped_column(
Enum(OCRStatus, name="ocr_status", create_constraint=True),
nullable=False,
default=OCRStatus.pending,
index=True,
)
raw_text: Mapped[str | None] = mapped_column(Text, nullable=True)
structured_data: Mapped[dict[str, Any] | None] = mapped_column(
JSONB,
nullable=True,
)
confidence_score: Mapped[float | None] = mapped_column(
Float,
nullable=True,
)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
vehicle = relationship("Vehicle", backref="ocr_results")
def __repr__(self) -> str:
return f"<OCRResult id={self.id} status={self.status}>"
def to_dict(self) -> dict[str, Any]:
"""Serialize OCR result for API responses."""
return {
"id": str(self.id),
"vehicle_id": str(self.vehicle_id) if self.vehicle_id else None,
"file_path": self.file_path,
"file_name": self.file_name,
"mime_type": self.mime_type,
"status": self.status.value if isinstance(self.status, OCRStatus) else str(self.status),
"raw_text": self.raw_text,
"structured_data": self.structured_data,
"confidence_score": self.confidence_score,
"error_message": self.error_message,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
+179
View File
@@ -0,0 +1,179 @@
"""OCR router: upload, get results, list results, apply to vehicle."""
import uuid
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, Query, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.dependencies import get_current_user
from app.models.user import User
from app.models.ocr_result import OCRStatus
from app.schemas.ocr import (
OCRApplyResponse,
OCRResultListResponse,
OCRResultResponse,
OCRUploadResponse,
)
from app.services import ocr_service
from app.tasks.ocr_processing import run_ocr_processing
router = APIRouter(prefix="/ocr", tags=["ocr"])
@router.post(
"/upload",
response_model=OCRUploadResponse,
status_code=status.HTTP_202_ACCEPTED,
)
async def upload_scan(
background_tasks: BackgroundTasks,
file: UploadFile = File(..., description="Image file to OCR"),
vehicle_id: str | None = Form(None, description="Optional vehicle ID to link"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Upload an image file for OCR processing.
Returns 202 with ocr_result_id. Processing happens asynchronously.
"""
if not file or not file.filename:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"error": {"code": "NO_FILE", "message": "No file provided"}},
)
mime_type = file.content_type or ""
if not ocr_service.validate_mime_type(mime_type):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"error": {
"code": "INVALID_MIME_TYPE",
"message": f"Invalid MIME type: {mime_type}. Only image/* types are allowed.",
}
},
)
# Read file content
file_bytes = await file.read()
if not ocr_service.validate_file_size(len(file_bytes)):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={
"error": {
"code": "FILE_TOO_LARGE",
"message": f"File size exceeds limit of {settings.MAX_FILE_SIZE_MB} MB",
}
},
)
# Parse optional vehicle_id
parsed_vehicle_id: uuid.UUID | None = None
if vehicle_id:
try:
parsed_vehicle_id = uuid.UUID(vehicle_id)
except (ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"error": {"code": "INVALID_VEHICLE_ID", "message": "Invalid vehicle UUID"}},
)
try:
ocr_result = await ocr_service.upload_file(
db=db,
file_bytes=file_bytes,
file_name=file.filename or "upload.png",
mime_type=mime_type,
vehicle_id=parsed_vehicle_id,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"error": {"code": "UPLOAD_FAILED", "message": str(exc)}},
)
# Queue background processing
background_tasks.add_task(run_ocr_processing, ocr_result.id)
return OCRUploadResponse(
message="OCR processing queued",
ocr_result_id=ocr_result.id,
status=ocr_result.status.value if isinstance(ocr_result.status, OCRStatus) else str(ocr_result.status),
)
@router.get(
"/results/{result_id}",
response_model=OCRResultResponse,
status_code=status.HTTP_200_OK,
)
async def get_ocr_result(
result_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single OCR result by ID."""
ocr_result = await ocr_service.get_result(db, result_id)
if ocr_result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "OCR_NOT_FOUND", "message": "OCR result not found"}},
)
return OCRResultResponse.model_validate(ocr_result)
@router.get(
"/results",
response_model=OCRResultListResponse,
status_code=status.HTTP_200_OK,
)
async def list_ocr_results(
db: AsyncSession = Depends(get_db),
vehicle_id: uuid.UUID | None = Query(None, description="Filter by vehicle ID"),
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
current_user: User = Depends(get_current_user),
):
"""List OCR results, optionally filtered by vehicle_id."""
items, total = await ocr_service.list_results(
db=db,
vehicle_id=vehicle_id,
page=page,
page_size=page_size,
)
return OCRResultListResponse(
items=[OCRResultResponse.model_validate(item) for item in items],
total=total,
page=page,
page_size=page_size,
)
@router.post(
"/results/{result_id}/apply",
response_model=OCRApplyResponse,
status_code=status.HTTP_200_OK,
)
async def apply_ocr_to_vehicle(
result_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Apply OCR structured data to the linked vehicle."""
try:
ocr_result, vehicle, updated_fields = await ocr_service.apply_to_vehicle(db, result_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": {"code": "APPLY_FAILED", "message": str(exc)}},
)
return OCRApplyResponse(
message="OCR data applied to vehicle",
ocr_result_id=ocr_result.id,
vehicle_id=vehicle.id,
updated_fields=updated_fields,
)
+64
View File
@@ -0,0 +1,64 @@
"""Pydantic schemas for OCR-related request and response bodies."""
import uuid
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, ConfigDict, Field
class OCRUploadResponse(BaseModel):
"""Response for POST /api/v1/ocr/upload."""
message: str = "OCR processing queued"
ocr_result_id: uuid.UUID
status: str = "pending"
class OCRStructuredData(BaseModel):
"""Structured data extracted from OCR scan (ZB I/II fields)."""
brand: Optional[str] = None
model: Optional[str] = None
vin: Optional[str] = None
first_registration: Optional[str] = None
mileage: Optional[int] = None
power_kw: Optional[int] = None
fuel_type: Optional[str] = None
class OCRResultResponse(BaseModel):
"""Response for GET /api/v1/ocr/results/:id."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
vehicle_id: Optional[uuid.UUID] = None
file_path: str
file_name: str
mime_type: str
status: str
raw_text: Optional[str] = None
structured_data: Optional[dict[str, Any]] = None
confidence_score: Optional[float] = None
error_message: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class OCRResultListResponse(BaseModel):
"""Paginated OCR results list response."""
items: list[OCRResultResponse]
total: int
page: int
page_size: int
class OCRApplyResponse(BaseModel):
"""Response for POST /api/v1/ocr/results/:id/apply."""
message: str = "OCR data applied to vehicle"
ocr_result_id: uuid.UUID
vehicle_id: uuid.UUID
updated_fields: list[str] = Field(default_factory=list)
+246
View File
@@ -0,0 +1,246 @@
"""OCR service: file upload, result retrieval, list, apply-to-vehicle, and async processing."""
from __future__ import annotations
import logging
import os
import uuid
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.ocr_result import OCRResult, OCRStatus
from app.models.vehicle import Vehicle
from app.utils.openrouter import perform_ocr
logger = logging.getLogger(__name__)
# Confidence threshold: below this, status is set to manual_review
CONFIDENCE_THRESHOLD = 0.7
# Allowed MIME types for OCR uploads
ALLOWED_MIME_TYPES = {"image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"}
def validate_mime_type(mime_type: str) -> bool:
"""Check if the MIME type is allowed for OCR uploads."""
return mime_type in ALLOWED_MIME_TYPES
def validate_file_size(file_size_bytes: int) -> bool:
"""Check if the file size is within the configured limit."""
max_bytes = settings.MAX_FILE_SIZE_MB * 1024 * 1024
return file_size_bytes <= max_bytes
async def upload_file(
db: AsyncSession,
file_bytes: bytes,
file_name: str,
mime_type: str,
vehicle_id: uuid.UUID | None = None,
) -> OCRResult:
"""Save uploaded file to disk and create an OCRResult record with status=pending.
Validates MIME type and file size before saving.
"""
if not validate_mime_type(mime_type):
raise ValueError(f"Invalid MIME type: {mime_type}. Allowed: {ALLOWED_MIME_TYPES}")
if not validate_file_size(len(file_bytes)):
raise ValueError(
f"File size exceeds limit of {settings.MAX_FILE_SIZE_MB} MB"
)
# Ensure upload directory exists
upload_dir = settings.UPLOAD_DIR
os.makedirs(upload_dir, exist_ok=True)
# Generate unique filename
file_ext = os.path.splitext(file_name)[1] or ".png"
unique_name = f"{uuid.uuid4().hex}{file_ext}"
file_path = os.path.join(upload_dir, unique_name)
# Write file to disk
with open(file_path, "wb") as f:
f.write(file_bytes)
# Create OCR result record
ocr_result = OCRResult(
vehicle_id=vehicle_id,
file_path=file_path,
file_name=file_name,
mime_type=mime_type,
status=OCRStatus.pending,
)
db.add(ocr_result)
await db.flush()
await db.refresh(ocr_result)
return ocr_result
async def get_result(db: AsyncSession, result_id: uuid.UUID) -> OCRResult | None:
"""Get a single OCR result by ID."""
stmt = select(OCRResult).where(OCRResult.id == result_id)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def list_results(
db: AsyncSession,
vehicle_id: uuid.UUID | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[list[OCRResult], int]:
"""List OCR results, optionally filtered by vehicle_id, with pagination."""
conditions = []
if vehicle_id is not None:
conditions.append(OCRResult.vehicle_id == vehicle_id)
# Count query
count_stmt = select(func.count(OCRResult.id))
if conditions:
count_stmt = count_stmt.where(and_(*conditions))
total_result = await db.execute(count_stmt)
total = total_result.scalar_one()
# Data query
data_stmt = select(OCRResult).order_by(OCRResult.created_at.desc())
if conditions:
data_stmt = data_stmt.where(and_(*conditions))
offset = (page - 1) * page_size
data_stmt = data_stmt.offset(offset).limit(page_size)
result = await db.execute(data_stmt)
items = list(result.scalars().all())
return items, total
async def apply_to_vehicle(
db: AsyncSession, result_id: uuid.UUID
) -> tuple[OCRResult, Vehicle, list[str]]:
"""Apply OCR structured data to the linked vehicle.
Maps OCR fields to vehicle fields:
brand → make, model → model, vin → fin,
first_registration → first_registration, mileage → mileage_km,
power_kw → power_kw, fuel_type → fuel_type
Returns (ocr_result, vehicle, updated_fields).
Raises ValueError if OCR result not found, no vehicle linked, or no structured data.
"""
ocr_result = await get_result(db, result_id)
if ocr_result is None:
raise ValueError("OCR result not found")
if ocr_result.vehicle_id is None:
raise ValueError("No vehicle linked to this OCR result")
if not ocr_result.structured_data:
raise ValueError("No structured data available to apply")
# Fetch vehicle
stmt = select(Vehicle).where(
and_(Vehicle.id == ocr_result.vehicle_id, Vehicle.deleted_at.is_(None))
)
vehicle_result = await db.execute(stmt)
vehicle = vehicle_result.scalar_one_or_none()
if vehicle is None:
raise ValueError("Linked vehicle not found")
data = ocr_result.structured_data
updated_fields: list[str] = []
# Map OCR fields to vehicle fields
field_mapping = {
"brand": "make",
"model": "model",
"vin": "fin",
"first_registration": "first_registration",
"mileage": "mileage_km",
"power_kw": "power_kw",
"fuel_type": "fuel_type",
}
for ocr_field, vehicle_field in field_mapping.items():
value = data.get(ocr_field)
if value is not None and value != "":
# Parse first_registration to date
if ocr_field == "first_registration" and isinstance(value, str):
try:
from datetime import datetime as dt
parsed = dt.strptime(value, "%d.%m.%Y").date()
setattr(vehicle, vehicle_field, parsed)
updated_fields.append(vehicle_field)
continue
except ValueError:
try:
from datetime import date
parsed = date.fromisoformat(value)
setattr(vehicle, vehicle_field, parsed)
updated_fields.append(vehicle_field)
continue
except ValueError:
logger.warning("Could not parse first_registration: %s", value)
continue
setattr(vehicle, vehicle_field, value)
updated_fields.append(vehicle_field)
await db.flush()
await db.refresh(vehicle)
return ocr_result, vehicle, updated_fields
async def process_ocr(db: AsyncSession, result_id: uuid.UUID) -> OCRResult:
"""Process an OCR result: read file, call OpenRouter, update result.
This is the async processing function called by the background task.
Sets status to 'completed' if confidence >= threshold, else 'manual_review'.
Sets status to 'failed' on error.
"""
ocr_result = await get_result(db, result_id)
if ocr_result is None:
raise ValueError(f"OCR result {result_id} not found")
# Update status to processing
ocr_result.status = OCRStatus.processing
await db.flush()
try:
# Read file from disk
with open(ocr_result.file_path, "rb") as f:
image_bytes = f.read()
# Call OpenRouter vision model
ocr_output = await perform_ocr(
image_bytes=image_bytes,
mime_type=ocr_result.mime_type,
)
# Update OCR result with extracted data
ocr_result.raw_text = ocr_output.get("raw_text", "")
ocr_result.structured_data = ocr_output.get("structured_data", {})
ocr_result.confidence_score = ocr_output.get("confidence_score", 0.0)
# Set status based on confidence threshold
if ocr_result.confidence_score >= CONFIDENCE_THRESHOLD:
ocr_result.status = OCRStatus.completed
else:
ocr_result.status = OCRStatus.manual_review
except Exception as exc:
logger.error("OCR processing failed for %s: %s", result_id, exc)
ocr_result.status = OCRStatus.failed
ocr_result.error_message = str(exc)
await db.flush()
await db.refresh(ocr_result)
return ocr_result
+1
View File
@@ -0,0 +1 @@
# Tasks package
+36
View File
@@ -0,0 +1,36 @@
"""Async OCR processing task using background tasks.
In production this would use Redis Queue (RQ) or Celery.
For now, we use FastAPI BackgroundTasks to trigger async processing.
"""
from __future__ import annotations
import logging
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import async_session_factory
from app.services.ocr_service import process_ocr
logger = logging.getLogger(__name__)
async def run_ocr_processing(result_id: uuid.UUID) -> None:
"""Background task: process OCR result asynchronously.
Creates its own DB session (independent of the request session)
so the HTTP response can return immediately.
"""
async with async_session_factory() as session:
try:
await process_ocr(session, result_id)
await session.commit()
logger.info("OCR processing completed for result %s", result_id)
except Exception as exc:
await session.rollback()
logger.error("OCR background task failed for %s: %s", result_id, exc)
raise
finally:
await session.close()
+187
View File
@@ -0,0 +1,187 @@
"""OpenRouter API client for Qwen2.5-VL vision model OCR processing.
Sends image to the vision model with a structured prompt and parses the
returned JSON containing brand, model, vin, first_registration, mileage,
power_kw, fuel_type plus a confidence score.
"""
from __future__ import annotations
import base64
import json
import logging
from typing import Any
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
OCR_SYSTEM_PROMPT = (
"You are an expert OCR system specialized in reading German vehicle "
"registration documents (Zulassungsbescheinigung Teil I and II). "
"Extract the following fields from the provided image and return them "
"as a JSON object. If a field is not readable or not present, use null. "
"Fields to extract: brand, model, vin, first_registration (DD.MM.YYYY), "
"mileage (integer km), power_kw (integer), fuel_type. "
"Also provide a confidence_score between 0.0 and 1.0 reflecting how "
"confident you are in the extracted data. Return ONLY valid JSON, "
"no markdown, no explanation."
)
EXPECTED_FIELDS = {
"brand",
"model",
"vin",
"first_registration",
"mileage",
"power_kw",
"fuel_type",
}
def _encode_image(image_bytes: bytes, mime_type: str = "image/png") -> str:
"""Encode image bytes to a base64 data URI."""
b64 = base64.b64encode(image_bytes).decode("utf-8")
return f"data:{mime_type};base64,{b64}"
def _build_messages(image_data_uri: str) -> list[dict[str, Any]]:
"""Build the chat messages for the OpenRouter vision API."""
return [
{
"role": "system",
"content": OCR_SYSTEM_PROMPT,
},
{
"role": "user",
"content": [
{
"type": "text",
"text": (
"Please extract the vehicle data from this "
"registration document image and return as JSON."
),
},
{
"type": "image_url",
"image_url": {"url": image_data_uri},
},
],
},
]
def _parse_response(raw_content: str) -> dict[str, Any]:
"""Parse the model response into structured data + confidence.
Handles markdown code fences and extracts the JSON object.
"""
text = raw_content.strip()
# Strip markdown code fences if present
if text.startswith("```"):
lines = text.split("\n")
# Remove first line (```json or ```) and last line (```)
lines = [l for l in lines if not l.strip().startswith("```")]
text = "\n".join(lines).strip()
try:
data = json.loads(text)
except json.JSONDecodeError:
# Try to find JSON object within the text
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1:
try:
data = json.loads(text[start : end + 1])
except json.JSONDecodeError:
logger.error("Failed to parse OpenRouter response: %s", text[:200])
return {"structured_data": {}, "confidence_score": 0.0, "raw_text": raw_content}
else:
logger.error("No JSON found in OpenRouter response: %s", text[:200])
return {"structured_data": {}, "confidence_score": 0.0, "raw_text": raw_content}
# Extract confidence score (may be inside or outside the data)
confidence = data.pop("confidence_score", None)
if confidence is None:
confidence = data.pop("confidence", 0.5)
try:
confidence_float = float(confidence)
except (TypeError, ValueError):
confidence_float = 0.5
# Clamp to 0.0-1.0
confidence_float = max(0.0, min(1.0, confidence_float))
# Ensure all expected fields exist (default None)
structured: dict[str, Any] = {}
for field in EXPECTED_FIELDS:
structured[field] = data.get(field)
# Convert mileage and power_kw to int if present
if structured.get("mileage") is not None:
try:
structured["mileage"] = int(structured["mileage"])
except (TypeError, ValueError):
pass
if structured.get("power_kw") is not None:
try:
structured["power_kw"] = int(structured["power_kw"])
except (TypeError, ValueError):
pass
return {
"structured_data": structured,
"confidence_score": confidence_float,
"raw_text": raw_content,
}
async def perform_ocr(
image_bytes: bytes,
mime_type: str = "image/png",
api_key: str | None = None,
model: str | None = None,
) -> dict[str, Any]:
"""Send image to OpenRouter Qwen2.5-VL and return parsed OCR result.
Returns dict with keys:
- structured_data: dict with brand, model, vin, etc.
- confidence_score: float 0.0-1.0
- raw_text: str (raw model response)
Raises httpx.HTTPStatusError on API failure.
"""
key = api_key or settings.OPENROUTER_API_KEY
if not key:
raise ValueError("OPENROUTER_API_KEY is not configured")
model_name = model or settings.OPENROUTER_OCR_MODEL
image_data_uri = _encode_image(image_bytes, mime_type)
messages = _build_messages(image_data_uri)
headers = {
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
}
payload: dict[str, Any] = {
"model": model_name,
"messages": messages,
"temperature": 0.1,
"max_tokens": 1024,
}
base_url = settings.OPENROUTER_BASE_URL.rstrip("/")
url = f"{base_url}/chat/completions"
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client:
response = await client.post(url, headers=headers, json=payload)
response.raise_for_status()
body = response.json()
content = body.get("choices", [{}])[0].get("message", {}).get("content", "")
return _parse_response(content)
+624
View File
@@ -0,0 +1,624 @@
"""Tests for OCR module: upload, results, apply, and processing with mocked OpenRouter."""
import io
import uuid
from datetime import date
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import Base, get_db
from app.main import app
from app.models.ocr_result import OCRResult, OCRStatus
from app.models.vehicle import Vehicle
from app.services import ocr_service
from app.services.ocr_service import CONFIDENCE_THRESHOLD
# Ensure all models are registered with Base.metadata
from app.models import user, vehicle, ocr_result # noqa: F401
@pytest_asyncio.fixture
async def test_vehicle(db_session: AsyncSession) -> Vehicle:
"""Create a test vehicle for OCR linking."""
vehicle = Vehicle(
make="Mercedes",
model="Actros",
fin="WDB9066351L123456",
year=2020,
power_kw=350,
fuel_type="Diesel",
condition="used",
availability="available",
price=45000,
vehicle_type="lkw",
)
db_session.add(vehicle)
await db_session.commit()
await db_session.refresh(vehicle)
return vehicle
def _make_mock_openrouter_response(
confidence: float = 0.85,
brand: str = "Mercedes",
model: str = "Actros",
vin: str = "WDB9066351L123456",
first_registration: str = "15.03.2020",
mileage: int = 120000,
power_kw: int = 350,
fuel_type: str = "Diesel",
) -> dict:
"""Build a mock OpenRouter response dict."""
return {
"structured_data": {
"brand": brand,
"model": model,
"vin": vin,
"first_registration": first_registration,
"mileage": mileage,
"power_kw": power_kw,
"fuel_type": fuel_type,
},
"confidence_score": confidence,
"raw_text": f'{{"brand": "{brand}", "model": "{model}", "vin": "{vin}", "confidence_score": {confidence}}}',
}
class TestOCRService:
"""Unit tests for ocr_service functions."""
@pytest.mark.asyncio
async def test_validate_mime_type_valid(self):
assert ocr_service.validate_mime_type("image/png") is True
assert ocr_service.validate_mime_type("image/jpeg") is True
assert ocr_service.validate_mime_type("image/webp") is True
@pytest.mark.asyncio
async def test_validate_mime_type_invalid(self):
assert ocr_service.validate_mime_type("application/pdf") is False
assert ocr_service.validate_mime_type("text/plain") is False
assert ocr_service.validate_mime_type("") is False
@pytest.mark.asyncio
async def test_validate_file_size_valid(self):
# 1 MB should be valid (limit is 50 MB)
assert ocr_service.validate_file_size(1024 * 1024) is True
@pytest.mark.asyncio
async def test_validate_file_size_too_large(self):
# 51 MB should be invalid
assert ocr_service.validate_file_size(51 * 1024 * 1024) is False
@pytest.mark.asyncio
async def test_upload_file_success(self, db_session: AsyncSession, tmp_path):
"""Test uploading a valid image file creates an OCRResult."""
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
file_bytes = b"fake-image-data"
result = await ocr_service.upload_file(
db=db_session,
file_bytes=file_bytes,
file_name="scan.png",
mime_type="image/png",
)
assert result.id is not None
assert result.status == OCRStatus.pending
assert result.file_name == "scan.png"
assert result.mime_type == "image/png"
assert result.file_path.endswith(".png")
@pytest.mark.asyncio
async def test_upload_file_invalid_mime(self, db_session: AsyncSession):
"""Test uploading with invalid MIME type raises ValueError."""
with pytest.raises(ValueError, match="Invalid MIME type"):
await ocr_service.upload_file(
db=db_session,
file_bytes=b"data",
file_name="doc.pdf",
mime_type="application/pdf",
)
@pytest.mark.asyncio
async def test_upload_file_too_large(self, db_session: AsyncSession):
"""Test uploading a file that exceeds size limit raises ValueError."""
large_bytes = b"x" * (51 * 1024 * 1024)
with pytest.raises(ValueError, match="File size exceeds"):
await ocr_service.upload_file(
db=db_session,
file_bytes=large_bytes,
file_name="big.png",
mime_type="image/png",
)
@pytest.mark.asyncio
async def test_get_result_not_found(self, db_session: AsyncSession):
"""Test getting a non-existent OCR result returns None."""
result = await ocr_service.get_result(db_session, uuid.uuid4())
assert result is None
@pytest.mark.asyncio
async def test_list_results_empty(self, db_session: AsyncSession):
"""Test listing OCR results when none exist."""
items, total = await ocr_service.list_results(db_session)
assert total == 0
assert items == []
@pytest.mark.asyncio
async def test_list_results_with_data(self, db_session: AsyncSession, tmp_path):
"""Test listing OCR results with data."""
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
await ocr_service.upload_file(
db=db_session,
file_bytes=b"img1",
file_name="scan1.png",
mime_type="image/png",
)
await ocr_service.upload_file(
db=db_session,
file_bytes=b"img2",
file_name="scan2.png",
mime_type="image/png",
)
await db_session.commit()
items, total = await ocr_service.list_results(db_session)
assert total == 2
assert len(items) == 2
@pytest.mark.asyncio
async def test_list_results_filter_by_vehicle(
self, db_session: AsyncSession, test_vehicle: Vehicle, tmp_path
):
"""Test listing OCR results filtered by vehicle_id."""
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
await ocr_service.upload_file(
db=db_session,
file_bytes=b"img1",
file_name="scan1.png",
mime_type="image/png",
vehicle_id=test_vehicle.id,
)
await ocr_service.upload_file(
db=db_session,
file_bytes=b"img2",
file_name="scan2.png",
mime_type="image/png",
)
await db_session.commit()
items, total = await ocr_service.list_results(
db_session, vehicle_id=test_vehicle.id
)
assert total == 1
assert len(items) == 1
assert items[0].vehicle_id == test_vehicle.id
@pytest.mark.asyncio
async def test_process_ocr_high_confidence(
self, db_session: AsyncSession, tmp_path
):
"""Test OCR processing with high confidence sets status to completed."""
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
ocr_result = await ocr_service.upload_file(
db=db_session,
file_bytes=b"fake-image",
file_name="scan.png",
mime_type="image/png",
)
await db_session.commit()
mock_response = _make_mock_openrouter_response(confidence=0.92)
with patch(
"app.services.ocr_service.perform_ocr",
new_callable=AsyncMock,
return_value=mock_response,
):
result = await ocr_service.process_ocr(db_session, ocr_result.id)
assert result.status == OCRStatus.completed
assert result.confidence_score == 0.92
assert result.structured_data is not None
assert result.structured_data["brand"] == "Mercedes"
assert result.structured_data["model"] == "Actros"
assert result.structured_data["vin"] == "WDB9066351L123456"
@pytest.mark.asyncio
async def test_process_ocr_low_confidence(
self, db_session: AsyncSession, tmp_path
):
"""Test OCR processing with low confidence sets status to manual_review."""
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
ocr_result = await ocr_service.upload_file(
db=db_session,
file_bytes=b"fake-image",
file_name="scan.png",
mime_type="image/png",
)
await db_session.commit()
mock_response = _make_mock_openrouter_response(confidence=0.45)
with patch(
"app.services.ocr_service.perform_ocr",
new_callable=AsyncMock,
return_value=mock_response,
):
result = await ocr_service.process_ocr(db_session, ocr_result.id)
assert result.status == OCRStatus.manual_review
assert result.confidence_score == 0.45
@pytest.mark.asyncio
async def test_process_ocr_openrouter_failure(
self, db_session: AsyncSession, tmp_path
):
"""Test OCR processing when OpenRouter fails sets status to failed."""
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
ocr_result = await ocr_service.upload_file(
db=db_session,
file_bytes=b"fake-image",
file_name="scan.png",
mime_type="image/png",
)
await db_session.commit()
with patch(
"app.services.ocr_service.perform_ocr",
new_callable=AsyncMock,
side_effect=Exception("OpenRouter API unavailable"),
):
result = await ocr_service.process_ocr(db_session, ocr_result.id)
assert result.status == OCRStatus.failed
assert result.error_message is not None
assert "OpenRouter API unavailable" in result.error_message
@pytest.mark.asyncio
async def test_apply_to_vehicle_success(
self, db_session: AsyncSession, test_vehicle: Vehicle, tmp_path
):
"""Test applying OCR data to a vehicle."""
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
ocr_result = await ocr_service.upload_file(
db=db_session,
file_bytes=b"fake-image",
file_name="scan.png",
mime_type="image/png",
vehicle_id=test_vehicle.id,
)
# Set structured data manually
ocr_result.structured_data = {
"brand": "MAN",
"model": "TGX",
"vin": "WDB9066351L123456",
"first_registration": "15.03.2020",
"mileage": 85000,
"power_kw": 400,
"fuel_type": "Diesel",
}
ocr_result.confidence_score = 0.88
ocr_result.status = OCRStatus.completed
await db_session.commit()
ocr, vehicle, updated_fields = await ocr_service.apply_to_vehicle(
db_session, ocr_result.id
)
assert vehicle.make == "MAN"
assert vehicle.model == "TGX"
assert vehicle.mileage_km == 85000
assert vehicle.power_kw == 400
assert vehicle.fuel_type == "Diesel"
assert "make" in updated_fields
assert "model" in updated_fields
assert "mileage_km" in updated_fields
@pytest.mark.asyncio
async def test_apply_to_vehicle_no_vehicle(
self, db_session: AsyncSession, tmp_path
):
"""Test applying OCR data when no vehicle is linked."""
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
ocr_result = await ocr_service.upload_file(
db=db_session,
file_bytes=b"fake-image",
file_name="scan.png",
mime_type="image/png",
)
ocr_result.structured_data = {"brand": "MAN"}
await db_session.commit()
with pytest.raises(ValueError, match="No vehicle linked"):
await ocr_service.apply_to_vehicle(db_session, ocr_result.id)
@pytest.mark.asyncio
async def test_apply_to_vehicle_no_data(
self, db_session: AsyncSession, test_vehicle: Vehicle, tmp_path
):
"""Test applying OCR data when no structured data exists."""
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
ocr_result = await ocr_service.upload_file(
db=db_session,
file_bytes=b"fake-image",
file_name="scan.png",
mime_type="image/png",
vehicle_id=test_vehicle.id,
)
await db_session.commit()
with pytest.raises(ValueError, match="No structured data"):
await ocr_service.apply_to_vehicle(db_session, ocr_result.id)
class TestOCRRouter:
"""Integration tests for OCR API endpoints."""
@pytest_asyncio.fixture
async def ocr_client(self, test_session_factory, admin_token):
"""HTTP client with DB override and admin auth."""
async def _override_get_db():
async with test_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
app.dependency_overrides[get_db] = _override_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
ac.headers.update({"Authorization": f"Bearer {admin_token}"})
yield ac
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_upload_success(self, ocr_client: AsyncClient, tmp_path):
"""POST /api/v1/ocr/upload with valid image returns 202."""
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
# Patch background task to avoid actual processing
with patch("app.routers.ocr.run_ocr_processing") as mock_task:
response = await ocr_client.post(
"/api/v1/ocr/upload",
files={"file": ("scan.png", io.BytesIO(b"fake-image"), "image/png")},
)
assert response.status_code == 202
data = response.json()
assert "ocr_result_id" in data
assert data["status"] == "pending"
assert data["message"] == "OCR processing queued"
@pytest.mark.asyncio
async def test_upload_no_file(self, ocr_client: AsyncClient):
"""POST /api/v1/ocr/upload without file returns 422."""
response = await ocr_client.post("/api/v1/ocr/upload")
assert response.status_code == 422
@pytest.mark.asyncio
async def test_upload_invalid_mime(self, ocr_client: AsyncClient, tmp_path):
"""POST /api/v1/ocr/upload with invalid MIME type returns 422."""
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
response = await ocr_client.post(
"/api/v1/ocr/upload",
files={"file": ("doc.pdf", io.BytesIO(b"fake-pdf"), "application/pdf")},
)
assert response.status_code == 422
data = response.json()
assert data["detail"]["error"]["code"] == "INVALID_MIME_TYPE"
@pytest.mark.asyncio
async def test_get_result_success(self, ocr_client: AsyncClient, tmp_path):
"""GET /api/v1/ocr/results/:id returns 200 with result data."""
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
with patch("app.routers.ocr.run_ocr_processing"):
upload_resp = await ocr_client.post(
"/api/v1/ocr/upload",
files={"file": ("scan.png", io.BytesIO(b"fake-image"), "image/png")},
)
result_id = upload_resp.json()["ocr_result_id"]
response = await ocr_client.get(f"/api/v1/ocr/results/{result_id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == result_id
assert data["status"] == "pending"
@pytest.mark.asyncio
async def test_get_result_not_found(self, ocr_client: AsyncClient):
"""GET /api/v1/ocr/results/:nonexistent returns 404."""
fake_id = uuid.uuid4()
response = await ocr_client.get(f"/api/v1/ocr/results/{fake_id}")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_list_results(self, ocr_client: AsyncClient, tmp_path):
"""GET /api/v1/ocr/results returns 200 with list."""
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
with patch("app.routers.ocr.run_ocr_processing"):
for i in range(3):
await ocr_client.post(
"/api/v1/ocr/upload",
files={"file": (f"scan{i}.png", io.BytesIO(b"fake-image"), "image/png")},
)
response = await ocr_client.get("/api/v1/ocr/results")
assert response.status_code == 200
data = response.json()
assert data["total"] >= 3
assert len(data["items"]) >= 3
@pytest.mark.asyncio
async def test_list_results_filter_vehicle(
self, ocr_client: AsyncClient, test_vehicle: Vehicle, tmp_path
):
"""GET /api/v1/ocr/results?vehicle_id=X returns filtered list."""
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
with patch("app.routers.ocr.run_ocr_processing"):
await ocr_client.post(
"/api/v1/ocr/upload",
files={"file": ("scan1.png", io.BytesIO(b"img1"), "image/png")},
data={"vehicle_id": str(test_vehicle.id)},
)
await ocr_client.post(
"/api/v1/ocr/upload",
files={"file": ("scan2.png", io.BytesIO(b"img2"), "image/png")},
)
response = await ocr_client.get(
f"/api/v1/ocr/results?vehicle_id={test_vehicle.id}"
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
@pytest.mark.asyncio
async def test_apply_to_vehicle_endpoint(
self, ocr_client: AsyncClient, test_vehicle: Vehicle, tmp_path
):
"""POST /api/v1/ocr/results/:id/apply returns 200 and updates vehicle."""
with patch.object(ocr_service.settings, "UPLOAD_DIR", str(tmp_path)):
with patch("app.routers.ocr.run_ocr_processing"):
upload_resp = await ocr_client.post(
"/api/v1/ocr/upload",
files={"file": ("scan.png", io.BytesIO(b"fake-image"), "image/png")},
data={"vehicle_id": str(test_vehicle.id)},
)
result_id = upload_resp.json()["ocr_result_id"]
# Manually set structured data via direct DB session
from tests.conftest import TEST_DATABASE_URL
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
engine = create_async_engine(TEST_DATABASE_URL)
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
stmt = select(OCRResult).where(OCRResult.id == uuid.UUID(result_id))
res = await session.execute(stmt)
ocr = res.scalar_one()
ocr.structured_data = {
"brand": "Volvo",
"model": "FH16",
"vin": "WDB9066351L123456",
"first_registration": "20.01.2021",
"mileage": 200000,
"power_kw": 500,
"fuel_type": "Diesel",
}
ocr.confidence_score = 0.9
ocr.status = OCRStatus.completed
await session.commit()
await engine.dispose()
response = await ocr_client.post(f"/api/v1/ocr/results/{result_id}/apply")
assert response.status_code == 200
data = response.json()
assert data["vehicle_id"] == str(test_vehicle.id)
assert "make" in data["updated_fields"]
class TestOpenRouterClient:
"""Tests for the OpenRouter API client utility."""
def test_parse_response_valid_json(self):
"""Test parsing a valid JSON response."""
from app.utils.openrouter import _parse_response
raw = '{"brand": "BMW", "model": "X5", "vin": "ABC123", "confidence_score": 0.9}'
result = _parse_response(raw)
assert result["structured_data"]["brand"] == "BMW"
assert result["confidence_score"] == 0.9
def test_parse_response_markdown_fenced(self):
"""Test parsing a markdown-fenced JSON response."""
from app.utils.openrouter import _parse_response
raw = '```json\n{"brand": "Audi", "model": "A4", "confidence_score": 0.85}\n```'
result = _parse_response(raw)
assert result["structured_data"]["brand"] == "Audi"
assert result["confidence_score"] == 0.85
def test_parse_response_with_text_around(self):
"""Test parsing JSON embedded in text."""
from app.utils.openrouter import _parse_response
raw = 'Here is the result: {"brand": "VW", "model": "Golf", "confidence_score": 0.7} done.'
result = _parse_response(raw)
assert result["structured_data"]["brand"] == "VW"
assert result["confidence_score"] == 0.7
def test_parse_response_invalid(self):
"""Test parsing an invalid response returns defaults."""
from app.utils.openrouter import _parse_response
result = _parse_response("not json at all")
assert result["structured_data"] == {}
assert result["confidence_score"] == 0.0
def test_parse_response_clamps_confidence(self):
"""Test that confidence score is clamped to 0.0-1.0."""
from app.utils.openrouter import _parse_response
result = _parse_response('{"brand": "X", "confidence_score": 1.5}')
assert result["confidence_score"] == 1.0
result = _parse_response('{"brand": "X", "confidence_score": -0.5}')
assert result["confidence_score"] == 0.0
def test_parse_response_all_expected_fields(self):
"""Test that all expected fields are present in structured_data."""
from app.utils.openrouter import _parse_response, EXPECTED_FIELDS
raw = '{"brand": "M", "model": "A", "vin": "V", "first_registration": "01.01.2020", "mileage": 100, "power_kw": 200, "fuel_type": "D", "confidence_score": 0.8}'
result = _parse_response(raw)
for field in EXPECTED_FIELDS:
assert field in result["structured_data"]
@pytest.mark.asyncio
async def test_perform_ocr_no_api_key(self):
"""Test perform_ocr raises ValueError when no API key is configured."""
from app.utils.openrouter import perform_ocr
with patch("app.utils.openrouter.settings") as mock_settings:
mock_settings.OPENROUTER_API_KEY = ""
mock_settings.OPENROUTER_OCR_MODEL = "test-model"
mock_settings.OPENROUTER_BASE_URL = "https://test.example.com"
with pytest.raises(ValueError, match="OPENROUTER_API_KEY"):
await perform_ocr(b"image", "image/png")
@pytest.mark.asyncio
async def test_perform_ocr_mocked_httpx(self):
"""Test perform_ocr with mocked httpx client."""
from app.utils.openrouter import perform_ocr
mock_response = MagicMock()
mock_response.json.return_value = {
"choices": [
{
"message": {
"content": '{"brand": "Test", "model": "Model", "vin": "VIN123", "confidence_score": 0.95}'
}
}
]
}
mock_response.raise_for_status = MagicMock()
with patch("app.utils.openrouter.settings") as mock_settings:
mock_settings.OPENROUTER_API_KEY = "test-key"
mock_settings.OPENROUTER_OCR_MODEL = "test-model"
mock_settings.OPENROUTER_BASE_URL = "https://test.example.com"
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client_class.return_value = mock_client
result = await perform_ocr(b"image", "image/png")
assert result["structured_data"]["brand"] == "Test"
assert result["confidence_score"] == 0.95