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:
parent
c9fcfde694
commit
896b4e048c
12 changed files with 2335 additions and 12 deletions
256
app/api/endpoints/recipe_scan.py
Normal file
256
app/api/endpoints/recipe_scan.py
Normal 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)
|
||||||
|
|
@ -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 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.community import router as community_router
|
||||||
from app.api.endpoints.corrections import router as corrections_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
|
from app.api.endpoints.recipe_tags import router as recipe_tags_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
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(export.router, tags=["export"])
|
||||||
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
|
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
|
||||||
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
|
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(recipes.router, prefix="/recipes", tags=["recipes"])
|
||||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||||
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
||||||
|
|
|
||||||
23
app/db/migrations/041_user_recipes.sql
Normal file
23
app/db/migrations/041_user_recipes.sql
Normal 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);
|
||||||
|
|
@ -61,6 +61,8 @@ class Store:
|
||||||
"style_tags",
|
"style_tags",
|
||||||
# meal plan columns
|
# meal plan columns
|
||||||
"meal_types",
|
"meal_types",
|
||||||
|
# user_recipes columns
|
||||||
|
"steps", "tags",
|
||||||
# captured_products columns
|
# captured_products columns
|
||||||
"allergens"):
|
"allergens"):
|
||||||
if key in d and isinstance(d[key], str):
|
if key in d and isinstance(d[key], str):
|
||||||
|
|
@ -1802,3 +1804,54 @@ class Store:
|
||||||
confidence, 1 if confirmed_by_user else 0, source,
|
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
|
||||||
|
|
|
||||||
74
app/models/schemas/recipe_scan.py
Normal file
74
app/models/schemas/recipe_scan.py
Normal 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
|
||||||
411
app/services/recipe/recipe_scanner.py
Normal file
411
app/services/recipe/recipe_scanner.py
Normal 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 []),
|
||||||
|
)
|
||||||
|
|
@ -15,6 +15,7 @@ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
|
||||||
"recipe_suggestions",
|
"recipe_suggestions",
|
||||||
"expiry_llm_matching",
|
"expiry_llm_matching",
|
||||||
"receipt_ocr",
|
"receipt_ocr",
|
||||||
|
"recipe_scan",
|
||||||
"style_classifier",
|
"style_classifier",
|
||||||
"meal_plan_llm",
|
"meal_plan_llm",
|
||||||
"meal_plan_llm_timing",
|
"meal_plan_llm_timing",
|
||||||
|
|
@ -58,6 +59,9 @@ KIWI_FEATURES: dict[str, str] = {
|
||||||
"community_publish": "paid", # Publish plans/outcomes to community feed
|
"community_publish": "paid", # Publish plans/outcomes to community feed
|
||||||
"community_fork_adapt": "paid", # Fork with LLM pantry adaptation (BYOK-unlockable)
|
"community_fork_adapt": "paid", # Fork with LLM pantry adaptation (BYOK-unlockable)
|
||||||
|
|
||||||
|
# Paid tier (continued)
|
||||||
|
"recipe_scan": "paid", # BYOK-unlockable: photo -> structured recipe
|
||||||
|
|
||||||
# Premium tier
|
# Premium tier
|
||||||
"multi_household": "premium",
|
"multi_household": "premium",
|
||||||
"background_monitoring": "premium",
|
"background_monitoring": "premium",
|
||||||
|
|
|
||||||
844
frontend/src/components/RecipeScanModal.vue
Normal file
844
frontend/src/components/RecipeScanModal.vue
Normal 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>
|
||||||
|
|
@ -1,21 +1,53 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="recipes-view">
|
<div class="recipes-view">
|
||||||
|
|
||||||
<!-- Tab bar: Find / Browse / Saved -->
|
<!-- Tab bar: Find / Browse / Saved + Scan action button -->
|
||||||
<div role="tablist" aria-label="Recipe sections" class="tab-bar flex gap-xs mb-md">
|
<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"
|
||||||
|
:id="`tab-${tab.id}`"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activeTab === tab.id"
|
||||||
|
:tabindex="activeTab === tab.id ? 0 : -1"
|
||||||
|
:class="['btn', 'tab-btn', activeTab === tab.id ? 'btn-primary' : 'btn-secondary']"
|
||||||
|
@click="activateTab(tab.id)"
|
||||||
|
@keydown="onTabKeydown"
|
||||||
|
>{{ tab.label }}</button>
|
||||||
|
</div>
|
||||||
|
<!-- Scan recipe button — opens modal to photograph a recipe card -->
|
||||||
<button
|
<button
|
||||||
v-for="tab in tabs"
|
class="btn btn-secondary scan-btn"
|
||||||
:key="tab.id"
|
@click="scanModalOpen = true"
|
||||||
:id="`tab-${tab.id}`"
|
title="Scan a recipe card or cookbook page"
|
||||||
role="tab"
|
aria-label="Scan a recipe"
|
||||||
:aria-selected="activeTab === tab.id"
|
>
|
||||||
:tabindex="activeTab === tab.id ? 0 : -1"
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
:class="['btn', 'tab-btn', activeTab === tab.id ? 'btn-primary' : 'btn-secondary']"
|
<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"/>
|
||||||
@click="activateTab(tab.id)"
|
<circle cx="12" cy="13" r="4"/>
|
||||||
@keydown="onTabKeydown"
|
</svg>
|
||||||
>{{ tab.label }}</button>
|
Scan
|
||||||
|
</button>
|
||||||
</div>
|
</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 -->
|
<!-- Browse tab -->
|
||||||
<RecipeBrowserPanel
|
<RecipeBrowserPanel
|
||||||
v-if="activeTab === 'browse'"
|
v-if="activeTab === 'browse'"
|
||||||
|
|
@ -746,10 +778,22 @@ import SavedRecipesPanel from './SavedRecipesPanel.vue'
|
||||||
import CommunityFeedPanel from './CommunityFeedPanel.vue'
|
import CommunityFeedPanel from './CommunityFeedPanel.vue'
|
||||||
import BuildYourOwnTab from './BuildYourOwnTab.vue'
|
import BuildYourOwnTab from './BuildYourOwnTab.vue'
|
||||||
import OrchUsagePill from './OrchUsagePill.vue'
|
import OrchUsagePill from './OrchUsagePill.vue'
|
||||||
|
import RecipeScanModal from './RecipeScanModal.vue'
|
||||||
import type { ForkResult } from '../stores/community'
|
import type { ForkResult } from '../stores/community'
|
||||||
import type { RecipeSuggestion, GroceryLink, StreamTokenResponse } from '../services/api'
|
import type { RecipeSuggestion, GroceryLink, StreamTokenResponse } from '../services/api'
|
||||||
import { recipesAPI } 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
|
// Streaming state
|
||||||
const isStreaming = ref(false)
|
const isStreaming = ref(false)
|
||||||
const streamChunks = ref('')
|
const streamChunks = ref('')
|
||||||
|
|
|
||||||
|
|
@ -1204,4 +1204,77 @@ export const DEFAULT_SENSORY_PREFERENCES: SensoryPreferences = {
|
||||||
max_noise: null,
|
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
|
export default api
|
||||||
|
|
|
||||||
304
tests/api/test_recipe_scan.py
Normal file
304
tests/api/test_recipe_scan.py
Normal 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
|
||||||
233
tests/services/recipe/test_recipe_scanner.py
Normal file
233
tests/services/recipe/test_recipe_scanner.py
Normal 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])
|
||||||
Loading…
Reference in a new issue