Compare commits
No commits in common. "d5a4b144005220f9da107eba9a43345f7d044e99" and "c9fcfde6949ad7fe4a423338399e341cfeab3212" have entirely different histories.
d5a4b14400
...
c9fcfde694
16 changed files with 56 additions and 2638 deletions
|
|
@ -1,256 +0,0 @@
|
|||
"""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,7 +2,6 @@ from fastapi import APIRouter
|
|||
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, feedback_attach, household, saved_recipes, imitate, meal_plans, orch_usage, session, shopping
|
||||
from app.api.endpoints.community import router as community_router
|
||||
from app.api.endpoints.corrections import router as corrections_router
|
||||
from app.api.endpoints.recipe_scan import router as recipe_scan_router
|
||||
from app.api.endpoints.recipe_tags import router as recipe_tags_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
|
@ -14,9 +13,6 @@ api_router.include_router(ocr.router, prefix="/receipts", tags=
|
|||
api_router.include_router(export.router, tags=["export"])
|
||||
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
|
||||
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
|
||||
# recipe_scan_router registered BEFORE recipes.router so /recipes/scan and /recipes/user
|
||||
# take priority over /recipes/{recipe_id} (which would otherwise match them as int IDs).
|
||||
api_router.include_router(recipe_scan_router, prefix="/recipes", tags=["recipe-scan"])
|
||||
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
|
||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
-- 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,8 +61,6 @@ class Store:
|
|||
"style_tags",
|
||||
# meal plan columns
|
||||
"meal_types",
|
||||
# user_recipes columns
|
||||
"steps", "tags",
|
||||
# captured_products columns
|
||||
"allergens"):
|
||||
if key in d and isinstance(d[key], str):
|
||||
|
|
@ -1804,54 +1802,3 @@ class Store:
|
|||
confidence, 1 if confirmed_by_user else 0, source,
|
||||
),
|
||||
)
|
||||
|
||||
# ── User Recipes (kiwi#9) ──────────────────────────────────────────────────
|
||||
|
||||
def create_user_recipe(
|
||||
self,
|
||||
title: str,
|
||||
ingredients: list[dict],
|
||||
steps: list[str],
|
||||
subtitle: str | None = None,
|
||||
servings: str | None = None,
|
||||
cook_time: str | None = None,
|
||||
source_note: str | None = None,
|
||||
notes: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
source: str = "manual",
|
||||
pantry_match_pct: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._insert_returning(
|
||||
"""INSERT INTO user_recipes
|
||||
(title, subtitle, servings, cook_time, source_note,
|
||||
ingredients, steps, notes, tags, source, pantry_match_pct)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING *""",
|
||||
(
|
||||
title, subtitle, servings, cook_time, source_note,
|
||||
self._dump(ingredients),
|
||||
self._dump(steps),
|
||||
notes,
|
||||
self._dump(tags or []),
|
||||
source,
|
||||
pantry_match_pct,
|
||||
),
|
||||
)
|
||||
|
||||
def get_user_recipe(self, recipe_id: int) -> dict[str, Any] | None:
|
||||
return self._fetch_one(
|
||||
"SELECT * FROM user_recipes WHERE id = ?",
|
||||
(recipe_id,),
|
||||
)
|
||||
|
||||
def list_user_recipes(self) -> list[dict[str, Any]]:
|
||||
return self._fetch_all(
|
||||
"SELECT * FROM user_recipes ORDER BY created_at DESC",
|
||||
)
|
||||
|
||||
def delete_user_recipe(self, recipe_id: int) -> bool:
|
||||
cur = self.conn.execute(
|
||||
"DELETE FROM user_recipes WHERE id = ?", (recipe_id,)
|
||||
)
|
||||
self.conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -26,7 +26,7 @@ DOMAINS: dict[str, dict] = {
|
|||
"label": "Cuisine",
|
||||
"categories": {
|
||||
"Italian": {
|
||||
"keywords": ["cuisine:Italian", "italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
|
||||
"keywords": ["italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
|
||||
"subcategories": {
|
||||
"Sicilian": ["sicilian", "sicily", "arancini", "caponata",
|
||||
"involtini", "cannoli"],
|
||||
|
|
@ -43,8 +43,8 @@ DOMAINS: dict[str, dict] = {
|
|||
},
|
||||
},
|
||||
"Mexican": {
|
||||
"keywords": ["cuisine:Mexican", "mexican", "taco", "enchilada", "burrito",
|
||||
"salsa", "guacamole", "mole", "tamale"],
|
||||
"keywords": ["mexican", "taco", "enchilada", "burrito", "salsa",
|
||||
"guacamole", "mole", "tamale"],
|
||||
"subcategories": {
|
||||
"Oaxacan": ["oaxacan", "oaxaca", "mole negro", "tlayuda",
|
||||
"chapulines", "mezcal", "tasajo", "memelas"],
|
||||
|
|
@ -67,9 +67,7 @@ DOMAINS: dict[str, dict] = {
|
|||
},
|
||||
},
|
||||
"Asian": {
|
||||
"keywords": ["cuisine:Chinese", "cuisine:Japanese", "cuisine:Korean",
|
||||
"cuisine:Thai", "cuisine:Vietnamese",
|
||||
"asian", "chinese", "japanese", "thai", "korean", "vietnamese",
|
||||
"keywords": ["asian", "chinese", "japanese", "thai", "korean", "vietnamese",
|
||||
"stir fry", "stir-fry", "ramen", "sushi", "malaysian",
|
||||
"taiwanese", "singaporean", "burmese", "cambodian",
|
||||
"laotian", "mongolian", "hong kong"],
|
||||
|
|
@ -130,7 +128,7 @@ DOMAINS: dict[str, dict] = {
|
|||
},
|
||||
},
|
||||
"Indian": {
|
||||
"keywords": ["cuisine:Indian", "indian", "curry", "lentil", "dal", "tikka", "masala",
|
||||
"keywords": ["indian", "curry", "lentil", "dal", "tikka", "masala",
|
||||
"biryani", "naan", "chutney", "pakistani", "sri lankan",
|
||||
"bangladeshi", "nepali"],
|
||||
"subcategories": {
|
||||
|
|
@ -158,8 +156,7 @@ DOMAINS: dict[str, dict] = {
|
|||
},
|
||||
},
|
||||
"Mediterranean": {
|
||||
"keywords": ["cuisine:Mediterranean", "cuisine:Greek", "cuisine:Middle Eastern",
|
||||
"mediterranean", "greek", "middle eastern", "turkish",
|
||||
"keywords": ["mediterranean", "greek", "middle eastern", "turkish",
|
||||
"lebanese", "jewish", "palestinian", "yemeni", "egyptian",
|
||||
"syrian", "iraqi", "jordanian"],
|
||||
"subcategories": {
|
||||
|
|
@ -193,8 +190,7 @@ DOMAINS: dict[str, dict] = {
|
|||
},
|
||||
},
|
||||
"American": {
|
||||
"keywords": ["cuisine:American", "cuisine:Southern", "cuisine:Cajun",
|
||||
"american", "southern", "comfort food", "cajun", "creole",
|
||||
"keywords": ["american", "southern", "comfort food", "cajun", "creole",
|
||||
"hawaiian", "tex-mex", "soul food"],
|
||||
"subcategories": {
|
||||
"Southern": ["southern", "soul food", "fried chicken",
|
||||
|
|
@ -218,8 +214,10 @@ DOMAINS: dict[str, dict] = {
|
|||
},
|
||||
},
|
||||
"BBQ & Smoke": {
|
||||
# Top-level keywords: cuisine:BBQ inferred tag + broad corpus terms.
|
||||
"keywords": ["cuisine:BBQ", "bbq", "barbecue", "barbeque", "smoked", "smoky",
|
||||
# Top-level keywords use broad corpus-friendly terms that appear in
|
||||
# food.com keyword/category fields (e.g. "BBQ", "Oven BBQ", "Smoker").
|
||||
# Subcategory keywords remain specific for drill-down filtering.
|
||||
"keywords": ["bbq", "barbecue", "barbeque", "smoked", "smoky",
|
||||
"smoke", "pit", "smoke ring", "low and slow",
|
||||
"brisket", "pulled pork", "ribs", "spare ribs",
|
||||
"baby back", "baby back ribs", "dry rub", "wet rub",
|
||||
|
|
@ -253,8 +251,7 @@ DOMAINS: dict[str, dict] = {
|
|||
},
|
||||
},
|
||||
"European": {
|
||||
"keywords": ["cuisine:French", "cuisine:German", "cuisine:Spanish",
|
||||
"french", "german", "spanish", "british", "irish", "scottish",
|
||||
"keywords": ["french", "german", "spanish", "british", "irish", "scottish",
|
||||
"welsh", "scandinavian", "nordic", "eastern european"],
|
||||
"subcategories": {
|
||||
"French": ["french", "provencal", "beurre", "crepe",
|
||||
|
|
@ -284,8 +281,7 @@ DOMAINS: dict[str, dict] = {
|
|||
},
|
||||
},
|
||||
"Latin American": {
|
||||
"keywords": ["cuisine:Latin American", "cuisine:Caribbean",
|
||||
"latin american", "peruvian", "argentinian", "colombian",
|
||||
"keywords": ["latin american", "peruvian", "argentinian", "colombian",
|
||||
"cuban", "caribbean", "brazilian", "venezuelan", "chilean"],
|
||||
"subcategories": {
|
||||
"Peruvian": ["peruvian", "ceviche", "lomo saltado", "anticucho",
|
||||
|
|
@ -429,18 +425,12 @@ DOMAINS: dict[str, dict] = {
|
|||
"meal_type": {
|
||||
"label": "Meal Type",
|
||||
"categories": {
|
||||
# Keywords use two complementary sources:
|
||||
# 1. inferred_tag phrases ("meal:X", "main:X") — indexed in recipe_browser_fts.inferred_tags.
|
||||
# FTS5 tokenises "meal:Breakfast" → ["meal","breakfast"], so the quoted phrase
|
||||
# "meal:Breakfast" matches exactly that consecutive token pair.
|
||||
# 2. Corpus keyword/category text — only covers the ~1,200 keyword-tagged recipes.
|
||||
# Kept as a fallback; not the primary signal.
|
||||
"Breakfast": {
|
||||
"keywords": ["meal:Breakfast", "breakfast", "brunch", "pancakes",
|
||||
"waffles", "oatmeal", "muffin"],
|
||||
"keywords": ["breakfast", "brunch", "eggs", "pancakes", "waffles",
|
||||
"oatmeal", "muffin"],
|
||||
"subcategories": {
|
||||
"Eggs": ["meal:Breakfast", "egg", "omelette", "frittata",
|
||||
"quiche", "scrambled", "benedict", "shakshuka"],
|
||||
"Eggs": ["egg", "omelette", "frittata", "quiche",
|
||||
"scrambled", "benedict", "shakshuka"],
|
||||
"Pancakes & Waffles": ["pancake", "waffle", "crepe", "french toast"],
|
||||
"Baked Goods": ["muffin", "scone", "biscuit", "quick bread",
|
||||
"coffee cake", "danish"],
|
||||
|
|
@ -449,15 +439,12 @@ DOMAINS: dict[str, dict] = {
|
|||
},
|
||||
},
|
||||
"Lunch": {
|
||||
# meal:Lunch tag covers explicitly-tagged recipes.
|
||||
# Coverage is limited — most lunch-style recipes have no distinct meal-type tag.
|
||||
"keywords": ["meal:Lunch", "lunch", "sandwich", "wrap", "salad",
|
||||
"soup", "light meal"],
|
||||
"keywords": ["lunch", "sandwich", "wrap", "salad", "soup", "light meal"],
|
||||
"subcategories": {
|
||||
"Sandwiches": ["sandwich", "sub", "hoagie", "panini", "club",
|
||||
"grilled cheese", "blt"],
|
||||
"Salads": ["salad", "grain bowl", "chopped", "caesar",
|
||||
"cobb"],
|
||||
"niçoise", "cobb"],
|
||||
"Soups": ["soup", "bisque", "chowder", "gazpacho",
|
||||
"minestrone", "lentil soup"],
|
||||
"Wraps": ["wrap", "burrito bowl", "pita", "lettuce wrap",
|
||||
|
|
@ -465,27 +452,23 @@ DOMAINS: dict[str, dict] = {
|
|||
},
|
||||
},
|
||||
"Dinner": {
|
||||
# Primary: main:X inferred tags (800k+ recipes).
|
||||
# "meal:Dinner" does not exist in the inferred-tag vocabulary — main-protein
|
||||
# tags are the best available proxy for main-course dinner recipes.
|
||||
"keywords": ["main:Chicken", "main:Beef", "main:Pork", "main:Fish",
|
||||
"main:Pasta", "dinner", "main dish", "entree",
|
||||
"main course", "supper"],
|
||||
"keywords": ["dinner", "main dish", "entree", "main course", "supper"],
|
||||
"subcategories": {
|
||||
"Chicken": ["main:Chicken"],
|
||||
"Beef": ["main:Beef"],
|
||||
"Pork": ["main:Pork"],
|
||||
"Fish & Seafood": ["main:Fish"],
|
||||
"Pasta": ["main:Pasta"],
|
||||
"Casseroles": ["casserole", "bake", "gratin", "pot pie"],
|
||||
"Casseroles": ["casserole", "bake", "gratin", "lasagna",
|
||||
"sheperd's pie", "pot pie"],
|
||||
"Stews": ["stew", "braise", "slow cooker", "pot roast",
|
||||
"daube"],
|
||||
"Grilled": ["grilled", "grill", "barbecue", "kebab", "skewer"],
|
||||
"daube", "ragù"],
|
||||
"Grilled": ["grilled", "grill", "barbecue", "charred",
|
||||
"kebab", "skewer"],
|
||||
"Stir-Fries": ["stir fry", "stir-fry", "wok", "sauté",
|
||||
"sauteed"],
|
||||
"Roasts": ["roast", "roasted", "oven", "baked chicken",
|
||||
"pot roast"],
|
||||
},
|
||||
},
|
||||
"Snack": {
|
||||
"keywords": ["meal:Snack", "snack", "appetizer", "finger food",
|
||||
"dip", "bite", "starter"],
|
||||
"keywords": ["snack", "appetizer", "finger food", "dip", "bite",
|
||||
"starter"],
|
||||
"subcategories": {
|
||||
"Dips & Spreads": ["dip", "spread", "hummus", "guacamole",
|
||||
"salsa", "pate"],
|
||||
|
|
@ -496,9 +479,8 @@ DOMAINS: dict[str, dict] = {
|
|||
},
|
||||
},
|
||||
"Dessert": {
|
||||
# "sweet" removed — it matches flavor:Sweet inferred tags, causing false positives.
|
||||
"keywords": ["meal:Dessert", "dessert", "cake", "cookie", "pie",
|
||||
"pudding", "ice cream", "brownie"],
|
||||
"keywords": ["dessert", "cake", "cookie", "pie", "sweet", "pudding",
|
||||
"ice cream", "brownie"],
|
||||
"subcategories": {
|
||||
"Cakes": ["cake", "cupcake", "layer cake", "bundt",
|
||||
"cheesecake", "torte"],
|
||||
|
|
@ -514,41 +496,20 @@ DOMAINS: dict[str, dict] = {
|
|||
"caramel", "toffee"],
|
||||
},
|
||||
},
|
||||
"Beverage": ["meal:Beverage", "drink", "smoothie", "cocktail", "beverage",
|
||||
"juice", "shake", "lemonade"],
|
||||
"Side Dish": {
|
||||
# meal:Side Dish not in inferred-tag vocabulary.
|
||||
# main:Vegetables and main:Grains are the best proxies — will overlap
|
||||
# with some vegetarian mains, which is acceptable.
|
||||
"keywords": ["main:Vegetables", "main:Grains", "side dish", "side",
|
||||
"pilaf", "accompaniment"],
|
||||
"subcategories": {
|
||||
"Vegetables": ["main:Vegetables"],
|
||||
"Grains & Rice": ["main:Grains", "rice", "pilaf", "quinoa"],
|
||||
"Bread": ["meal:Bread", "bread", "roll", "biscuit"],
|
||||
},
|
||||
},
|
||||
"Beverage": ["drink", "smoothie", "cocktail", "beverage", "juice", "shake"],
|
||||
"Side Dish": ["side dish", "side", "accompaniment", "garnish"],
|
||||
},
|
||||
},
|
||||
"dietary": {
|
||||
"label": "Dietary",
|
||||
# Primary: dietary:X inferred tags (indexed in recipe_browser_fts.inferred_tags).
|
||||
# Secondary: text tokens kept as fallback for keyword-tagged recipes.
|
||||
# IMPORTANT: Use ONLY structured dietary:X phrases here.
|
||||
# Bare text keywords like "vegan", "low-carb" also match can_be:Vegan,
|
||||
# can_be:Low-Carb etc. — those are "achievable with substitutions", not
|
||||
# "recipe already is". The structured phrase "dietary:Vegan" (consecutive
|
||||
# FTS tokens "dietary"+"vegan") does NOT match can_be:Vegan.
|
||||
"categories": {
|
||||
"Vegetarian": ["dietary:Vegetarian"],
|
||||
"Vegan": ["dietary:Vegan"],
|
||||
"Gluten-Free": ["dietary:Gluten-Free"],
|
||||
"Low-Carb": ["dietary:Low-Carb"],
|
||||
"High-Protein": ["dietary:High-Protein"],
|
||||
"Low-Fat": ["dietary:Low-Fat"],
|
||||
"Dairy-Free": ["dietary:Dairy-Free"],
|
||||
"Low-Sodium": ["dietary:Low-Sodium"],
|
||||
"Paleo": ["dietary:Paleo"],
|
||||
"Vegetarian": ["vegetarian"],
|
||||
"Vegan": ["vegan", "plant-based", "plant based"],
|
||||
"Gluten-Free": ["gluten-free", "gluten free", "celiac"],
|
||||
"Low-Carb": ["low-carb", "low carb", "keto", "ketogenic"],
|
||||
"High-Protein": ["high protein", "high-protein"],
|
||||
"Low-Fat": ["low-fat", "low fat", "light"],
|
||||
"Dairy-Free": ["dairy-free", "dairy free", "lactose"],
|
||||
},
|
||||
},
|
||||
"main_ingredient": {
|
||||
|
|
|
|||
|
|
@ -1,411 +0,0 @@
|
|||
"""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 []),
|
||||
)
|
||||
|
|
@ -22,8 +22,6 @@ queries find recipes the food.com corpus tags alone would miss.
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text-signal tables
|
||||
|
|
@ -123,50 +121,6 @@ _TIME_SIGNALS: list[tuple[str, list[str]]] = [
|
|||
("time:Slow Cook", ["slow cooker", "crockpot", "< 4 hours", "braise"]),
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Meal type signals — matched against TITLE ONLY (not ingredient text).
|
||||
# Ingredient names frequently contain words like "cake flour" or "sandwich
|
||||
# bread" which would produce false meal-type tags if matched against the full
|
||||
# title+ingredient string.
|
||||
# ---------------------------------------------------------------------------
|
||||
_MEAL_SIGNALS: list[tuple[str, list[str]]] = [
|
||||
("meal:Breakfast", [
|
||||
"breakfast", "pancake", "waffle", "french toast", "scrambled egg",
|
||||
"frittata", "hash brown", "hash browns", "breakfast burrito",
|
||||
"breakfast sandwich", "breakfast casserole", "overnight oat",
|
||||
"granola", "oatmeal", "muffin", "morning glory", "eggs benedict",
|
||||
"shakshuka", "crepe", "scone",
|
||||
]),
|
||||
("meal:Dessert", [
|
||||
"dessert", "cake", "cookie", "brownie", "cheesecake", "pudding",
|
||||
"fudge", "ice cream", "sorbet", "cupcake", "mousse", "candy",
|
||||
"truffle", "gelato", "donut", "doughnut", "cobbler", "crisp",
|
||||
"crumble", "tiramisu", "eclair", "sundae", "milkshake", "parfait",
|
||||
"biscotti", "macaron", "panna cotta", "baklava", "churro", "tart",
|
||||
"torte", "strudel", "compote", "semifreddo",
|
||||
]),
|
||||
("meal:Snack", [
|
||||
"snack", "appetizer", "dip", "chips", "popcorn", "trail mix",
|
||||
"energy ball", "deviled egg", "cheese ball", "nachos",
|
||||
"pretzel bites", "protein ball", "granola bar",
|
||||
]),
|
||||
("meal:Beverage", [
|
||||
"smoothie", "cocktail", "mocktail", "lemonade", "limeade",
|
||||
"margarita", "sangria", "punch", "milkshake", "milk shake",
|
||||
"juice", "spritzer", "iced tea", "hot chocolate", "chai latte",
|
||||
"mulled wine", "eggnog", "slushie", "frappe", "horchata",
|
||||
"agua fresca", "shrub", "switchel",
|
||||
]),
|
||||
("meal:Lunch", [
|
||||
"lunch", "sandwich", "panini", "grilled cheese", "wrap",
|
||||
"lunchbox", "lunch box",
|
||||
]),
|
||||
("meal:Bread", [
|
||||
"bread", "sourdough", "focaccia", "flatbread", "dinner roll",
|
||||
"loaf", "baguette", "ciabatta", "brioche", "challah", "pita",
|
||||
]),
|
||||
]
|
||||
|
||||
_MAIN_INGREDIENT_SIGNALS: list[tuple[str, list[str]]] = [
|
||||
("main:Chicken", ["chicken", "poultry", "turkey"]),
|
||||
("main:Beef", ["beef", "ground beef", "steak", "brisket", "pot roast"]),
|
||||
|
|
@ -242,29 +196,6 @@ def _match_signals(text: str, table: list[tuple[str, list[str]]]) -> list[str]:
|
|||
return [tag for tag, pats in table if any(p in text for p in pats)]
|
||||
|
||||
|
||||
def _match_title_signals(title: str, table: list[tuple[str, list[str]]]) -> list[str]:
|
||||
"""Match signals against title text only, using word-boundary + optional plural.
|
||||
|
||||
Pattern: `\\bWORD(?:s|es)?\\b`
|
||||
|
||||
This handles:
|
||||
- Plurals: "cookie" matches "cookies", "sandwich" matches "sandwiches"
|
||||
- Substring rejection: "cake" does NOT match "pancake" (no word boundary
|
||||
before 'c' in pan|cake), "tart" does NOT match "tartare" (after "tart"
|
||||
the 'a' is a word char, not a boundary)
|
||||
- Avoids false positives from ingredient text ("cake flour", "sandwich bread")
|
||||
by only matching the recipe title, not the full title+ingredient string.
|
||||
"""
|
||||
t = title.lower()
|
||||
return [
|
||||
tag for tag, pats in table
|
||||
if any(
|
||||
re.search(r"\b" + re.escape(p.strip()) + r"(?:s|es)?\b", t)
|
||||
for p in pats
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def infer_tags(
|
||||
title: str,
|
||||
ingredient_names: list[str],
|
||||
|
|
@ -327,9 +258,6 @@ def infer_tags(
|
|||
tags.update(_match_signals(text, _FLAVOR_SIGNALS))
|
||||
tags.update(_match_signals(text, _MAIN_INGREDIENT_SIGNALS))
|
||||
|
||||
# Meal type: title-only to avoid "cake flour" → meal:Dessert false positives
|
||||
tags.update(_match_title_signals(title, _MEAL_SIGNALS))
|
||||
|
||||
# 3. Time signals from corpus keywords + text
|
||||
corpus_text = " ".join(kw.lower() for kw in corpus_keywords)
|
||||
tags.update(_match_signals(corpus_text, _TIME_SIGNALS))
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
|
|||
"recipe_suggestions",
|
||||
"expiry_llm_matching",
|
||||
"receipt_ocr",
|
||||
"recipe_scan",
|
||||
"style_classifier",
|
||||
"meal_plan_llm",
|
||||
"meal_plan_llm_timing",
|
||||
|
|
@ -59,9 +58,6 @@ KIWI_FEATURES: dict[str, str] = {
|
|||
"community_publish": "paid", # Publish plans/outcomes to community feed
|
||||
"community_fork_adapt": "paid", # Fork with LLM pantry adaptation (BYOK-unlockable)
|
||||
|
||||
# Paid tier (continued)
|
||||
"recipe_scan": "paid", # BYOK-unlockable: photo -> structured recipe
|
||||
|
||||
# Premium tier
|
||||
"multi_household": "premium",
|
||||
"background_monitoring": "premium",
|
||||
|
|
|
|||
|
|
@ -1,844 +0,0 @@
|
|||
<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,58 +1,21 @@
|
|||
<template>
|
||||
<div class="recipes-view">
|
||||
|
||||
<!-- Tab bar: Find / Browse / Saved + Scan action button -->
|
||||
<div class="tab-bar-row flex gap-xs mb-md" style="align-items:center;">
|
||||
<div role="tablist" aria-label="Recipe sections" class="tab-bar-scroll">
|
||||
<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"
|
||||
>
|
||||
<span v-if="tab.mobileLabel" class="desktop-only">{{ tab.label }}</span>
|
||||
<span v-if="tab.mobileLabel" class="mobile-only">{{ tab.mobileLabel }}</span>
|
||||
<template v-if="!tab.mobileLabel">{{ tab.label }}</template>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Scan recipe button — opens modal to photograph a recipe card -->
|
||||
<!-- Tab bar: Find / Browse / Saved -->
|
||||
<div role="tablist" aria-label="Recipe sections" class="tab-bar flex gap-xs mb-md">
|
||||
<button
|
||||
class="btn btn-secondary scan-btn"
|
||||
style="flex-shrink:0;"
|
||||
@click="scanModalOpen = true"
|
||||
title="Scan a recipe card or cookbook page"
|
||||
aria-label="Scan a recipe"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
|
||||
<circle cx="12" cy="13" r="4"/>
|
||||
</svg>
|
||||
<span class="desktop-only">Scan</span>
|
||||
</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 success toast -->
|
||||
<div
|
||||
v-if="lastScannedTitle"
|
||||
class="undo-toast"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
Saved "{{ lastScannedTitle }}" to your recipes.
|
||||
</div>
|
||||
|
||||
<!-- Scan modal -->
|
||||
<RecipeScanModal
|
||||
v-if="scanModalOpen"
|
||||
@close="scanModalOpen = false"
|
||||
@saved="onScanSaved"
|
||||
/>
|
||||
|
||||
<!-- Browse tab -->
|
||||
<RecipeBrowserPanel
|
||||
v-if="activeTab === 'browse'"
|
||||
|
|
@ -783,22 +746,10 @@ import SavedRecipesPanel from './SavedRecipesPanel.vue'
|
|||
import CommunityFeedPanel from './CommunityFeedPanel.vue'
|
||||
import BuildYourOwnTab from './BuildYourOwnTab.vue'
|
||||
import OrchUsagePill from './OrchUsagePill.vue'
|
||||
import RecipeScanModal from './RecipeScanModal.vue'
|
||||
import type { ForkResult } from '../stores/community'
|
||||
import type { RecipeSuggestion, GroceryLink, StreamTokenResponse } from '../services/api'
|
||||
import { recipesAPI } from '../services/api'
|
||||
|
||||
// ── Scan modal ────────────────────────────────────────────────────────────────
|
||||
const scanModalOpen = ref(false)
|
||||
const lastScannedTitle = ref<string | null>(null)
|
||||
|
||||
function onScanSaved(recipe: { id: number; title: string }) {
|
||||
lastScannedTitle.value = recipe.title
|
||||
scanModalOpen.value = false
|
||||
// Dismiss the toast after 4 seconds
|
||||
setTimeout(() => { lastScannedTitle.value = null }, 4000)
|
||||
}
|
||||
|
||||
// Streaming state
|
||||
const isStreaming = ref(false)
|
||||
const streamChunks = ref('')
|
||||
|
|
@ -810,9 +761,9 @@ const settingsStore = useSettingsStore()
|
|||
|
||||
// Tab state
|
||||
type TabId = 'find' | 'browse' | 'saved' | 'community' | 'build'
|
||||
const tabs: Array<{ id: TabId; label: string; mobileLabel?: string }> = [
|
||||
const tabs: Array<{ id: TabId; label: string }> = [
|
||||
{ id: 'saved', label: 'Saved' },
|
||||
{ id: 'build', label: 'Build Your Own', mobileLabel: 'Build' },
|
||||
{ id: 'build', label: 'Build Your Own' },
|
||||
{ id: 'community', label: 'Community' },
|
||||
{ id: 'find', label: 'Find' },
|
||||
{ id: 'browse', label: 'Browse' },
|
||||
|
|
@ -1294,14 +1245,6 @@ watch(
|
|||
padding-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Prevent theme's mobile .btn white-space:normal from wrapping tab labels */
|
||||
@media (max-width: 480px) {
|
||||
.tab-btn {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
border-bottom: none;
|
||||
|
|
|
|||
|
|
@ -1204,77 +1204,4 @@ export const DEFAULT_SENSORY_PREFERENCES: SensoryPreferences = {
|
|||
max_noise: null,
|
||||
}
|
||||
|
||||
// ── Recipe Scanner (kiwi#9) ───────────────────────────────────────────────────
|
||||
|
||||
export interface ScannedIngredient {
|
||||
name: string
|
||||
qty: string | null
|
||||
unit: string | null
|
||||
raw: string | null
|
||||
in_pantry: boolean
|
||||
}
|
||||
|
||||
export interface ScannedRecipe {
|
||||
title: string | null
|
||||
subtitle: string | null
|
||||
servings: string | null
|
||||
cook_time: string | null
|
||||
source_note: string | null
|
||||
ingredients: ScannedIngredient[]
|
||||
steps: string[]
|
||||
notes: string | null
|
||||
tags: string[]
|
||||
pantry_match_pct: number
|
||||
confidence: 'high' | 'medium' | 'low'
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface UserRecipe {
|
||||
id: number
|
||||
title: string
|
||||
subtitle: string | null
|
||||
servings: string | null
|
||||
cook_time: string | null
|
||||
source_note: string | null
|
||||
ingredients: ScannedIngredient[]
|
||||
steps: string[]
|
||||
notes: string | null
|
||||
tags: string[]
|
||||
source: string
|
||||
pantry_match_pct: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export const recipeScanAPI = {
|
||||
/** Scan 1-4 recipe photos. Returns structured recipe for review (not saved). */
|
||||
scan(files: File[]): Promise<ScannedRecipe> {
|
||||
const form = new FormData()
|
||||
files.forEach((f) => form.append('files', f))
|
||||
return api.post('/recipes/scan', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 120_000, // VLM can be slow on first call
|
||||
}).then((r) => r.data)
|
||||
},
|
||||
|
||||
/** Save a reviewed/edited scanned recipe to user_recipes. */
|
||||
saveScanned(recipe: Omit<ScannedRecipe, 'pantry_match_pct' | 'confidence' | 'warnings'> & { source?: string }): Promise<UserRecipe> {
|
||||
return api.post('/recipes/scan/save', recipe).then((r) => r.data)
|
||||
},
|
||||
|
||||
/** List all user-created recipes (scan + manual). */
|
||||
listUserRecipes(): Promise<UserRecipe[]> {
|
||||
return api.get('/recipes/user').then((r) => r.data)
|
||||
},
|
||||
|
||||
/** Get a single user recipe by ID. */
|
||||
getUserRecipe(id: number): Promise<UserRecipe> {
|
||||
return api.get(`/recipes/user/${id}`).then((r) => r.data)
|
||||
},
|
||||
|
||||
/** Delete a user recipe. */
|
||||
deleteUserRecipe(id: number): Promise<void> {
|
||||
return api.delete(`/recipes/user/${id}`).then(() => undefined)
|
||||
},
|
||||
}
|
||||
|
||||
export default api
|
||||
|
|
|
|||
|
|
@ -436,24 +436,6 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
/* Horizontally scrollable tab bar — for tab rows with many items */
|
||||
.tab-bar-scroll {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
flex-wrap: nowrap;
|
||||
padding-bottom: 2px; /* prevent focus ring clipping */
|
||||
}
|
||||
|
||||
.tab-bar-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
TEXT UTILITIES
|
||||
============================================ */
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
"""
|
||||
Fast targeted backfill for meal: tags only.
|
||||
|
||||
Rather than re-deriving ALL inferred_tags via the full infer_tags() pipeline
|
||||
(which takes ~2.5h for 3.19M recipes), this script:
|
||||
|
||||
1. Reads only id + title + inferred_tags (no ingredient profiles needed —
|
||||
meal signals are title-only).
|
||||
2. Runs _match_title_signals() against the title to get meal tags.
|
||||
3. For rows that already have inferred_tags: merges in the new meal tags
|
||||
(no-op if already present).
|
||||
4. For rows with no inferred_tags: runs the full infer_tags() pipeline so
|
||||
those rows get a complete tag set, not just meal tags.
|
||||
5. Rebuilds the FTS5 index once at the end.
|
||||
|
||||
Estimated runtime on 3.19M recipes: 3–5 minutes.
|
||||
|
||||
Usage:
|
||||
python scripts/pipeline/backfill_meal_tags.py [path/to/kiwi.db]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from app.services.recipe.tag_inferrer import _MEAL_SIGNALS, _match_title_signals
|
||||
|
||||
|
||||
def run(db_path: Path, batch_size: int = 10_000) -> None:
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
|
||||
total = conn.execute("SELECT count(*) FROM recipes").fetchone()[0]
|
||||
print(f"Total recipes: {total:,}")
|
||||
|
||||
updated = 0
|
||||
skipped = 0
|
||||
offset = 0
|
||||
|
||||
while True:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, title, inferred_tags
|
||||
FROM recipes
|
||||
ORDER BY id
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(batch_size, offset),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
break
|
||||
|
||||
updates: list[tuple[str, int]] = []
|
||||
for row_id, title, tags_json in rows:
|
||||
title = title or ""
|
||||
meal_tags = _match_title_signals(title, _MEAL_SIGNALS)
|
||||
if not meal_tags:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
existing: list[str] = json.loads(tags_json) if tags_json else []
|
||||
except Exception:
|
||||
existing = []
|
||||
|
||||
# Merge: union of existing + new meal tags, sorted
|
||||
merged = sorted(set(existing) | set(meal_tags))
|
||||
if merged == existing:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
updates.append((json.dumps(merged), row_id))
|
||||
|
||||
if updates:
|
||||
conn.executemany(
|
||||
"UPDATE recipes SET inferred_tags = ? WHERE id = ?", updates
|
||||
)
|
||||
conn.commit()
|
||||
updated += len(updates)
|
||||
|
||||
offset += len(rows)
|
||||
pct = min(100, int(offset * 100 / total))
|
||||
print(f" {pct:>3}% offset {offset:,} merged {updated:,} skipped {skipped:,}",
|
||||
end="\r")
|
||||
|
||||
print(f"\nDone. Merged meal tags into {updated:,} recipes ({skipped:,} unchanged).")
|
||||
|
||||
if updated > 0:
|
||||
print("Rebuilding FTS5 browser index...")
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO recipe_browser_fts(recipe_browser_fts) VALUES('rebuild')"
|
||||
)
|
||||
conn.commit()
|
||||
print("FTS rebuild complete.")
|
||||
except Exception as e:
|
||||
print(f"FTS rebuild skipped: {e}")
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("db", nargs="?", default="data/kiwi.db", type=Path)
|
||||
parser.add_argument("--batch-size", type=int, default=10_000)
|
||||
args = parser.parse_args()
|
||||
if not args.db.exists():
|
||||
print(f"DB not found: {args.db}")
|
||||
sys.exit(1)
|
||||
run(args.db, args.batch_size)
|
||||
|
|
@ -1,304 +0,0 @@
|
|||
"""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
|
||||
|
|
@ -1,233 +0,0 @@
|
|||
"""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