feat(T03): OCR-Erfassung via OpenRouter Qwen2.5-VL + OCR UI with drag-and-drop
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user