kiwi/app/models/schemas/recipe_scan.py
pyr0ball 896b4e048c feat: recipe scanner — photo to structured recipe (kiwi#9)
New feature: photograph a recipe card, cookbook page, or handwritten
note and have it extracted into a structured, editable recipe.

Backend:
- POST /recipes/scan: accept 1-4 photos, run VLM extraction, return
  structured JSON for review (not auto-saved)
- POST /recipes/scan/save: persist a reviewed/edited recipe
- GET/DELETE /recipes/user: user-created recipe CRUD
- Vision backend priority: cf-orch -> local Qwen2.5-VL -> Anthropic BYOK
- 503 with clear config hint when no vision backend available
- Multi-photo support: facing pages (ingredients/directions) sent together
- Pantry cross-reference: marks which ingredients are already on hand
- migration 041: user_recipes table (title, servings, cook_time, steps,
  ingredients JSON, source, pantry_match_pct)
- Tier gate: recipe_scan -> paid, BYOK-unlockable

Frontend:
- "Scan" button in the Recipes tab bar (camera icon)
- RecipeScanModal: upload step (drag-drop + file picker, up to 4 photos,
  live previews), processing step (spinner), review/edit step (all
  fields inline-editable before save), pantry match badge, warning banner
  for low-confidence or incomplete scans

Tests: 35 new tests (23 unit + 12 API), 404 total passing
2026-04-27 08:23:01 -07:00

74 lines
3.3 KiB
Python

"""Pydantic schemas for the recipe scanner (kiwi#9).
Scan input → photo(s).
Scan output → ScannedRecipeResponse (for review + editing before save).
Save input → ScannedRecipeSaveRequest.
User recipe output → UserRecipeResponse (after save).
"""
from __future__ import annotations
from pydantic import BaseModel, Field
# ── Ingredient in a scanned recipe ────────────────────────────────────────────
class ScannedIngredientSchema(BaseModel):
"""One ingredient line extracted from a recipe photo."""
name: str # normalized generic name ("ranch dressing")
qty: str | None = None # quantity as string, preserving fractions ("1/2", "¼")
unit: str | None = None # unit of measure; null for countable items
raw: str | None = None # verbatim original line from the image
in_pantry: bool = False # True if this ingredient matches something in the pantry
# ── Scan response (returned immediately, not persisted) ───────────────────────
class ScannedRecipeResponse(BaseModel):
"""Structured recipe extracted from photo(s). Returned for user review before save."""
title: str | None = None
subtitle: str | None = None # e.g. "with Broccoli & Ranch Dressing"
servings: str | None = None # kept as string: "2", "4-6", "serves 8"
cook_time: str | None = None # kept as string: "25 min", "1 hour"
source_note: str | None = None # e.g. "Purple Carrot", "Betty Crocker"
ingredients: list[ScannedIngredientSchema] = Field(default_factory=list)
steps: list[str] = Field(default_factory=list)
notes: str | None = None
tags: list[str] = Field(default_factory=list)
pantry_match_pct: int = 0 # 0-100: percentage of ingredients found in pantry
confidence: str = "medium" # "high" | "medium" | "low"
warnings: list[str] = Field(default_factory=list)
# ── Save request ──────────────────────────────────────────────────────────────
class ScannedRecipeSaveRequest(BaseModel):
"""User-reviewed (possibly edited) recipe data to persist as a user recipe."""
title: str
subtitle: str | None = None
servings: str | None = None
cook_time: str | None = None
source_note: str | None = None
ingredients: list[ScannedIngredientSchema]
steps: list[str]
notes: str | None = None
tags: list[str] = Field(default_factory=list)
source: str = "scan" # "scan" | "manual"
# ── User recipe (persisted) ───────────────────────────────────────────────────
class UserRecipeResponse(BaseModel):
"""A user-created or user-scanned recipe stored in user_recipes table."""
id: int
title: str
subtitle: str | None = None
servings: str | None = None
cook_time: str | None = None
source_note: str | None = None
ingredients: list[ScannedIngredientSchema]
steps: list[str]
notes: str | None = None
tags: list[str] = Field(default_factory=list)
source: str
pantry_match_pct: int | None = None
created_at: str