kiwi/app/models/schemas/inventory.py
pyr0ball 17e62c451f feat: visual label capture for unenriched barcodes (kiwi#79)
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
2026-04-24 17:57:25 -07:00

189 lines
5.8 KiB
Python

"""Pydantic schemas for inventory management (integer IDs, SQLite-compatible)."""
from __future__ import annotations
from datetime import date, datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
# ── Tags ──────────────────────────────────────────────────────────────────────
class TagCreate(BaseModel):
name: str = Field(..., max_length=100)
slug: str = Field(..., max_length=100)
description: Optional[str] = None
color: Optional[str] = Field(None, max_length=7)
category: Optional[str] = None
class TagResponse(BaseModel):
id: int
name: str
slug: str
description: Optional[str]
color: Optional[str]
category: Optional[str]
created_at: str
updated_at: str
model_config = {"from_attributes": True}
# ── Products ──────────────────────────────────────────────────────────────────
class ProductCreate(BaseModel):
name: str = Field(..., max_length=500)
barcode: Optional[str] = Field(None, max_length=50)
brand: Optional[str] = None
category: Optional[str] = None
description: Optional[str] = None
image_url: Optional[str] = None
nutrition_data: Dict[str, Any] = Field(default_factory=dict)
source: str = "manual"
source_data: Dict[str, Any] = Field(default_factory=dict)
class ProductUpdate(BaseModel):
name: Optional[str] = None
brand: Optional[str] = None
category: Optional[str] = None
description: Optional[str] = None
image_url: Optional[str] = None
nutrition_data: Optional[Dict[str, Any]] = None
class ProductResponse(BaseModel):
id: int
barcode: Optional[str]
name: str
brand: Optional[str]
category: Optional[str]
description: Optional[str]
image_url: Optional[str]
nutrition_data: Dict[str, Any]
source: str
created_at: str
updated_at: str
model_config = {"from_attributes": True}
# ── Inventory Items ───────────────────────────────────────────────────────────
class InventoryItemCreate(BaseModel):
product_id: int
quantity: float = Field(default=1.0, gt=0)
unit: str = "count"
location: str
sublocation: Optional[str] = None
purchase_date: Optional[date] = None
expiration_date: Optional[date] = None
notes: Optional[str] = None
source: str = "manual"
class InventoryItemUpdate(BaseModel):
quantity: Optional[float] = Field(None, gt=0)
unit: Optional[str] = None
location: Optional[str] = None
sublocation: Optional[str] = None
purchase_date: Optional[date] = None
expiration_date: Optional[date] = None
opened_date: Optional[date] = None
status: Optional[str] = None
notes: Optional[str] = None
disposal_reason: Optional[str] = None
class PartialConsumeRequest(BaseModel):
quantity: float = Field(..., gt=0, description="Amount to consume from this item")
class DiscardRequest(BaseModel):
reason: Optional[str] = Field(None, max_length=200)
class InventoryItemResponse(BaseModel):
id: int
product_id: int
product_name: Optional[str] = None
barcode: Optional[str] = None
category: Optional[str] = None
quantity: float
unit: str
location: str
sublocation: Optional[str]
purchase_date: Optional[str]
expiration_date: Optional[str]
opened_date: Optional[str] = None
opened_expiry_date: Optional[str] = None
secondary_state: Optional[str] = None
secondary_uses: Optional[List[str]] = None
secondary_warning: Optional[str] = None
secondary_discard_signs: Optional[str] = None
status: str
notes: Optional[str]
disposal_reason: Optional[str] = None
source: str
created_at: str
updated_at: str
model_config = {"from_attributes": True}
# ── Barcode scan ──────────────────────────────────────────────────────────────
class BarcodeScanResult(BaseModel):
barcode: str
barcode_type: str
product: Optional[ProductResponse]
inventory_item: Optional[InventoryItemResponse]
added_to_inventory: bool
needs_manual_entry: bool = False
needs_visual_capture: bool = False # Paid tier offer when no product data found
message: str
class BarcodeScanResponse(BaseModel):
success: bool
barcodes_found: int
results: List[BarcodeScanResult]
message: str
# ── Bulk add by name ─────────────────────────────────────────────────────────
class BulkAddItem(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
quantity: float = Field(default=1.0, gt=0)
unit: str = "count"
location: str = "pantry"
class BulkAddByNameRequest(BaseModel):
items: List[BulkAddItem] = Field(..., min_length=1)
class BulkAddItemResult(BaseModel):
name: str
ok: bool
item_id: Optional[int] = None
error: Optional[str] = None
class BulkAddByNameResponse(BaseModel):
added: int
failed: int
results: List[BulkAddItemResult]
# ── Stats ─────────────────────────────────────────────────────────────────────
class InventoryStats(BaseModel):
total_items: int
available_items: int
expiring_soon: int
expired_items: int
locations: Dict[str, int]