kiwi/app/services/receipt_service.py
pyr0ball 8cbde774e5 chore: initial commit — kiwi Phase 2 complete
Pantry tracker app with:
- FastAPI backend + Vue 3 SPA frontend
- SQLite via circuitforge-core (migrations 001-005)
- Inventory CRUD, barcode scan, receipt OCR pipeline
- Expiry prediction (deterministic + LLM fallback)
- CF-core tier system integration
- Cloud session support (menagerie)
2026-03-30 22:20:48 -07:00

126 lines
5.5 KiB
Python

"""
Receipt processing service — orchestrates the OCR pipeline.
Pipeline stages:
1. Preprocess — enhance image, convert to PNG
2. Quality — score image; abort to 'low_quality' if below threshold
3. OCR — VisionLanguageOCR extracts structured data
4. Persist — flatten result into receipt_data table
5. Stage — set status to 'staged'; items await human approval
Items are NOT added to inventory automatically. Use the
POST /receipts/{id}/ocr/approve endpoint to commit approved items.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from app.db.store import Store
logger = logging.getLogger(__name__)
def _flatten_ocr_result(result: dict[str, Any]) -> dict[str, Any]:
"""Map nested VisionLanguageOCR output to the flat receipt_data schema."""
merchant = result.get("merchant") or {}
transaction = result.get("transaction") or {}
totals = result.get("totals") or {}
return {
"merchant_name": merchant.get("name"),
"merchant_address": merchant.get("address"),
"merchant_phone": merchant.get("phone"),
"transaction_date": transaction.get("date"),
"transaction_time": transaction.get("time"),
"receipt_number": transaction.get("receipt_number"),
"register_number": transaction.get("register"),
"cashier_name": transaction.get("cashier"),
"items": result.get("items") or [],
"subtotal": totals.get("subtotal"),
"tax": totals.get("tax"),
"discount": totals.get("discount"),
"total": totals.get("total"),
"payment_method": totals.get("payment_method"),
"amount_paid": totals.get("amount_paid"),
"change_given": totals.get("change"),
"raw_text": result.get("raw_text"),
"confidence_scores": result.get("confidence") or {},
"warnings": result.get("warnings") or [],
}
class ReceiptService:
def __init__(self, store: Store) -> None:
self.store = store
async def process(self, receipt_id: int, image_path: Path) -> None:
"""Run the full OCR pipeline for a receipt image.
Stages run synchronously inside asyncio.to_thread so SQLite and the
VLM (which uses torch) both stay off the async event loop.
"""
import asyncio
await asyncio.to_thread(self._run_pipeline, receipt_id, image_path)
def _run_pipeline(self, receipt_id: int, image_path: Path) -> None:
from app.core.config import settings
from app.services.image_preprocessing.enhancement import ImageEnhancer
from app.services.image_preprocessing.format_conversion import FormatConverter
from app.services.quality.assessment import QualityAssessor
# ── Stage 1: Preprocess ───────────────────────────────────────────────
enhancer = ImageEnhancer()
converter = FormatConverter()
enhanced = enhancer.enhance(image_path)
processed_path = converter.to_png(enhanced)
# ── Stage 2: Quality assessment ───────────────────────────────────────
assessor = QualityAssessor()
assessment = assessor.assess(processed_path)
self.store.upsert_quality_assessment(
receipt_id,
overall_score=assessment["overall_score"],
is_acceptable=assessment["is_acceptable"],
metrics=assessment.get("metrics", {}),
suggestions=assessment.get("suggestions", []),
)
if not assessment["is_acceptable"]:
self.store.update_receipt_status(receipt_id, "low_quality")
logger.warning(
"Receipt %s: quality too low for OCR (score=%.1f) — %s",
receipt_id, assessment["overall_score"],
"; ".join(assessment.get("suggestions", [])),
)
return
if not settings.ENABLE_OCR:
self.store.update_receipt_status(receipt_id, "processed")
logger.info("Receipt %s: quality OK but ENABLE_OCR=false — skipping OCR", receipt_id)
return
# ── Stage 3: OCR extraction ───────────────────────────────────────────
from app.services.ocr.vl_model import VisionLanguageOCR
ocr = VisionLanguageOCR()
result = ocr.extract_receipt_data(str(processed_path))
if result.get("error"):
self.store.update_receipt_status(receipt_id, "error", result["error"])
logger.error("Receipt %s: OCR failed — %s", receipt_id, result["error"])
return
# ── Stage 4: Persist extracted data ───────────────────────────────────
flat = _flatten_ocr_result(result)
self.store.upsert_receipt_data(receipt_id, flat)
item_count = len(flat.get("items") or [])
# ── Stage 5: Stage for human approval ────────────────────────────────
self.store.update_receipt_status(receipt_id, "staged")
logger.info(
"Receipt %s: OCR complete — %d item(s) staged for review "
"(confidence=%.2f)",
receipt_id, item_count,
(result.get("confidence") or {}).get("overall", 0.0),
)