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
This commit is contained in:
pyr0ball 2026-04-27 08:23:01 -07:00
parent c9fcfde694
commit 896b4e048c
12 changed files with 2335 additions and 12 deletions

View file

@ -0,0 +1,256 @@
"""Recipe scanner endpoints (kiwi#9).
POST /recipes/scan -- scan photo(s) -> structured recipe JSON (not saved)
POST /recipes/scan/save -- save a confirmed scanned recipe to user_recipes
GET /recipes/user -- list user-created recipes
GET /recipes/user/{id} -- get a single user recipe
DELETE /recipes/user/{id} -- delete a user recipe
BSL 1.1 -- recipe_scan requires Paid tier or BYOK.
"""
from __future__ import annotations
import asyncio
import logging
import uuid
from pathlib import Path
from typing import Annotated
import aiofiles
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import JSONResponse
from app.cloud_session import CloudUser, get_session
from app.core.config import settings
from app.db.session import get_store
from app.db.store import Store
from app.models.schemas.recipe_scan import (
ScannedIngredientSchema,
ScannedRecipeResponse,
ScannedRecipeSaveRequest,
UserRecipeResponse,
)
from app.tiers import can_use
logger = logging.getLogger(__name__)
router = APIRouter()
_ALLOWED_MIME_TYPES = {
"image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic", "image/heif"
}
_MAX_FILE_SIZE_MB = 20
async def _save_upload_temp(file: UploadFile) -> Path:
"""Write upload to a temp path under UPLOAD_DIR. Caller is responsible for cleanup."""
settings.ensure_dirs()
dest = settings.UPLOAD_DIR / f"scan_{uuid.uuid4()}_{file.filename}"
async with aiofiles.open(dest, "wb") as f:
await f.write(await file.read())
return dest
def _result_to_response(result) -> ScannedRecipeResponse:
"""Convert ScannedRecipeResult (dataclass) to Pydantic response schema."""
return ScannedRecipeResponse(
title=result.title,
subtitle=result.subtitle,
servings=result.servings,
cook_time=result.cook_time,
source_note=result.source_note,
ingredients=[
ScannedIngredientSchema(
name=i.name,
qty=i.qty,
unit=i.unit,
raw=i.raw,
in_pantry=i.in_pantry,
)
for i in result.ingredients
],
steps=result.steps,
notes=result.notes,
tags=result.tags,
pantry_match_pct=result.pantry_match_pct,
confidence=result.confidence,
warnings=result.warnings,
)
def _row_to_user_recipe(row: dict) -> UserRecipeResponse:
"""Convert a store row dict to UserRecipeResponse."""
return UserRecipeResponse(
id=row["id"],
title=row["title"],
subtitle=row.get("subtitle"),
servings=row.get("servings"),
cook_time=row.get("cook_time"),
source_note=row.get("source_note"),
ingredients=[
ScannedIngredientSchema(**i) if isinstance(i, dict) else i
for i in (row.get("ingredients") or [])
],
steps=row.get("steps") or [],
notes=row.get("notes"),
tags=row.get("tags") or [],
source=row.get("source", "manual"),
pantry_match_pct=row.get("pantry_match_pct"),
created_at=row["created_at"],
)
# ── Scan endpoint ──────────────────────────────────────────────────────────────
@router.post("/scan", response_model=ScannedRecipeResponse)
async def scan_recipe(
files: Annotated[list[UploadFile], File(...)],
store: Store = Depends(get_store),
session: CloudUser = Depends(get_session),
):
"""Scan one or more recipe photos and return a structured recipe for review.
Accepts 1-4 images. Multi-page recipes (e.g. ingredients on page 1,
directions on page 2) work best when all pages are submitted together.
The response is NOT saved automatically -- the user reviews and edits it,
then calls POST /recipes/scan/save to persist.
Tier: Paid (or BYOK).
"""
if not can_use("recipe_scan", session.tier, session.has_byok):
raise HTTPException(
status_code=403,
detail=(
"Recipe scanning requires Paid tier or a configured vision backend (BYOK). "
"Set ANTHROPIC_API_KEY or connect to a cf-orch vision service."
),
)
if not files:
raise HTTPException(status_code=422, detail="At least one image file is required.")
if len(files) > 4:
raise HTTPException(status_code=422, detail="Maximum 4 images per scan request.")
for f in files:
ct = (f.content_type or "").lower()
if ct and ct not in _ALLOWED_MIME_TYPES:
raise HTTPException(
status_code=422,
detail=f"Unsupported file type: {ct}. Supported: JPEG, PNG, WebP, HEIC.",
)
# Save uploads to temp files
saved_paths: list[Path] = []
try:
for f in files:
saved_paths.append(await _save_upload_temp(f))
# Get pantry item names for cross-reference
inventory = await asyncio.to_thread(store.list_inventory)
pantry_names = [item["product_name"] for item in inventory if item.get("product_name")]
# Run scanner (blocks on VLM -- use to_thread)
from app.services.recipe.recipe_scanner import RecipeScanner
def _run_scan():
scanner = RecipeScanner()
return scanner.scan(saved_paths, pantry_names=pantry_names)
try:
result = await asyncio.to_thread(_run_scan)
except ValueError as exc:
msg = str(exc)
if "not_a_recipe" in msg:
raise HTTPException(
status_code=422,
detail="The image does not appear to contain a recipe. "
"Please photograph a recipe card, cookbook page, or handwritten note.",
)
raise HTTPException(status_code=422, detail=msg)
except RuntimeError as exc:
raise HTTPException(
status_code=503,
detail=str(exc),
)
return _result_to_response(result)
finally:
# Clean up temp files
for p in saved_paths:
try:
p.unlink(missing_ok=True)
except Exception:
pass
# ── Save endpoint ──────────────────────────────────────────────────────────────
@router.post("/scan/save", response_model=UserRecipeResponse, status_code=201)
async def save_scanned_recipe(
body: ScannedRecipeSaveRequest,
store: Store = Depends(get_store),
session: CloudUser = Depends(get_session),
):
"""Save a user-reviewed (possibly edited) scanned recipe.
The body is the ScannedRecipeResponse (or a user-edited version of it).
Returns the persisted UserRecipe with an assigned ID.
Tier: Free (saving your own recipe doesn't require vision access).
"""
def _save():
return store.create_user_recipe(
title=body.title,
subtitle=body.subtitle,
servings=body.servings,
cook_time=body.cook_time,
source_note=body.source_note,
ingredients=[i.model_dump() for i in body.ingredients],
steps=body.steps,
notes=body.notes,
tags=body.tags,
source=body.source,
pantry_match_pct=None,
)
row = await asyncio.to_thread(_save)
return _row_to_user_recipe(row)
# ── User recipe list / get / delete ───────────────────────────────────────────
@router.get("/user", response_model=list[UserRecipeResponse])
async def list_user_recipes(
store: Store = Depends(get_store),
session: CloudUser = Depends(get_session),
):
"""List all user-created recipes (scanned + manually entered), newest first."""
rows = await asyncio.to_thread(store.list_user_recipes)
return [_row_to_user_recipe(r) for r in rows]
@router.get("/user/{recipe_id}", response_model=UserRecipeResponse)
async def get_user_recipe(
recipe_id: int,
store: Store = Depends(get_store),
session: CloudUser = Depends(get_session),
):
"""Get a single user recipe by ID."""
row = await asyncio.to_thread(store.get_user_recipe, recipe_id)
if not row:
raise HTTPException(status_code=404, detail="User recipe not found.")
return _row_to_user_recipe(row)
@router.delete("/user/{recipe_id}", status_code=204)
async def delete_user_recipe(
recipe_id: int,
store: Store = Depends(get_store),
session: CloudUser = Depends(get_session),
):
"""Delete a user recipe by ID."""
deleted = await asyncio.to_thread(store.delete_user_recipe, recipe_id)
if not deleted:
raise HTTPException(status_code=404, detail="User recipe not found.")
return JSONResponse(status_code=204, content=None)

View file

@ -2,6 +2,7 @@ from fastapi import APIRouter
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, feedback_attach, household, saved_recipes, imitate, meal_plans, orch_usage, session, shopping
from app.api.endpoints.community import router as community_router
from app.api.endpoints.corrections import router as corrections_router
from app.api.endpoints.recipe_scan import router as recipe_scan_router
from app.api.endpoints.recipe_tags import router as recipe_tags_router
api_router = APIRouter()
@ -13,6 +14,9 @@ api_router.include_router(ocr.router, prefix="/receipts", tags=
api_router.include_router(export.router, tags=["export"])
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
# recipe_scan_router registered BEFORE recipes.router so /recipes/scan and /recipes/user
# take priority over /recipes/{recipe_id} (which would otherwise match them as int IDs).
api_router.include_router(recipe_scan_router, prefix="/recipes", tags=["recipe-scan"])
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])

View file

@ -0,0 +1,23 @@
-- Migration 041: user_recipes table for user-scanned and manually-entered recipes.
--
-- Separate from the food.com corpus (recipes table) -- user recipes are personal,
-- not curated, and need different fields (servings as string, cook_time as string).
CREATE TABLE IF NOT EXISTS user_recipes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
subtitle TEXT,
servings TEXT, -- kept as string: "2", "4-6", "serves 8"
cook_time TEXT, -- kept as string: "25 min", "1 hour"
source_note TEXT, -- e.g. "Purple Carrot", "Betty Crocker"
ingredients TEXT NOT NULL DEFAULT '[]', -- JSON: [{name, qty, unit, raw}]
steps TEXT NOT NULL DEFAULT '[]', -- JSON: ["step 1", "step 2", ...]
notes TEXT,
tags TEXT DEFAULT '[]', -- JSON: ["vegan", "quick"]
source TEXT NOT NULL DEFAULT 'manual', -- 'scan' | 'manual'
pantry_match_pct INTEGER, -- 0-100, computed at scan time; null for manual
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_user_recipes_created ON user_recipes (created_at DESC);

View file

@ -61,6 +61,8 @@ class Store:
"style_tags",
# meal plan columns
"meal_types",
# user_recipes columns
"steps", "tags",
# captured_products columns
"allergens"):
if key in d and isinstance(d[key], str):
@ -1802,3 +1804,54 @@ class Store:
confidence, 1 if confirmed_by_user else 0, source,
),
)
# ── User Recipes (kiwi#9) ──────────────────────────────────────────────────
def create_user_recipe(
self,
title: str,
ingredients: list[dict],
steps: list[str],
subtitle: str | None = None,
servings: str | None = None,
cook_time: str | None = None,
source_note: str | None = None,
notes: str | None = None,
tags: list[str] | None = None,
source: str = "manual",
pantry_match_pct: int | None = None,
) -> dict[str, Any]:
return self._insert_returning(
"""INSERT INTO user_recipes
(title, subtitle, servings, cook_time, source_note,
ingredients, steps, notes, tags, source, pantry_match_pct)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *""",
(
title, subtitle, servings, cook_time, source_note,
self._dump(ingredients),
self._dump(steps),
notes,
self._dump(tags or []),
source,
pantry_match_pct,
),
)
def get_user_recipe(self, recipe_id: int) -> dict[str, Any] | None:
return self._fetch_one(
"SELECT * FROM user_recipes WHERE id = ?",
(recipe_id,),
)
def list_user_recipes(self) -> list[dict[str, Any]]:
return self._fetch_all(
"SELECT * FROM user_recipes ORDER BY created_at DESC",
)
def delete_user_recipe(self, recipe_id: int) -> bool:
cur = self.conn.execute(
"DELETE FROM user_recipes WHERE id = ?", (recipe_id,)
)
self.conn.commit()
return cur.rowcount > 0

View file

@ -0,0 +1,74 @@
"""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

View file

@ -0,0 +1,411 @@
"""Recipe scanner service (kiwi#9).
Extracts structured recipe data from one or more photos of recipe cards,
cookbook pages, or handwritten notes.
Pipeline:
photo(s) -> EXIF correction -> VLM extraction -> JSON parse -> pantry cross-ref
Vision backend priority (mirrors receipt OCR pattern):
1. cf-orch vision service (if CF_ORCH_URL set)
2. Local Qwen2.5-VL (if GPU available)
3. Anthropic API (BYOK -- if ANTHROPIC_API_KEY set)
BSL 1.1 -- requires Paid tier or BYOK.
"""
from __future__ import annotations
import base64
import io
import json
import logging
import os
import re
from dataclasses import dataclass
from pathlib import Path
logger = logging.getLogger(__name__)
# Maximum number of photos per scan call (to limit VLM context / VRAM)
MAX_IMAGES = 4
# VLM prompt -- adapted from tests/fixtures/recipe_scan/extract_test.py
_EXTRACTION_PROMPT = """
You are extracting a recipe from a photograph of a recipe card, cookbook page, or handwritten note.
If two or more images are provided, treat them as a single recipe across multiple pages
(e.g. ingredients on page 1, directions on page 2).
Return a single JSON object with these fields:
- title: recipe name (string)
- subtitle: any secondary title or serving suggestion e.g. "with Broccoli & Ranch Dressing" (string or null)
- servings: serving size if shown, as a string e.g. "2", "4-6" (string or null)
- cook_time: total cook time if shown, e.g. "15 min", "1 hour" (string or null)
- source_note: any attribution text like "From Betty Crocker" or "Purple Carrot" (string or null)
- ingredients: array of ingredient objects, each with:
- name: normalized generic ingredient name, lowercase, no quantities, no brand names
(e.g. "Follow Your Heart Vegan Ranch" becomes "ranch dressing")
- qty: quantity as a string, preserving fractions e.g. "1/2", a quarter symbol (string or null)
- unit: unit of measure, null for countable items (e.g. "3 eggs" has unit: null)
- raw: the original ingredient line verbatim, exactly as it appears
- steps: ordered array of instruction strings, one distinct step per element
- notes: any tips, substitutions, storage instructions, or variations (string or null)
- confidence: "high" if text is clear and complete, "medium" if some parts are uncertain,
"low" if mostly handwritten or significantly degraded
- warnings: array of strings describing anything the user should double-check
(e.g. "Directions appear to continue on another page not shown")
Return only valid JSON. No markdown fences. No explanation outside the JSON.
If the image does not appear to be a recipe at all, return: {"error": "not_a_recipe"}
""".strip()
# ── Data types ─────────────────────────────────────────────────────────────────
@dataclass
class ScannedIngredient:
name: str
qty: str | None = None
unit: str | None = None
raw: str | None = None
in_pantry: bool = False
@dataclass
class ScannedRecipeResult:
title: str | None
subtitle: str | None
servings: str | None
cook_time: str | None
source_note: str | None
ingredients: list[ScannedIngredient]
steps: list[str]
notes: str | None
tags: list[str]
pantry_match_pct: int
confidence: str
warnings: list[str]
# ── Image helpers ──────────────────────────────────────────────────────────────
def _load_image_b64(path: Path) -> str:
"""Load image, apply EXIF rotation, return base64-encoded JPEG bytes."""
from PIL import Image, ImageOps
with open(path, "rb") as f:
raw = f.read()
img = Image.open(io.BytesIO(raw))
img = ImageOps.exif_transpose(img).convert("RGB")
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=90)
return base64.b64encode(buf.getvalue()).decode()
# ── Vision backend ─────────────────────────────────────────────────────────────
def _call_via_anthropic(image_paths: list[Path], prompt: str) -> str:
"""Send image(s) + prompt to Anthropic API. Raises RuntimeError if unavailable."""
try:
import anthropic
except ImportError as exc:
raise RuntimeError("anthropic package not installed") from exc
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
raise RuntimeError("ANTHROPIC_API_KEY not set")
client = anthropic.Anthropic(api_key=api_key)
content: list[dict] = []
for i, path in enumerate(image_paths):
if i > 0:
content.append({"type": "text", "text": f"(Page {i + 1} of the same recipe:)"})
content.append({
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": _load_image_b64(path),
},
})
content.append({"type": "text", "text": prompt})
msg = client.messages.create(
# Haiku is cost-efficient for well-structured extraction prompts
model="claude-haiku-4-5-20251001",
max_tokens=2048,
messages=[{"role": "user", "content": content}],
)
return msg.content[0].text.strip()
def _call_via_local_vlm(image_paths: list[Path], prompt: str) -> str:
"""Send image(s) + prompt to local Qwen2.5-VL. Raises RuntimeError if unavailable."""
try:
import torch
except ImportError as exc:
raise RuntimeError("torch not installed") from exc
if not torch.cuda.is_available():
raise RuntimeError("No CUDA device -- local VLM unavailable")
# Lazy import so the module loads fast when GPU is absent
from transformers import Qwen2VLForConditionalGeneration, AutoProcessor
from PIL import Image, ImageOps
model_name = "Qwen/Qwen2.5-VL-7B-Instruct"
logger.info("Loading local VLM for recipe scan: %s", model_name)
model = Qwen2VLForConditionalGeneration.from_pretrained(
model_name,
torch_dtype=torch.float16,
device_map="auto",
low_cpu_mem_usage=True,
)
processor = AutoProcessor.from_pretrained(model_name)
model.train(False) # inference mode
images = []
for path in image_paths:
with open(path, "rb") as f:
raw = f.read()
img = Image.open(io.BytesIO(raw))
img = ImageOps.exif_transpose(img).convert("RGB")
images.append(img)
inputs = processor(images=images, text=prompt, return_tensors="pt")
inputs = {k: v.to("cuda", torch.float16) if isinstance(v, torch.Tensor) else v
for k, v in inputs.items()}
with torch.no_grad():
output_ids = model.generate(
**inputs,
max_new_tokens=2048,
do_sample=False,
temperature=0.0,
)
output = processor.decode(output_ids[0], skip_special_tokens=True)
output = output.replace(prompt, "").strip()
# Free VRAM
del model
torch.cuda.empty_cache()
return output
def _call_vision_backend(image_paths: list[Path], prompt: str) -> str:
"""Dispatch to the best available vision backend.
Priority: cf-orch vision -> local Qwen2.5-VL -> Anthropic API.
Raises RuntimeError with a clear message when no backend is available.
"""
errors: list[str] = []
# 1. Try cf-orch vision allocation
cf_orch_url = os.environ.get("CF_ORCH_URL")
if cf_orch_url:
try:
from circuitforge_orch.client import CFOrchClient
from app.services.ocr.docuvision_client import DocuvisionClient
client = CFOrchClient(cf_orch_url)
with client.allocate(
service="cf-vision",
model_candidates=["qwen2.5-vl-7b", "cf-docuvision"],
ttl_s=90.0,
caller="kiwi-recipe-scan",
) as alloc:
if alloc is not None:
doc_client = DocuvisionClient(alloc.url)
# docuvision takes a single image -- use first image only for now
result = doc_client.extract_text(image_paths[0])
if result.text:
return result.text
except Exception as exc:
logger.debug("cf-orch vision failed for recipe scan: %s", exc)
errors.append(f"cf-orch: {exc}")
# 2. Try local Qwen2.5-VL
try:
return _call_via_local_vlm(image_paths, prompt)
except Exception as exc:
logger.debug("Local VLM unavailable for recipe scan: %s", exc)
errors.append(f"local VLM: {exc}")
# 3. Try Anthropic API (BYOK)
try:
return _call_via_anthropic(image_paths, prompt)
except Exception as exc:
logger.debug("Anthropic API failed for recipe scan: %s", exc)
errors.append(f"Anthropic: {exc}")
raise RuntimeError(
"No vision backend configured for recipe scanning. "
"Options: cf-orch (CF_ORCH_URL), local GPU, or ANTHROPIC_API_KEY (BYOK). "
f"Errors: {'; '.join(errors)}"
)
# ── Parsing helpers ────────────────────────────────────────────────────────────
def _normalize_ingredient_name(name: str) -> str:
"""Lowercase + strip whitespace. Preserves multi-word names as-is."""
return name.lower().strip()
def _parse_scanner_json(raw_text: str) -> dict:
"""Extract and return the JSON dict from VLM output.
Handles:
- Pure JSON
- JSON wrapped in ```json ... ``` markdown fences
- JSON preceded by a line of prose ("Here is the recipe: {...}")
Raises ValueError on not_a_recipe or unparseable output.
"""
text = raw_text.strip()
# Strip markdown fences if present
if text.startswith("```"):
parts = text.split("```")
for part in parts:
part = part.strip()
if part.startswith("json"):
part = part[4:].strip()
if part.startswith("{"):
text = part
break
# Try direct parse first
try:
data = json.loads(text)
except json.JSONDecodeError:
# Extract first JSON object embedded in prose
match = re.search(r"\{.*\}", text, re.DOTALL)
if not match:
raise ValueError(f"Could not parse JSON from VLM output: {text[:200]!r}")
try:
data = json.loads(match.group(0))
except json.JSONDecodeError as exc:
raise ValueError(f"Could not parse JSON from VLM output: {exc}") from exc
if isinstance(data, dict) and data.get("error") == "not_a_recipe":
raise ValueError("not_a_recipe: image does not appear to contain a recipe")
return data
# ── Pantry cross-reference ─────────────────────────────────────────────────────
def _cross_reference_pantry(
ingredients: list[ScannedIngredient],
pantry_names: list[str],
) -> tuple[list[ScannedIngredient], int]:
"""Mark ingredients found in the pantry and return updated list + match percent.
Matching is bidirectional by token:
- "broccoli florets" matches pantry item "broccoli" (pantry token in ingredient)
- "pumpkin seeds" matches pantry "pumpkin seeds" (exact)
Returns (updated_ingredients, pantry_match_pct).
"""
if not ingredients:
return ingredients, 0
normalized_pantry = [_normalize_ingredient_name(p) for p in pantry_names]
updated: list[ScannedIngredient] = []
matched = 0
for ingr in ingredients:
norm_ingr = _normalize_ingredient_name(ingr.name)
in_pantry = any(
(p_tok in norm_ingr or norm_ingr in p_tok)
for p in normalized_pantry
for p_tok in p.split()
if len(p_tok) >= 4 # skip short stop-words like "of", "and", "the"
)
updated.append(ScannedIngredient(
name=ingr.name,
qty=ingr.qty,
unit=ingr.unit,
raw=ingr.raw,
in_pantry=in_pantry,
))
if in_pantry:
matched += 1
pct = round(matched / len(ingredients) * 100)
return updated, pct
# ── Main scanner class ─────────────────────────────────────────────────────────
class RecipeScanner:
"""Stateless recipe scanner. One instance can be reused across requests."""
def scan(
self,
image_paths: list[Path],
pantry_names: list[str] | None = None,
) -> ScannedRecipeResult:
"""Extract a structured recipe from one or more photos.
Args:
image_paths: 1-4 image files (phone photos, scans).
pantry_names: Flat list of product names from user's inventory.
Pass [] or None to skip pantry cross-reference.
Returns:
ScannedRecipeResult with all fields populated.
Raises:
ValueError: Image is not a recipe, or JSON could not be parsed.
RuntimeError: No vision backend is configured.
"""
if not image_paths:
raise ValueError("At least one image is required")
if len(image_paths) > MAX_IMAGES:
raise ValueError(f"Maximum {MAX_IMAGES} images per scan (got {len(image_paths)})")
# Call vision backend
raw_text = _call_vision_backend(image_paths, _EXTRACTION_PROMPT)
# Parse JSON from VLM output
data = _parse_scanner_json(raw_text)
# Build ingredient list
raw_ingredients = data.get("ingredients") or []
ingredients: list[ScannedIngredient] = [
ScannedIngredient(
name=str(item.get("name") or "").strip() or "unknown",
qty=str(item["qty"]) if item.get("qty") is not None else None,
unit=str(item["unit"]) if item.get("unit") is not None else None,
raw=str(item["raw"]) if item.get("raw") is not None else None,
)
for item in raw_ingredients
if isinstance(item, dict)
]
# Pantry cross-reference
ingredients, pct = _cross_reference_pantry(
ingredients,
pantry_names or [],
)
return ScannedRecipeResult(
title=data.get("title") or None,
subtitle=data.get("subtitle") or None,
servings=str(data["servings"]) if data.get("servings") is not None else None,
cook_time=str(data["cook_time"]) if data.get("cook_time") is not None else None,
source_note=data.get("source_note") or None,
ingredients=ingredients,
steps=[str(s) for s in (data.get("steps") or []) if s],
notes=data.get("notes") or None,
tags=list(data.get("tags") or []),
pantry_match_pct=pct,
confidence=data.get("confidence") or "medium",
warnings=list(data.get("warnings") or []),
)

View file

@ -15,6 +15,7 @@ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
"recipe_suggestions",
"expiry_llm_matching",
"receipt_ocr",
"recipe_scan",
"style_classifier",
"meal_plan_llm",
"meal_plan_llm_timing",
@ -58,6 +59,9 @@ KIWI_FEATURES: dict[str, str] = {
"community_publish": "paid", # Publish plans/outcomes to community feed
"community_fork_adapt": "paid", # Fork with LLM pantry adaptation (BYOK-unlockable)
# Paid tier (continued)
"recipe_scan": "paid", # BYOK-unlockable: photo -> structured recipe
# Premium tier
"multi_household": "premium",
"background_monitoring": "premium",

View file

@ -0,0 +1,844 @@
<template>
<div class="modal-overlay" @click.self="close" role="dialog" aria-modal="true" :aria-labelledby="titleId">
<div class="modal-panel scan-modal">
<!-- Header -->
<div class="modal-header">
<h2 :id="titleId" class="modal-title">
<span v-if="phase === 'upload'">Scan a Recipe</span>
<span v-else-if="phase === 'processing'">Scanning...</span>
<span v-else>Review Recipe</span>
</h2>
<button class="btn-icon close-btn" @click="close" aria-label="Close">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<!-- Upload phase -->
<div v-if="phase === 'upload'" class="modal-body">
<p class="hint-text">
Photograph a recipe card, cookbook page, or handwritten note.
For multi-page recipes (ingredients on one page, directions on another)
select both photos together up to 4 images.
</p>
<!-- Drop zone -->
<div
class="drop-zone"
:class="{ 'drop-zone-active': isDragging, 'has-files': selectedFiles.length > 0 }"
@dragover.prevent="isDragging = true"
@dragleave="isDragging = false"
@drop.prevent="onDrop"
@click="fileInput?.click()"
role="button"
tabindex="0"
@keydown.enter.space="fileInput?.click()"
aria-label="Click or drop photos here"
>
<input
ref="fileInput"
type="file"
accept="image/jpeg,image/jpg,image/png,image/webp,image/heic,image/heif"
multiple
class="hidden-input"
@change="onFileChange"
/>
<div v-if="selectedFiles.length === 0" class="drop-zone-empty">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="camera-icon">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
<p class="drop-zone-label">Tap or drop photos here</p>
<p class="drop-zone-sub">JPEG, PNG, WebP, HEIC up to 4 photos</p>
</div>
<div v-else class="file-preview-grid">
<div
v-for="(_file, i) in selectedFiles"
:key="i"
class="file-preview-item"
>
<img :src="previewUrls[i]" :alt="`Photo ${i + 1}`" class="preview-img" />
<button
class="remove-file-btn"
@click.stop="removeFile(i)"
:aria-label="`Remove photo ${i + 1}`"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
<p class="preview-label">Page {{ i + 1 }}</p>
</div>
<div
v-if="selectedFiles.length < 4"
class="file-preview-add"
@click.stop="fileInput?.click()"
role="button"
tabindex="0"
@keydown.enter.space.stop="fileInput?.click()"
aria-label="Add another photo"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</div>
</div>
</div>
<div v-if="uploadError" class="status-badge status-error mt-sm" role="alert">
{{ uploadError }}
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="close">Cancel</button>
<button
class="btn btn-primary"
:disabled="selectedFiles.length === 0"
@click="startScan"
>
Scan Recipe
</button>
</div>
</div>
<!-- Processing phase -->
<div v-else-if="phase === 'processing'" class="modal-body processing-body">
<div class="scan-spinner" aria-live="polite" aria-label="Scanning recipe">
<svg class="spin-icon" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
<p class="processing-label">Extracting recipe from {{ selectedFiles.length > 1 ? selectedFiles.length + ' photos' : 'photo' }}...</p>
<p class="processing-sub">This can take 10-30 seconds.</p>
</div>
</div>
<!-- Review phase -->
<div v-else-if="phase === 'review' && extracted" class="modal-body review-body">
<!-- Confidence banner -->
<div
v-if="extracted.confidence !== 'high' || extracted.warnings.length > 0"
:class="['status-badge', extracted.confidence === 'low' ? 'status-warning' : 'status-info', 'mb-sm']"
role="status"
>
<span v-if="extracted.confidence === 'low'">Low confidence scan handwritten or degraded text. Please review carefully.</span>
<span v-else>Medium confidence. Check the fields below.</span>
<ul v-if="extracted.warnings.length > 0" class="warning-list">
<li v-for="w in extracted.warnings" :key="w">{{ w }}</li>
</ul>
</div>
<!-- Pantry match badge -->
<div v-if="extracted.ingredients.length > 0" class="pantry-match-row mb-sm">
<span class="pantry-badge" :class="pantryMatchClass">
{{ extracted.pantry_match_pct }}% pantry match
({{ pantryCount }} of {{ extracted.ingredients.length }} ingredients on hand)
</span>
</div>
<!-- Editable fields -->
<div class="review-form">
<div class="form-group">
<label class="form-label" for="scan-title">Recipe name</label>
<input
id="scan-title"
v-model="editTitle"
class="form-input"
type="text"
placeholder="Recipe name"
required
/>
</div>
<div class="form-row-2">
<div class="form-group">
<label class="form-label" for="scan-servings">Servings</label>
<input id="scan-servings" v-model="editServings" class="form-input" type="text" placeholder="e.g. 2" />
</div>
<div class="form-group">
<label class="form-label" for="scan-cooktime">Cook time</label>
<input id="scan-cooktime" v-model="editCookTime" class="form-input" type="text" placeholder="e.g. 25 min" />
</div>
</div>
<!-- Ingredients -->
<div class="form-group">
<label class="form-label">Ingredients</label>
<div class="ingredient-list">
<div
v-for="(ingr, i) in editIngredients"
:key="i"
:class="['ingredient-row', ingr.in_pantry ? 'in-pantry' : '']"
>
<span v-if="ingr.in_pantry" class="pantry-dot" title="In your pantry" aria-label="In pantry"></span>
<input
v-model="ingr.qty"
class="form-input ingr-qty"
type="text"
placeholder="qty"
:aria-label="`Ingredient ${i + 1} quantity`"
/>
<input
v-model="ingr.unit"
class="form-input ingr-unit"
type="text"
placeholder="unit"
:aria-label="`Ingredient ${i + 1} unit`"
/>
<input
v-model="ingr.name"
class="form-input ingr-name"
type="text"
placeholder="ingredient"
:aria-label="`Ingredient ${i + 1} name`"
/>
<button
class="btn-icon remove-ingr-btn"
@click="removeIngredient(i)"
:aria-label="`Remove ingredient ${i + 1}`"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
<button class="btn btn-ghost btn-sm mt-xs" @click="addIngredient">+ Add ingredient</button>
</div>
<!-- Steps -->
<div class="form-group">
<label class="form-label">Steps</label>
<div class="step-list">
<div v-for="(_step, i) in editSteps" :key="i" class="step-row">
<span class="step-num">{{ i + 1 }}</span>
<textarea
v-model="editSteps[i]"
class="form-input step-textarea"
rows="2"
:aria-label="`Step ${i + 1}`"
></textarea>
<button
class="btn-icon remove-step-btn"
@click="removeStep(i)"
:aria-label="`Remove step ${i + 1}`"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
<button class="btn btn-ghost btn-sm mt-xs" @click="addStep">+ Add step</button>
</div>
<!-- Notes (optional) -->
<div class="form-group">
<label class="form-label" for="scan-notes">Notes <span class="optional-label">(optional)</span></label>
<textarea id="scan-notes" v-model="editNotes" class="form-input" rows="2" placeholder="Tips, variations, storage..."></textarea>
</div>
<!-- Source attribution -->
<div v-if="extracted.source_note" class="source-note">
Source: {{ extracted.source_note }}
</div>
</div>
<div v-if="saveError" class="status-badge status-error mt-sm" role="alert">
{{ saveError }}
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="phase = 'upload'">Re-scan</button>
<button
class="btn btn-primary"
:disabled="!editTitle.trim() || saving"
@click="save"
>
{{ saving ? 'Saving...' : 'Save Recipe' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onBeforeUnmount } from 'vue'
import { type ScannedRecipe, type ScannedIngredient, recipeScanAPI } from '@/services/api'
type Phase = 'upload' | 'processing' | 'review'
const emit = defineEmits<{
(e: 'close'): void
(e: 'saved', recipe: { id: number; title: string }): void
}>()
const titleId = 'scan-modal-title'
// Upload state
const phase = ref<Phase>('upload')
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFiles = ref<File[]>([])
const previewUrls = ref<string[]>([])
const isDragging = ref(false)
const uploadError = ref('')
function onDrop(e: DragEvent) {
isDragging.value = false
const dt = e.dataTransfer
if (!dt) return
addFiles(Array.from(dt.files))
}
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
if (!input.files) return
addFiles(Array.from(input.files))
// Reset so the same file can be re-selected after removal
input.value = ''
}
function addFiles(incoming: File[]) {
uploadError.value = ''
const combined = [...selectedFiles.value, ...incoming]
if (combined.length > 4) {
uploadError.value = 'Maximum 4 photos per scan.'
return
}
// Revoke old preview URLs before replacing
previewUrls.value.forEach((url) => URL.revokeObjectURL(url))
selectedFiles.value = combined
previewUrls.value = combined.map((f) => URL.createObjectURL(f))
}
function removeFile(index: number) {
URL.revokeObjectURL(previewUrls.value[index] ?? '')
selectedFiles.value = selectedFiles.value.filter((_, i) => i !== index)
previewUrls.value = previewUrls.value.filter((_, i) => i !== index)
}
// Scan
const extracted = ref<ScannedRecipe | null>(null)
async function startScan() {
if (selectedFiles.value.length === 0) return
uploadError.value = ''
phase.value = 'processing'
try {
const result = await recipeScanAPI.scan(selectedFiles.value)
extracted.value = result
initEditState(result)
phase.value = 'review'
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
uploadError.value = msg.includes('not appear to contain a recipe')
? 'This photo does not look like a recipe. Please try a different photo.'
: msg.includes('No vision backend')
? 'Recipe scanning is not available right now. Check your BYOK settings.'
: `Scan failed: ${msg}`
phase.value = 'upload'
}
}
// Review/edit state
const editTitle = ref('')
const editServings = ref('')
const editCookTime = ref('')
const editIngredients = ref<ScannedIngredient[]>([])
const editSteps = ref<string[]>([])
const editNotes = ref('')
function initEditState(r: ScannedRecipe) {
editTitle.value = r.title ?? ''
editServings.value = r.servings ?? ''
editCookTime.value = r.cook_time ?? ''
editIngredients.value = r.ingredients.map((i) => ({ ...i }))
editSteps.value = [...r.steps]
editNotes.value = r.notes ?? ''
}
function removeIngredient(i: number) {
editIngredients.value = editIngredients.value.filter((_, idx) => idx !== i)
}
function addIngredient() {
editIngredients.value = [...editIngredients.value, { name: '', qty: null, unit: null, raw: null, in_pantry: false }]
}
function removeStep(i: number) {
editSteps.value = editSteps.value.filter((_, idx) => idx !== i)
}
function addStep() {
editSteps.value = [...editSteps.value, '']
}
// Pantry match display
const pantryCount = computed(() =>
editIngredients.value.filter((i) => i.in_pantry).length
)
const pantryMatchClass = computed(() => {
const pct = extracted.value?.pantry_match_pct ?? 0
if (pct >= 80) return 'pantry-high'
if (pct >= 50) return 'pantry-mid'
return 'pantry-low'
})
// Save
const saving = ref(false)
const saveError = ref('')
async function save() {
if (!editTitle.value.trim()) return
saving.value = true
saveError.value = ''
try {
const payload = {
title: editTitle.value.trim(),
subtitle: extracted.value?.subtitle ?? null,
servings: editServings.value || null,
cook_time: editCookTime.value || null,
source_note: extracted.value?.source_note ?? null,
ingredients: editIngredients.value.filter((i) => i.name.trim()),
steps: editSteps.value.filter((s) => s.trim()),
notes: editNotes.value.trim() || null,
tags: extracted.value?.tags ?? [],
source: 'scan' as const,
}
const saved = await recipeScanAPI.saveScanned(payload)
emit('saved', { id: saved.id, title: saved.title })
close()
} catch (err: unknown) {
saveError.value = err instanceof Error ? err.message : 'Failed to save recipe.'
} finally {
saving.value = false
}
}
// Cleanup
function close() {
previewUrls.value.forEach((url) => URL.revokeObjectURL(url))
emit('close')
}
onBeforeUnmount(() => {
previewUrls.value.forEach((url) => URL.revokeObjectURL(url))
})
</script>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal, 1000);
padding: var(--spacing-md);
}
.modal-panel {
background: var(--bg-card, #fff);
border-radius: var(--radius-lg, 12px);
box-shadow: var(--shadow-xl, 0 20px 60px rgba(0,0,0,0.2));
width: 100%;
max-width: 560px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-md) var(--spacing-lg);
border-bottom: 1px solid var(--border-color, #e5e7eb);
flex-shrink: 0;
}
.modal-title {
font-size: var(--font-lg, 1.125rem);
font-weight: 600;
color: var(--text-primary, #111);
margin: 0;
}
.close-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
color: var(--text-secondary, #6b7280);
border-radius: var(--radius-sm, 4px);
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
background: var(--bg-hover, #f3f4f6);
color: var(--text-primary, #111);
}
.modal-body {
padding: var(--spacing-lg);
overflow-y: auto;
flex: 1;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--spacing-sm);
padding-top: var(--spacing-md);
border-top: 1px solid var(--border-color, #e5e7eb);
margin-top: var(--spacing-md);
}
/* ── Upload ── */
.hint-text {
color: var(--text-secondary, #6b7280);
font-size: var(--font-sm, 0.875rem);
margin-bottom: var(--spacing-md);
line-height: 1.5;
}
.drop-zone {
border: 2px dashed var(--border-color, #d1d5db);
border-radius: var(--radius-md, 8px);
padding: var(--spacing-xl);
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
min-height: 160px;
display: flex;
align-items: center;
justify-content: center;
}
.drop-zone:hover,
.drop-zone-active {
border-color: var(--color-primary, #4f46e5);
background: var(--bg-hover, #f5f3ff);
}
.drop-zone.has-files {
border-style: solid;
border-color: var(--color-primary, #4f46e5);
padding: var(--spacing-md);
}
.hidden-input {
display: none;
}
.drop-zone-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-xs);
}
.camera-icon {
color: var(--text-secondary, #9ca3af);
margin-bottom: var(--spacing-xs);
}
.drop-zone-label {
font-weight: 600;
color: var(--text-primary, #111);
margin: 0;
}
.drop-zone-sub {
color: var(--text-secondary, #6b7280);
font-size: var(--font-sm, 0.875rem);
margin: 0;
}
.file-preview-grid {
display: flex;
gap: var(--spacing-sm);
flex-wrap: wrap;
align-items: center;
width: 100%;
}
.file-preview-item {
position: relative;
width: 100px;
}
.preview-img {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: var(--radius-sm, 6px);
border: 1px solid var(--border-color, #e5e7eb);
}
.remove-file-btn {
position: absolute;
top: -6px;
right: -6px;
background: var(--color-danger, #ef4444);
color: white;
border: none;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
}
.preview-label {
text-align: center;
font-size: var(--font-xs, 0.75rem);
color: var(--text-secondary, #6b7280);
margin: 4px 0 0;
}
.file-preview-add {
width: 100px;
height: 100px;
border: 2px dashed var(--border-color, #d1d5db);
border-radius: var(--radius-sm, 6px);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--text-secondary, #9ca3af);
transition: border-color 0.15s;
}
.file-preview-add:hover {
border-color: var(--color-primary, #4f46e5);
color: var(--color-primary, #4f46e5);
}
/* ── Processing ── */
.processing-body {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
}
.scan-spinner {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-sm);
}
.spin-icon {
color: var(--color-primary, #4f46e5);
animation: spin 1.5s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.processing-label {
font-weight: 600;
color: var(--text-primary, #111);
margin: 0;
}
.processing-sub {
color: var(--text-secondary, #6b7280);
font-size: var(--font-sm, 0.875rem);
margin: 0;
}
/* ── Review ── */
.review-body {
padding-bottom: var(--spacing-sm);
}
.pantry-match-row {
display: flex;
align-items: center;
}
.pantry-badge {
display: inline-block;
font-size: var(--font-sm, 0.875rem);
font-weight: 600;
padding: 3px 10px;
border-radius: 999px;
}
.pantry-high { background: var(--color-success-bg, #d1fae5); color: var(--color-success, #065f46); }
.pantry-mid { background: var(--color-info-bg, #dbeafe); color: var(--color-info, #1e40af); }
.pantry-low { background: var(--bg-secondary, #f3f4f6); color: var(--text-secondary, #374151); }
.review-form {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.form-row-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-sm);
}
/* Ingredients */
.ingredient-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.ingredient-row {
display: flex;
align-items: center;
gap: 6px;
}
.pantry-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-success, #10b981);
flex-shrink: 0;
}
.in-pantry {
background: var(--color-success-bg-faint, #f0fdf4);
border-radius: var(--radius-sm, 4px);
padding: 2px 4px;
}
.ingr-qty { width: 60px; flex-shrink: 0; }
.ingr-unit { width: 70px; flex-shrink: 0; }
.ingr-name { flex: 1; }
.remove-ingr-btn,
.remove-step-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
color: var(--text-secondary, #9ca3af);
border-radius: var(--radius-sm, 4px);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.remove-ingr-btn:hover,
.remove-step-btn:hover {
background: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #ef4444);
}
/* Steps */
.step-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.step-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.step-num {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--bg-secondary, #f3f4f6);
color: var(--text-secondary, #374151);
font-size: var(--font-xs, 0.75rem);
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 8px;
}
.step-textarea {
flex: 1;
resize: vertical;
min-height: 60px;
}
/* Source */
.source-note {
font-size: var(--font-xs, 0.75rem);
color: var(--text-secondary, #9ca3af);
text-align: right;
font-style: italic;
}
.optional-label {
color: var(--text-secondary, #9ca3af);
font-weight: normal;
font-size: var(--font-xs, 0.75rem);
}
.warning-list {
margin: 4px 0 0;
padding-left: 16px;
font-size: var(--font-sm, 0.875rem);
}
.btn-ghost {
background: none;
border: none;
cursor: pointer;
color: var(--color-primary, #4f46e5);
padding: 4px 8px;
font-size: var(--font-sm, 0.875rem);
border-radius: var(--radius-sm, 4px);
}
.btn-ghost:hover {
background: var(--bg-hover, #f5f3ff);
}
.btn-sm {
padding: 4px 10px;
font-size: var(--font-sm, 0.875rem);
}
.mt-xs { margin-top: var(--spacing-xs, 4px); }
.mt-sm { margin-top: var(--spacing-sm, 8px); }
.mb-sm { margin-bottom: var(--spacing-sm, 8px); }
@media (max-width: 480px) {
.form-row-2 {
grid-template-columns: 1fr;
}
.modal-panel {
border-radius: var(--radius-md, 8px);
max-height: 95vh;
}
}
</style>

View file

@ -1,8 +1,9 @@
<template>
<div class="recipes-view">
<!-- Tab bar: Find / Browse / Saved -->
<div role="tablist" aria-label="Recipe sections" class="tab-bar flex gap-xs mb-md">
<!-- Tab bar: Find / Browse / Saved + Scan action button -->
<div class="tab-bar-row flex gap-xs mb-md" style="align-items:center;">
<div role="tablist" aria-label="Recipe sections" class="flex gap-xs" style="flex:1;flex-wrap:wrap;">
<button
v-for="tab in tabs"
:key="tab.id"
@ -15,6 +16,37 @@
@keydown="onTabKeydown"
>{{ tab.label }}</button>
</div>
<!-- Scan recipe button opens modal to photograph a recipe card -->
<button
class="btn btn-secondary scan-btn"
@click="scanModalOpen = true"
title="Scan a recipe card or cookbook page"
aria-label="Scan a recipe"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
Scan
</button>
</div>
<!-- Scan success toast -->
<div
v-if="lastScannedTitle"
class="undo-toast"
role="status"
aria-live="polite"
>
Saved "{{ lastScannedTitle }}" to your recipes.
</div>
<!-- Scan modal -->
<RecipeScanModal
v-if="scanModalOpen"
@close="scanModalOpen = false"
@saved="onScanSaved"
/>
<!-- Browse tab -->
<RecipeBrowserPanel
@ -746,10 +778,22 @@ import SavedRecipesPanel from './SavedRecipesPanel.vue'
import CommunityFeedPanel from './CommunityFeedPanel.vue'
import BuildYourOwnTab from './BuildYourOwnTab.vue'
import OrchUsagePill from './OrchUsagePill.vue'
import RecipeScanModal from './RecipeScanModal.vue'
import type { ForkResult } from '../stores/community'
import type { RecipeSuggestion, GroceryLink, StreamTokenResponse } from '../services/api'
import { recipesAPI } from '../services/api'
// Scan modal
const scanModalOpen = ref(false)
const lastScannedTitle = ref<string | null>(null)
function onScanSaved(recipe: { id: number; title: string }) {
lastScannedTitle.value = recipe.title
scanModalOpen.value = false
// Dismiss the toast after 4 seconds
setTimeout(() => { lastScannedTitle.value = null }, 4000)
}
// Streaming state
const isStreaming = ref(false)
const streamChunks = ref('')

View file

@ -1204,4 +1204,77 @@ export const DEFAULT_SENSORY_PREFERENCES: SensoryPreferences = {
max_noise: null,
}
// ── Recipe Scanner (kiwi#9) ───────────────────────────────────────────────────
export interface ScannedIngredient {
name: string
qty: string | null
unit: string | null
raw: string | null
in_pantry: boolean
}
export interface ScannedRecipe {
title: string | null
subtitle: string | null
servings: string | null
cook_time: string | null
source_note: string | null
ingredients: ScannedIngredient[]
steps: string[]
notes: string | null
tags: string[]
pantry_match_pct: number
confidence: 'high' | 'medium' | 'low'
warnings: string[]
}
export interface UserRecipe {
id: number
title: string
subtitle: string | null
servings: string | null
cook_time: string | null
source_note: string | null
ingredients: ScannedIngredient[]
steps: string[]
notes: string | null
tags: string[]
source: string
pantry_match_pct: number | null
created_at: string
}
export const recipeScanAPI = {
/** Scan 1-4 recipe photos. Returns structured recipe for review (not saved). */
scan(files: File[]): Promise<ScannedRecipe> {
const form = new FormData()
files.forEach((f) => form.append('files', f))
return api.post('/recipes/scan', form, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120_000, // VLM can be slow on first call
}).then((r) => r.data)
},
/** Save a reviewed/edited scanned recipe to user_recipes. */
saveScanned(recipe: Omit<ScannedRecipe, 'pantry_match_pct' | 'confidence' | 'warnings'> & { source?: string }): Promise<UserRecipe> {
return api.post('/recipes/scan/save', recipe).then((r) => r.data)
},
/** List all user-created recipes (scan + manual). */
listUserRecipes(): Promise<UserRecipe[]> {
return api.get('/recipes/user').then((r) => r.data)
},
/** Get a single user recipe by ID. */
getUserRecipe(id: number): Promise<UserRecipe> {
return api.get(`/recipes/user/${id}`).then((r) => r.data)
},
/** Delete a user recipe. */
deleteUserRecipe(id: number): Promise<void> {
return api.delete(`/recipes/user/${id}`).then(() => undefined)
},
}
export default api

View file

@ -0,0 +1,304 @@
"""API tests for recipe scan endpoints (kiwi#9).
VLM calls are mocked at the service level -- no GPU or API key needed.
"""
from __future__ import annotations
import io
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.cloud_session import get_session
from app.db.session import get_store
client = TestClient(app)
_GOOD_SCAN_JSON = {
"title": "Green Goddess Bowls",
"subtitle": "with Broccoli & Ranch Dressing",
"servings": "2",
"cook_time": "15 min",
"source_note": "Purple Carrot",
"ingredients": [
{"name": "brown rice", "qty": "1/2", "unit": "cup", "raw": "1/2 cup brown rice"},
{"name": "broccoli florets", "qty": "8", "unit": "oz", "raw": "8 oz broccoli florets"},
{"name": "avocado", "qty": "1", "unit": None, "raw": "1 avocado"},
],
"steps": ["Cook rice.", "Steam broccoli.", "Assemble bowls."],
"notes": None,
"confidence": "high",
"warnings": [],
}
def _make_session(tier: str = "paid", has_byok: bool = False) -> MagicMock:
mock = MagicMock()
mock.tier = tier
mock.has_byok = has_byok
mock.db = ":memory:"
return mock
def _make_store() -> MagicMock:
mock = MagicMock()
mock.list_inventory.return_value = [
{"product_name": "brown rice"},
{"product_name": "avocado"},
]
mock.create_user_recipe.return_value = {
"id": 1,
"title": "Green Goddess Bowls",
"subtitle": "with Broccoli & Ranch Dressing",
"servings": "2",
"cook_time": "15 min",
"source_note": "Purple Carrot",
"ingredients": _GOOD_SCAN_JSON["ingredients"],
"steps": _GOOD_SCAN_JSON["steps"],
"notes": None,
"tags": [],
"source": "scan",
"pantry_match_pct": None,
"created_at": "2026-04-27T00:00:00",
}
mock.list_user_recipes.return_value = []
mock.get_user_recipe.return_value = None
mock.delete_user_recipe.return_value = False
return mock
def _fake_image() -> bytes:
return b"\xff\xd8\xff\xe0" + b"\x00" * 100 # minimal JPEG magic
@pytest.fixture(autouse=True)
def override_deps():
session_mock = _make_session()
store_mock = _make_store()
app.dependency_overrides[get_session] = lambda: session_mock
app.dependency_overrides[get_store] = lambda: store_mock
yield session_mock, store_mock
app.dependency_overrides.clear()
# ── POST /recipes/scan ─────────────────────────────────────────────────────────
def _make_scan_result(title: str = "Green Goddess Bowls"):
"""Create a fake ScannedRecipeResult for tests."""
from app.services.recipe.recipe_scanner import ScannedIngredient, ScannedRecipeResult
return ScannedRecipeResult(
title=title,
subtitle="with Broccoli & Ranch Dressing",
servings="2",
cook_time="15 min",
source_note="Purple Carrot",
ingredients=[
ScannedIngredient("brown rice", "1/2", "cup", in_pantry=True),
ScannedIngredient("broccoli florets", "8", "oz"),
ScannedIngredient("avocado", "1", None, in_pantry=True),
],
steps=["Cook rice.", "Steam broccoli.", "Assemble bowls."],
notes=None,
tags=[],
pantry_match_pct=67,
confidence="high",
warnings=[],
)
@pytest.fixture
def mock_scan_infra(tmp_path):
"""Patch file-saving and VLM calls so scan endpoint tests don't need disk or GPU."""
fake_path = tmp_path / "recipe.jpg"
fake_path.write_bytes(_fake_image())
async def _fake_save(upload_file):
return fake_path
with patch("app.api.endpoints.recipe_scan._save_upload_temp", side_effect=_fake_save):
with patch("app.api.endpoints.recipe_scan.asyncio.to_thread") as mock_thread:
yield mock_thread, fake_path
class TestScanEndpoint:
def test_scan_returns_200(self, override_deps, mock_scan_infra):
"""Happy path: paid tier, valid JPEG, VLM returns good JSON."""
_, store_mock = override_deps
mock_thread, _ = mock_scan_infra
scan_result = _make_scan_result()
call_count = 0
def side_effect(fn, *args, **kwargs):
nonlocal call_count
call_count += 1
return store_mock.list_inventory() if call_count == 1 else scan_result
mock_thread.side_effect = side_effect
resp = client.post(
"/api/v1/recipes/scan",
files=[("files", ("recipe.jpg", _fake_image(), "image/jpeg"))],
)
assert resp.status_code == 200
data = resp.json()
assert data["title"] == "Green Goddess Bowls"
assert data["confidence"] == "high"
assert data["pantry_match_pct"] == 67
assert len(data["ingredients"]) == 3
def test_scan_requires_paid_tier(self, override_deps):
"""Free tier without BYOK should get 403."""
session_mock, _ = override_deps
session_mock.tier = "free"
session_mock.has_byok = False
resp = client.post(
"/api/v1/recipes/scan",
files=[("files", ("recipe.jpg", _fake_image(), "image/jpeg"))],
)
assert resp.status_code == 403
def test_scan_byok_free_tier_allowed(self, override_deps, mock_scan_infra):
"""Free tier WITH BYOK should be allowed through the tier gate."""
session_mock, store_mock = override_deps
session_mock.tier = "free"
session_mock.has_byok = True
mock_thread, _ = mock_scan_infra
scan_result = _make_scan_result("Simple Bowl")
call_count = 0
def _side(fn, *a, **kw):
nonlocal call_count
call_count += 1
return store_mock.list_inventory() if call_count == 1 else scan_result
mock_thread.side_effect = _side
resp = client.post(
"/api/v1/recipes/scan",
files=[("files", ("recipe.jpg", _fake_image(), "image/jpeg"))],
)
assert resp.status_code == 200
def test_scan_no_files_rejected(self, override_deps):
"""Missing files field returns 422."""
resp = client.post("/api/v1/recipes/scan", files=[])
assert resp.status_code in (422, 400)
def test_scan_too_many_files(self, override_deps, mock_scan_infra):
"""More than 4 files should return 422."""
mock_thread, _ = mock_scan_infra
mock_thread.return_value = []
files = [("files", (f"p{i}.jpg", _fake_image(), "image/jpeg")) for i in range(5)]
resp = client.post("/api/v1/recipes/scan", files=files)
assert resp.status_code == 422
def test_scan_not_a_recipe_returns_422(self, override_deps, mock_scan_infra):
_, store_mock = override_deps
mock_thread, _ = mock_scan_infra
call_count = 0
def _side(fn, *a, **kw):
nonlocal call_count
call_count += 1
if call_count == 1:
return store_mock.list_inventory()
raise ValueError("not_a_recipe: image does not appear to contain a recipe")
mock_thread.side_effect = _side
resp = client.post(
"/api/v1/recipes/scan",
files=[("files", ("photo.jpg", _fake_image(), "image/jpeg"))],
)
assert resp.status_code == 422
assert "recipe" in resp.json()["detail"].lower()
def test_scan_backend_unavailable_returns_503(self, override_deps, mock_scan_infra):
_, store_mock = override_deps
mock_thread, _ = mock_scan_infra
call_count = 0
def _side(fn, *a, **kw):
nonlocal call_count
call_count += 1
if call_count == 1:
return store_mock.list_inventory()
raise RuntimeError("No vision backend configured")
mock_thread.side_effect = _side
resp = client.post(
"/api/v1/recipes/scan",
files=[("files", ("photo.jpg", _fake_image(), "image/jpeg"))],
)
assert resp.status_code == 503
# ── POST /recipes/scan/save ────────────────────────────────────────────────────
class TestSaveEndpoint:
def test_save_returns_201(self, override_deps):
_, store_mock = override_deps
store_mock.create_user_recipe.return_value = {
"id": 42,
"title": "Green Goddess Bowls",
"subtitle": None,
"servings": "2",
"cook_time": "15 min",
"source_note": None,
"ingredients": [{"name": "brown rice", "qty": "1", "unit": "cup", "raw": None, "in_pantry": False}],
"steps": ["Cook it."],
"notes": None,
"tags": [],
"source": "scan",
"pantry_match_pct": None,
"created_at": "2026-04-27T00:00:00",
}
payload = {
"title": "Green Goddess Bowls",
"servings": "2",
"cook_time": "15 min",
"ingredients": [{"name": "brown rice", "qty": "1", "unit": "cup"}],
"steps": ["Cook it."],
"source": "scan",
}
resp = client.post("/api/v1/recipes/scan/save", json=payload)
assert resp.status_code == 201
data = resp.json()
assert data["id"] == 42
assert data["title"] == "Green Goddess Bowls"
def test_save_missing_title_rejected(self, override_deps):
payload = {
"ingredients": [{"name": "eggs", "qty": "2"}],
"steps": ["Scramble."],
}
resp = client.post("/api/v1/recipes/scan/save", json=payload)
assert resp.status_code == 422
# ── GET /recipes/user ──────────────────────────────────────────────────────────
class TestUserRecipeEndpoints:
def test_list_empty(self, override_deps):
_, store_mock = override_deps
store_mock.list_user_recipes.return_value = []
resp = client.get("/api/v1/recipes/user")
assert resp.status_code == 200
assert resp.json() == []
def test_get_not_found(self, override_deps):
_, store_mock = override_deps
store_mock.get_user_recipe.return_value = None
resp = client.get("/api/v1/recipes/user/999")
assert resp.status_code == 404
def test_delete_not_found(self, override_deps):
_, store_mock = override_deps
store_mock.delete_user_recipe.return_value = False
resp = client.delete("/api/v1/recipes/user/999")
assert resp.status_code == 404

View file

@ -0,0 +1,233 @@
"""Unit tests for the recipe scanner service.
VLM calls are mocked these tests cover JSON parsing, pantry cross-reference,
error handling, and result normalization. No GPU required.
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from app.services.recipe.recipe_scanner import (
RecipeScanner,
ScannedIngredient,
ScannedRecipeResult,
_cross_reference_pantry,
_parse_scanner_json,
_normalize_ingredient_name,
)
# ── Fixtures ──────────────────────────────────────────────────────────────────
GOOD_JSON = {
"title": "Green Goddess Bowls",
"subtitle": "with Broccoli & Ranch Dressing",
"servings": "2",
"cook_time": "15 min",
"source_note": "Purple Carrot",
"ingredients": [
{"name": "brown rice", "qty": "1/2", "unit": "cup", "raw": "1/2 cup brown rice"},
{"name": "broccoli florets", "qty": "8", "unit": "oz", "raw": "8 oz broccoli florets"},
{"name": "avocado", "qty": "1", "unit": None, "raw": "1 avocado"},
{"name": "ranch dressing", "qty": "2", "unit": "tbsp", "raw": "2 tbsp Follow Your Heart Ranch"},
{"name": "pumpkin seeds", "qty": "1", "unit": "tbsp", "raw": "1 tbsp pumpkin seeds"},
],
"steps": [
"Cook rice according to package directions.",
"Steam broccoli for 5 minutes until tender.",
"Slice avocado. Assemble bowls and top with ranch.",
],
"notes": "Great leftover — keeps 3 days in the fridge.",
"confidence": "high",
"warnings": [],
}
def _fake_image_path(tmp_path: Path, name: str = "recipe.jpg") -> Path:
"""Create a tiny placeholder file so path-existence checks pass."""
p = tmp_path / name
p.write_bytes(b"\xff\xd8\xff") # minimal JPEG magic bytes
return p
# ── _normalize_ingredient_name ─────────────────────────────────────────────────
class TestNormalizeIngredientName:
def test_lowercases(self):
assert _normalize_ingredient_name("Brown Rice") == "brown rice"
def test_strips_whitespace(self):
assert _normalize_ingredient_name(" avocado ") == "avocado"
def test_removes_plural_s(self):
# For matching purposes only — "pumpkin seeds" stays as-is (stop at spaces)
assert _normalize_ingredient_name("avocados") == "avocados"
def test_passthrough(self):
assert _normalize_ingredient_name("ranch dressing") == "ranch dressing"
# ── _parse_scanner_json ───────────────────────────────────────────────────────
class TestParseScannerJson:
def test_parses_good_json(self):
result = _parse_scanner_json(json.dumps(GOOD_JSON))
assert result["title"] == "Green Goddess Bowls"
assert len(result["ingredients"]) == 5
def test_strips_markdown_fences(self):
wrapped = f"```json\n{json.dumps(GOOD_JSON)}\n```"
result = _parse_scanner_json(wrapped)
assert result["title"] == "Green Goddess Bowls"
def test_not_a_recipe_error(self):
with pytest.raises(ValueError, match="not_a_recipe"):
_parse_scanner_json(json.dumps({"error": "not_a_recipe"}))
def test_missing_title_returns_none_title(self):
data = dict(GOOD_JSON)
data.pop("title")
result = _parse_scanner_json(json.dumps(data))
assert result.get("title") is None
def test_malformed_json_raises(self):
with pytest.raises(ValueError, match="parse"):
_parse_scanner_json("this is not json at all")
def test_json_inside_prose(self):
"""Model sometimes adds leading text before the JSON object."""
text = f"Here is the extracted recipe:\n{json.dumps(GOOD_JSON)}"
result = _parse_scanner_json(text)
assert result["title"] == "Green Goddess Bowls"
# ── _cross_reference_pantry ───────────────────────────────────────────────────
class TestCrossReferencePantry:
PANTRY = ["brown rice", "pumpkin seeds", "olive oil", "broccoli"]
def test_marks_exact_match(self):
ingr = [
ScannedIngredient(name="brown rice", qty="1/2", unit="cup"),
ScannedIngredient(name="avocado", qty="1", unit=None),
]
result, pct = _cross_reference_pantry(ingr, self.PANTRY)
assert result[0].in_pantry is True
assert result[1].in_pantry is False
assert pct == 50
def test_partial_word_match(self):
"""'broccoli florets' should match pantry item 'broccoli'."""
ingr = [ScannedIngredient(name="broccoli florets", qty="8", unit="oz")]
result, pct = _cross_reference_pantry(ingr, self.PANTRY)
assert result[0].in_pantry is True
assert pct == 100
def test_empty_pantry_all_false(self):
ingr = [ScannedIngredient(name="broccoli", qty="1", unit=None)]
result, pct = _cross_reference_pantry(ingr, [])
assert result[0].in_pantry is False
assert pct == 0
def test_empty_ingredients_zero_pct(self):
_, pct = _cross_reference_pantry([], self.PANTRY)
assert pct == 0
def test_case_insensitive_match(self):
ingr = [ScannedIngredient(name="Brown Rice", qty="1", unit="cup")]
result, pct = _cross_reference_pantry(ingr, self.PANTRY)
assert result[0].in_pantry is True
# ── RecipeScanner ─────────────────────────────────────────────────────────────
class TestRecipeScanner:
def _make_scanner(self) -> RecipeScanner:
return RecipeScanner()
@patch("app.services.recipe.recipe_scanner._call_vision_backend")
def test_scan_single_image_success(self, mock_call, tmp_path):
mock_call.return_value = json.dumps(GOOD_JSON)
img = _fake_image_path(tmp_path)
scanner = self._make_scanner()
result = scanner.scan([img], pantry_names=["brown rice", "avocado"])
assert isinstance(result, ScannedRecipeResult)
assert result.title == "Green Goddess Bowls"
assert result.servings == "2"
assert result.cook_time == "15 min"
assert len(result.ingredients) == 5
assert result.confidence == "high"
assert result.pantry_match_pct == 40 # 2 of 5 in pantry
@patch("app.services.recipe.recipe_scanner._call_vision_backend")
def test_scan_multi_image(self, mock_call, tmp_path):
"""Two photos treated as one recipe — both passed to VLM."""
mock_call.return_value = json.dumps(GOOD_JSON)
img1 = _fake_image_path(tmp_path, "p1.jpg")
img2 = _fake_image_path(tmp_path, "p2.jpg")
scanner = self._make_scanner()
result = scanner.scan([img1, img2])
# Both images passed through
call_args = mock_call.call_args
assert len(call_args[0][0]) == 2 # image_paths list has 2 items
assert result.title == "Green Goddess Bowls"
@patch("app.services.recipe.recipe_scanner._call_vision_backend")
def test_scan_not_a_recipe_raises(self, mock_call, tmp_path):
mock_call.return_value = json.dumps({"error": "not_a_recipe"})
img = _fake_image_path(tmp_path)
scanner = self._make_scanner()
with pytest.raises(ValueError, match="not_a_recipe"):
scanner.scan([img])
@patch("app.services.recipe.recipe_scanner._call_vision_backend")
def test_warnings_propagated(self, mock_call, tmp_path):
data = dict(GOOD_JSON)
data["warnings"] = ["Directions appear to continue on another page not shown"]
mock_call.return_value = json.dumps(data)
img = _fake_image_path(tmp_path)
scanner = self._make_scanner()
result = scanner.scan([img])
assert len(result.warnings) == 1
assert "another page" in result.warnings[0]
@patch("app.services.recipe.recipe_scanner._call_vision_backend")
def test_scan_no_pantry_names(self, mock_call, tmp_path):
mock_call.return_value = json.dumps(GOOD_JSON)
img = _fake_image_path(tmp_path)
scanner = self._make_scanner()
result = scanner.scan([img])
# No pantry passed — all in_pantry=False, pct=0
assert result.pantry_match_pct == 0
assert all(not i.in_pantry for i in result.ingredients)
def test_scan_too_many_images_raises(self, tmp_path):
imgs = [_fake_image_path(tmp_path, f"p{i}.jpg") for i in range(5)]
scanner = self._make_scanner()
with pytest.raises(ValueError, match="4 images"):
scanner.scan(imgs)
def test_scan_no_images_raises(self):
scanner = self._make_scanner()
with pytest.raises(ValueError, match="least one"):
scanner.scan([])
@patch("app.services.recipe.recipe_scanner._call_vision_backend")
def test_backend_unavailable_raises(self, mock_call, tmp_path):
mock_call.side_effect = RuntimeError("No vision backend configured")
img = _fake_image_path(tmp_path)
scanner = self._make_scanner()
with pytest.raises(RuntimeError, match="No vision backend"):
scanner.scan([img])