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