When a barcode scan finds no product in FDC/OFF, paid-tier users now see a "Capture label" offer instead of a dead-end "add manually" prompt. Backend: - Migration 036: captured_products local cache table (keyed by barcode, UPSERT on conflict so re-capture refreshes rather than errors) - store.get_captured_product / save_captured_product (with JSON decode for ingredient_names and allergens) - app/services/label_capture.py: wraps cf-core VisionRouter (caption API); graceful fallback to zero-confidence mock when stub/error; JSON fence stripping; confidence clamped to [0,1]; KIWI_LABEL_CAPTURE_MOCK=1 for tests - New schemas: LabelCaptureResponse, LabelConfirmRequest, LabelConfirmResponse - POST /inventory/scan/label-capture — image to extraction (paid+ gate, 403) - POST /inventory/scan/label-confirm — save confirmed product + optional inventory add - Both scan endpoints now: check captured_products cache before FDC/OFF; set needs_visual_capture=True for gap products on paid tier; BarcodeScanResult gains needs_visual_capture field - visual_label_capture feature gate added to tiers.py (paid) Tests: 42 new tests (service, store/migration, API endpoints) — 367 total passing Frontend: - InventoryList.vue: capturePhase state machine (offer => uploading => reviewing) - Offer card appears after scan gap (calm UX: no urgency, Discard always visible) - Review form: pre-populated from extraction; amber label highlights for unread fields (confidence < 0.7); comma-separated ingredients/allergens - api.ts: LabelCaptureResult + LabelConfirmRequest types; captureLabelPhoto() and confirmLabelCapture() API methods
59 lines
2 KiB
Python
59 lines
2 KiB
Python
"""Pydantic schemas for visual label capture (kiwi#79)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class LabelCaptureResponse(BaseModel):
|
|
"""Extraction result returned after the user photographs a nutrition label."""
|
|
barcode: str
|
|
product_name: Optional[str] = None
|
|
brand: Optional[str] = None
|
|
serving_size_g: Optional[float] = None
|
|
calories: Optional[float] = None
|
|
fat_g: Optional[float] = None
|
|
saturated_fat_g: Optional[float] = None
|
|
carbs_g: Optional[float] = None
|
|
sugar_g: Optional[float] = None
|
|
fiber_g: Optional[float] = None
|
|
protein_g: Optional[float] = None
|
|
sodium_mg: Optional[float] = None
|
|
ingredient_names: List[str] = Field(default_factory=list)
|
|
allergens: List[str] = Field(default_factory=list)
|
|
confidence: float = 0.0
|
|
needs_review: bool = True # True when confidence < REVIEW_THRESHOLD
|
|
|
|
|
|
class LabelConfirmRequest(BaseModel):
|
|
"""User-confirmed extraction to save to the local product cache."""
|
|
barcode: str
|
|
product_name: Optional[str] = None
|
|
brand: Optional[str] = None
|
|
serving_size_g: Optional[float] = None
|
|
calories: Optional[float] = None
|
|
fat_g: Optional[float] = None
|
|
saturated_fat_g: Optional[float] = None
|
|
carbs_g: Optional[float] = None
|
|
sugar_g: Optional[float] = None
|
|
fiber_g: Optional[float] = None
|
|
protein_g: Optional[float] = None
|
|
sodium_mg: Optional[float] = None
|
|
ingredient_names: List[str] = Field(default_factory=list)
|
|
allergens: List[str] = Field(default_factory=list)
|
|
confidence: float = 0.0
|
|
# When True the confirmed product is also added to inventory
|
|
location: str = "pantry"
|
|
quantity: float = 1.0
|
|
auto_add: bool = True
|
|
|
|
|
|
class LabelConfirmResponse(BaseModel):
|
|
"""Result of confirming a captured product."""
|
|
ok: bool
|
|
barcode: str
|
|
product_id: Optional[int] = None
|
|
inventory_item_id: Optional[int] = None
|
|
message: str
|