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
+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