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 import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, feedback_attach, household, saved_recipes, imitate, meal_plans, orch_usage, session, shopping
|
||||||
from app.api.endpoints.community import router as community_router
|
from app.api.endpoints.community import router as community_router
|
||||||
from app.api.endpoints.corrections import router as corrections_router
|
from app.api.endpoints.corrections import router as corrections_router
|
||||||
from app.api.endpoints.recipe_scan import router as recipe_scan_router
|
|
||||||
from app.api.endpoints.recipe_tags import router as recipe_tags_router
|
from app.api.endpoints.recipe_tags import router as recipe_tags_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
@ -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(export.router, tags=["export"])
|
||||||
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
|
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
|
||||||
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
|
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
|
||||||
# recipe_scan_router registered BEFORE recipes.router so /recipes/scan and /recipes/user
|
|
||||||
# take priority over /recipes/{recipe_id} (which would otherwise match them as int IDs).
|
|
||||||
api_router.include_router(recipe_scan_router, prefix="/recipes", tags=["recipe-scan"])
|
|
||||||
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
|
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
|
||||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||||
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"style_tags",
|
||||||
# meal plan columns
|
# meal plan columns
|
||||||
"meal_types",
|
"meal_types",
|
||||||
# user_recipes columns
|
|
||||||
"steps", "tags",
|
|
||||||
# captured_products columns
|
# captured_products columns
|
||||||
"allergens"):
|
"allergens"):
|
||||||
if key in d and isinstance(d[key], str):
|
if key in d and isinstance(d[key], str):
|
||||||
|
|
@ -1804,54 +1802,3 @@ class Store:
|
||||||
confidence, 1 if confirmed_by_user else 0, source,
|
confidence, 1 if confirmed_by_user else 0, source,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── User Recipes (kiwi#9) ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def create_user_recipe(
|
|
||||||
self,
|
|
||||||
title: str,
|
|
||||||
ingredients: list[dict],
|
|
||||||
steps: list[str],
|
|
||||||
subtitle: str | None = None,
|
|
||||||
servings: str | None = None,
|
|
||||||
cook_time: str | None = None,
|
|
||||||
source_note: str | None = None,
|
|
||||||
notes: str | None = None,
|
|
||||||
tags: list[str] | None = None,
|
|
||||||
source: str = "manual",
|
|
||||||
pantry_match_pct: int | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return self._insert_returning(
|
|
||||||
"""INSERT INTO user_recipes
|
|
||||||
(title, subtitle, servings, cook_time, source_note,
|
|
||||||
ingredients, steps, notes, tags, source, pantry_match_pct)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
RETURNING *""",
|
|
||||||
(
|
|
||||||
title, subtitle, servings, cook_time, source_note,
|
|
||||||
self._dump(ingredients),
|
|
||||||
self._dump(steps),
|
|
||||||
notes,
|
|
||||||
self._dump(tags or []),
|
|
||||||
source,
|
|
||||||
pantry_match_pct,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_user_recipe(self, recipe_id: int) -> dict[str, Any] | None:
|
|
||||||
return self._fetch_one(
|
|
||||||
"SELECT * FROM user_recipes WHERE id = ?",
|
|
||||||
(recipe_id,),
|
|
||||||
)
|
|
||||||
|
|
||||||
def list_user_recipes(self) -> list[dict[str, Any]]:
|
|
||||||
return self._fetch_all(
|
|
||||||
"SELECT * FROM user_recipes ORDER BY created_at DESC",
|
|
||||||
)
|
|
||||||
|
|
||||||
def delete_user_recipe(self, recipe_id: int) -> bool:
|
|
||||||
cur = self.conn.execute(
|
|
||||||
"DELETE FROM user_recipes WHERE id = ?", (recipe_id,)
|
|
||||||
)
|
|
||||||
self.conn.commit()
|
|
||||||
return cur.rowcount > 0
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"label": "Cuisine",
|
||||||
"categories": {
|
"categories": {
|
||||||
"Italian": {
|
"Italian": {
|
||||||
"keywords": ["cuisine:Italian", "italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
|
"keywords": ["italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
|
||||||
"subcategories": {
|
"subcategories": {
|
||||||
"Sicilian": ["sicilian", "sicily", "arancini", "caponata",
|
"Sicilian": ["sicilian", "sicily", "arancini", "caponata",
|
||||||
"involtini", "cannoli"],
|
"involtini", "cannoli"],
|
||||||
|
|
@ -43,8 +43,8 @@ DOMAINS: dict[str, dict] = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"Mexican": {
|
"Mexican": {
|
||||||
"keywords": ["cuisine:Mexican", "mexican", "taco", "enchilada", "burrito",
|
"keywords": ["mexican", "taco", "enchilada", "burrito", "salsa",
|
||||||
"salsa", "guacamole", "mole", "tamale"],
|
"guacamole", "mole", "tamale"],
|
||||||
"subcategories": {
|
"subcategories": {
|
||||||
"Oaxacan": ["oaxacan", "oaxaca", "mole negro", "tlayuda",
|
"Oaxacan": ["oaxacan", "oaxaca", "mole negro", "tlayuda",
|
||||||
"chapulines", "mezcal", "tasajo", "memelas"],
|
"chapulines", "mezcal", "tasajo", "memelas"],
|
||||||
|
|
@ -67,9 +67,7 @@ DOMAINS: dict[str, dict] = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"Asian": {
|
"Asian": {
|
||||||
"keywords": ["cuisine:Chinese", "cuisine:Japanese", "cuisine:Korean",
|
"keywords": ["asian", "chinese", "japanese", "thai", "korean", "vietnamese",
|
||||||
"cuisine:Thai", "cuisine:Vietnamese",
|
|
||||||
"asian", "chinese", "japanese", "thai", "korean", "vietnamese",
|
|
||||||
"stir fry", "stir-fry", "ramen", "sushi", "malaysian",
|
"stir fry", "stir-fry", "ramen", "sushi", "malaysian",
|
||||||
"taiwanese", "singaporean", "burmese", "cambodian",
|
"taiwanese", "singaporean", "burmese", "cambodian",
|
||||||
"laotian", "mongolian", "hong kong"],
|
"laotian", "mongolian", "hong kong"],
|
||||||
|
|
@ -130,7 +128,7 @@ DOMAINS: dict[str, dict] = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"Indian": {
|
"Indian": {
|
||||||
"keywords": ["cuisine:Indian", "indian", "curry", "lentil", "dal", "tikka", "masala",
|
"keywords": ["indian", "curry", "lentil", "dal", "tikka", "masala",
|
||||||
"biryani", "naan", "chutney", "pakistani", "sri lankan",
|
"biryani", "naan", "chutney", "pakistani", "sri lankan",
|
||||||
"bangladeshi", "nepali"],
|
"bangladeshi", "nepali"],
|
||||||
"subcategories": {
|
"subcategories": {
|
||||||
|
|
@ -158,8 +156,7 @@ DOMAINS: dict[str, dict] = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"Mediterranean": {
|
"Mediterranean": {
|
||||||
"keywords": ["cuisine:Mediterranean", "cuisine:Greek", "cuisine:Middle Eastern",
|
"keywords": ["mediterranean", "greek", "middle eastern", "turkish",
|
||||||
"mediterranean", "greek", "middle eastern", "turkish",
|
|
||||||
"lebanese", "jewish", "palestinian", "yemeni", "egyptian",
|
"lebanese", "jewish", "palestinian", "yemeni", "egyptian",
|
||||||
"syrian", "iraqi", "jordanian"],
|
"syrian", "iraqi", "jordanian"],
|
||||||
"subcategories": {
|
"subcategories": {
|
||||||
|
|
@ -193,8 +190,7 @@ DOMAINS: dict[str, dict] = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"American": {
|
"American": {
|
||||||
"keywords": ["cuisine:American", "cuisine:Southern", "cuisine:Cajun",
|
"keywords": ["american", "southern", "comfort food", "cajun", "creole",
|
||||||
"american", "southern", "comfort food", "cajun", "creole",
|
|
||||||
"hawaiian", "tex-mex", "soul food"],
|
"hawaiian", "tex-mex", "soul food"],
|
||||||
"subcategories": {
|
"subcategories": {
|
||||||
"Southern": ["southern", "soul food", "fried chicken",
|
"Southern": ["southern", "soul food", "fried chicken",
|
||||||
|
|
@ -218,8 +214,10 @@ DOMAINS: dict[str, dict] = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"BBQ & Smoke": {
|
"BBQ & Smoke": {
|
||||||
# Top-level keywords: cuisine:BBQ inferred tag + broad corpus terms.
|
# Top-level keywords use broad corpus-friendly terms that appear in
|
||||||
"keywords": ["cuisine:BBQ", "bbq", "barbecue", "barbeque", "smoked", "smoky",
|
# 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",
|
"smoke", "pit", "smoke ring", "low and slow",
|
||||||
"brisket", "pulled pork", "ribs", "spare ribs",
|
"brisket", "pulled pork", "ribs", "spare ribs",
|
||||||
"baby back", "baby back ribs", "dry rub", "wet rub",
|
"baby back", "baby back ribs", "dry rub", "wet rub",
|
||||||
|
|
@ -253,8 +251,7 @@ DOMAINS: dict[str, dict] = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"European": {
|
"European": {
|
||||||
"keywords": ["cuisine:French", "cuisine:German", "cuisine:Spanish",
|
"keywords": ["french", "german", "spanish", "british", "irish", "scottish",
|
||||||
"french", "german", "spanish", "british", "irish", "scottish",
|
|
||||||
"welsh", "scandinavian", "nordic", "eastern european"],
|
"welsh", "scandinavian", "nordic", "eastern european"],
|
||||||
"subcategories": {
|
"subcategories": {
|
||||||
"French": ["french", "provencal", "beurre", "crepe",
|
"French": ["french", "provencal", "beurre", "crepe",
|
||||||
|
|
@ -284,8 +281,7 @@ DOMAINS: dict[str, dict] = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"Latin American": {
|
"Latin American": {
|
||||||
"keywords": ["cuisine:Latin American", "cuisine:Caribbean",
|
"keywords": ["latin american", "peruvian", "argentinian", "colombian",
|
||||||
"latin american", "peruvian", "argentinian", "colombian",
|
|
||||||
"cuban", "caribbean", "brazilian", "venezuelan", "chilean"],
|
"cuban", "caribbean", "brazilian", "venezuelan", "chilean"],
|
||||||
"subcategories": {
|
"subcategories": {
|
||||||
"Peruvian": ["peruvian", "ceviche", "lomo saltado", "anticucho",
|
"Peruvian": ["peruvian", "ceviche", "lomo saltado", "anticucho",
|
||||||
|
|
@ -429,18 +425,12 @@ DOMAINS: dict[str, dict] = {
|
||||||
"meal_type": {
|
"meal_type": {
|
||||||
"label": "Meal Type",
|
"label": "Meal Type",
|
||||||
"categories": {
|
"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": {
|
"Breakfast": {
|
||||||
"keywords": ["meal:Breakfast", "breakfast", "brunch", "pancakes",
|
"keywords": ["breakfast", "brunch", "eggs", "pancakes", "waffles",
|
||||||
"waffles", "oatmeal", "muffin"],
|
"oatmeal", "muffin"],
|
||||||
"subcategories": {
|
"subcategories": {
|
||||||
"Eggs": ["meal:Breakfast", "egg", "omelette", "frittata",
|
"Eggs": ["egg", "omelette", "frittata", "quiche",
|
||||||
"quiche", "scrambled", "benedict", "shakshuka"],
|
"scrambled", "benedict", "shakshuka"],
|
||||||
"Pancakes & Waffles": ["pancake", "waffle", "crepe", "french toast"],
|
"Pancakes & Waffles": ["pancake", "waffle", "crepe", "french toast"],
|
||||||
"Baked Goods": ["muffin", "scone", "biscuit", "quick bread",
|
"Baked Goods": ["muffin", "scone", "biscuit", "quick bread",
|
||||||
"coffee cake", "danish"],
|
"coffee cake", "danish"],
|
||||||
|
|
@ -449,15 +439,12 @@ DOMAINS: dict[str, dict] = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"Lunch": {
|
"Lunch": {
|
||||||
# meal:Lunch tag covers explicitly-tagged recipes.
|
"keywords": ["lunch", "sandwich", "wrap", "salad", "soup", "light meal"],
|
||||||
# Coverage is limited — most lunch-style recipes have no distinct meal-type tag.
|
|
||||||
"keywords": ["meal:Lunch", "lunch", "sandwich", "wrap", "salad",
|
|
||||||
"soup", "light meal"],
|
|
||||||
"subcategories": {
|
"subcategories": {
|
||||||
"Sandwiches": ["sandwich", "sub", "hoagie", "panini", "club",
|
"Sandwiches": ["sandwich", "sub", "hoagie", "panini", "club",
|
||||||
"grilled cheese", "blt"],
|
"grilled cheese", "blt"],
|
||||||
"Salads": ["salad", "grain bowl", "chopped", "caesar",
|
"Salads": ["salad", "grain bowl", "chopped", "caesar",
|
||||||
"cobb"],
|
"niçoise", "cobb"],
|
||||||
"Soups": ["soup", "bisque", "chowder", "gazpacho",
|
"Soups": ["soup", "bisque", "chowder", "gazpacho",
|
||||||
"minestrone", "lentil soup"],
|
"minestrone", "lentil soup"],
|
||||||
"Wraps": ["wrap", "burrito bowl", "pita", "lettuce wrap",
|
"Wraps": ["wrap", "burrito bowl", "pita", "lettuce wrap",
|
||||||
|
|
@ -465,27 +452,23 @@ DOMAINS: dict[str, dict] = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"Dinner": {
|
"Dinner": {
|
||||||
# Primary: main:X inferred tags (800k+ recipes).
|
"keywords": ["dinner", "main dish", "entree", "main course", "supper"],
|
||||||
# "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"],
|
|
||||||
"subcategories": {
|
"subcategories": {
|
||||||
"Chicken": ["main:Chicken"],
|
"Casseroles": ["casserole", "bake", "gratin", "lasagna",
|
||||||
"Beef": ["main:Beef"],
|
"sheperd's pie", "pot pie"],
|
||||||
"Pork": ["main:Pork"],
|
|
||||||
"Fish & Seafood": ["main:Fish"],
|
|
||||||
"Pasta": ["main:Pasta"],
|
|
||||||
"Casseroles": ["casserole", "bake", "gratin", "pot pie"],
|
|
||||||
"Stews": ["stew", "braise", "slow cooker", "pot roast",
|
"Stews": ["stew", "braise", "slow cooker", "pot roast",
|
||||||
"daube"],
|
"daube", "ragù"],
|
||||||
"Grilled": ["grilled", "grill", "barbecue", "kebab", "skewer"],
|
"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": {
|
"Snack": {
|
||||||
"keywords": ["meal:Snack", "snack", "appetizer", "finger food",
|
"keywords": ["snack", "appetizer", "finger food", "dip", "bite",
|
||||||
"dip", "bite", "starter"],
|
"starter"],
|
||||||
"subcategories": {
|
"subcategories": {
|
||||||
"Dips & Spreads": ["dip", "spread", "hummus", "guacamole",
|
"Dips & Spreads": ["dip", "spread", "hummus", "guacamole",
|
||||||
"salsa", "pate"],
|
"salsa", "pate"],
|
||||||
|
|
@ -496,9 +479,8 @@ DOMAINS: dict[str, dict] = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"Dessert": {
|
"Dessert": {
|
||||||
# "sweet" removed — it matches flavor:Sweet inferred tags, causing false positives.
|
"keywords": ["dessert", "cake", "cookie", "pie", "sweet", "pudding",
|
||||||
"keywords": ["meal:Dessert", "dessert", "cake", "cookie", "pie",
|
"ice cream", "brownie"],
|
||||||
"pudding", "ice cream", "brownie"],
|
|
||||||
"subcategories": {
|
"subcategories": {
|
||||||
"Cakes": ["cake", "cupcake", "layer cake", "bundt",
|
"Cakes": ["cake", "cupcake", "layer cake", "bundt",
|
||||||
"cheesecake", "torte"],
|
"cheesecake", "torte"],
|
||||||
|
|
@ -514,41 +496,20 @@ DOMAINS: dict[str, dict] = {
|
||||||
"caramel", "toffee"],
|
"caramel", "toffee"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"Beverage": ["meal:Beverage", "drink", "smoothie", "cocktail", "beverage",
|
"Beverage": ["drink", "smoothie", "cocktail", "beverage", "juice", "shake"],
|
||||||
"juice", "shake", "lemonade"],
|
"Side Dish": ["side dish", "side", "accompaniment", "garnish"],
|
||||||
"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"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"dietary": {
|
"dietary": {
|
||||||
"label": "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": {
|
"categories": {
|
||||||
"Vegetarian": ["dietary:Vegetarian"],
|
"Vegetarian": ["vegetarian"],
|
||||||
"Vegan": ["dietary:Vegan"],
|
"Vegan": ["vegan", "plant-based", "plant based"],
|
||||||
"Gluten-Free": ["dietary:Gluten-Free"],
|
"Gluten-Free": ["gluten-free", "gluten free", "celiac"],
|
||||||
"Low-Carb": ["dietary:Low-Carb"],
|
"Low-Carb": ["low-carb", "low carb", "keto", "ketogenic"],
|
||||||
"High-Protein": ["dietary:High-Protein"],
|
"High-Protein": ["high protein", "high-protein"],
|
||||||
"Low-Fat": ["dietary:Low-Fat"],
|
"Low-Fat": ["low-fat", "low fat", "light"],
|
||||||
"Dairy-Free": ["dietary:Dairy-Free"],
|
"Dairy-Free": ["dairy-free", "dairy free", "lactose"],
|
||||||
"Low-Sodium": ["dietary:Low-Sodium"],
|
|
||||||
"Paleo": ["dietary:Paleo"],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"main_ingredient": {
|
"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
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Text-signal tables
|
# Text-signal tables
|
||||||
|
|
@ -123,50 +121,6 @@ _TIME_SIGNALS: list[tuple[str, list[str]]] = [
|
||||||
("time:Slow Cook", ["slow cooker", "crockpot", "< 4 hours", "braise"]),
|
("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_INGREDIENT_SIGNALS: list[tuple[str, list[str]]] = [
|
||||||
("main:Chicken", ["chicken", "poultry", "turkey"]),
|
("main:Chicken", ["chicken", "poultry", "turkey"]),
|
||||||
("main:Beef", ["beef", "ground beef", "steak", "brisket", "pot roast"]),
|
("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)]
|
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(
|
def infer_tags(
|
||||||
title: str,
|
title: str,
|
||||||
ingredient_names: list[str],
|
ingredient_names: list[str],
|
||||||
|
|
@ -327,9 +258,6 @@ def infer_tags(
|
||||||
tags.update(_match_signals(text, _FLAVOR_SIGNALS))
|
tags.update(_match_signals(text, _FLAVOR_SIGNALS))
|
||||||
tags.update(_match_signals(text, _MAIN_INGREDIENT_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
|
# 3. Time signals from corpus keywords + text
|
||||||
corpus_text = " ".join(kw.lower() for kw in corpus_keywords)
|
corpus_text = " ".join(kw.lower() for kw in corpus_keywords)
|
||||||
tags.update(_match_signals(corpus_text, _TIME_SIGNALS))
|
tags.update(_match_signals(corpus_text, _TIME_SIGNALS))
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
|
||||||
"recipe_suggestions",
|
"recipe_suggestions",
|
||||||
"expiry_llm_matching",
|
"expiry_llm_matching",
|
||||||
"receipt_ocr",
|
"receipt_ocr",
|
||||||
"recipe_scan",
|
|
||||||
"style_classifier",
|
"style_classifier",
|
||||||
"meal_plan_llm",
|
"meal_plan_llm",
|
||||||
"meal_plan_llm_timing",
|
"meal_plan_llm_timing",
|
||||||
|
|
@ -59,9 +58,6 @@ KIWI_FEATURES: dict[str, str] = {
|
||||||
"community_publish": "paid", # Publish plans/outcomes to community feed
|
"community_publish": "paid", # Publish plans/outcomes to community feed
|
||||||
"community_fork_adapt": "paid", # Fork with LLM pantry adaptation (BYOK-unlockable)
|
"community_fork_adapt": "paid", # Fork with LLM pantry adaptation (BYOK-unlockable)
|
||||||
|
|
||||||
# Paid tier (continued)
|
|
||||||
"recipe_scan": "paid", # BYOK-unlockable: photo -> structured recipe
|
|
||||||
|
|
||||||
# Premium tier
|
# Premium tier
|
||||||
"multi_household": "premium",
|
"multi_household": "premium",
|
||||||
"background_monitoring": "premium",
|
"background_monitoring": "premium",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<template>
|
||||||
<div class="recipes-view">
|
<div class="recipes-view">
|
||||||
|
|
||||||
<!-- Tab bar: Find / Browse / Saved + Scan action button -->
|
<!-- Tab bar: Find / Browse / Saved -->
|
||||||
<div class="tab-bar-row flex gap-xs mb-md" style="align-items:center;">
|
<div role="tablist" aria-label="Recipe sections" class="tab-bar flex gap-xs mb-md">
|
||||||
<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 -->
|
|
||||||
<button
|
<button
|
||||||
class="btn btn-secondary scan-btn"
|
v-for="tab in tabs"
|
||||||
style="flex-shrink:0;"
|
:key="tab.id"
|
||||||
@click="scanModalOpen = true"
|
:id="`tab-${tab.id}`"
|
||||||
title="Scan a recipe card or cookbook page"
|
role="tab"
|
||||||
aria-label="Scan a recipe"
|
:aria-selected="activeTab === tab.id"
|
||||||
>
|
:tabindex="activeTab === tab.id ? 0 : -1"
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
:class="['btn', 'tab-btn', activeTab === tab.id ? 'btn-primary' : 'btn-secondary']"
|
||||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
|
@click="activateTab(tab.id)"
|
||||||
<circle cx="12" cy="13" r="4"/>
|
@keydown="onTabKeydown"
|
||||||
</svg>
|
>{{ tab.label }}</button>
|
||||||
<span class="desktop-only">Scan</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scan success toast -->
|
|
||||||
<div
|
|
||||||
v-if="lastScannedTitle"
|
|
||||||
class="undo-toast"
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
Saved "{{ lastScannedTitle }}" to your recipes.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Scan modal -->
|
|
||||||
<RecipeScanModal
|
|
||||||
v-if="scanModalOpen"
|
|
||||||
@close="scanModalOpen = false"
|
|
||||||
@saved="onScanSaved"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Browse tab -->
|
<!-- Browse tab -->
|
||||||
<RecipeBrowserPanel
|
<RecipeBrowserPanel
|
||||||
v-if="activeTab === 'browse'"
|
v-if="activeTab === 'browse'"
|
||||||
|
|
@ -783,22 +746,10 @@ import SavedRecipesPanel from './SavedRecipesPanel.vue'
|
||||||
import CommunityFeedPanel from './CommunityFeedPanel.vue'
|
import CommunityFeedPanel from './CommunityFeedPanel.vue'
|
||||||
import BuildYourOwnTab from './BuildYourOwnTab.vue'
|
import BuildYourOwnTab from './BuildYourOwnTab.vue'
|
||||||
import OrchUsagePill from './OrchUsagePill.vue'
|
import OrchUsagePill from './OrchUsagePill.vue'
|
||||||
import RecipeScanModal from './RecipeScanModal.vue'
|
|
||||||
import type { ForkResult } from '../stores/community'
|
import type { ForkResult } from '../stores/community'
|
||||||
import type { RecipeSuggestion, GroceryLink, StreamTokenResponse } from '../services/api'
|
import type { RecipeSuggestion, GroceryLink, StreamTokenResponse } from '../services/api'
|
||||||
import { recipesAPI } from '../services/api'
|
import { recipesAPI } from '../services/api'
|
||||||
|
|
||||||
// ── Scan modal ────────────────────────────────────────────────────────────────
|
|
||||||
const scanModalOpen = ref(false)
|
|
||||||
const lastScannedTitle = ref<string | null>(null)
|
|
||||||
|
|
||||||
function onScanSaved(recipe: { id: number; title: string }) {
|
|
||||||
lastScannedTitle.value = recipe.title
|
|
||||||
scanModalOpen.value = false
|
|
||||||
// Dismiss the toast after 4 seconds
|
|
||||||
setTimeout(() => { lastScannedTitle.value = null }, 4000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Streaming state
|
// Streaming state
|
||||||
const isStreaming = ref(false)
|
const isStreaming = ref(false)
|
||||||
const streamChunks = ref('')
|
const streamChunks = ref('')
|
||||||
|
|
@ -810,9 +761,9 @@ const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
// Tab state
|
// Tab state
|
||||||
type TabId = 'find' | 'browse' | 'saved' | 'community' | 'build'
|
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: 'saved', label: 'Saved' },
|
||||||
{ id: 'build', label: 'Build Your Own', mobileLabel: 'Build' },
|
{ id: 'build', label: 'Build Your Own' },
|
||||||
{ id: 'community', label: 'Community' },
|
{ id: 'community', label: 'Community' },
|
||||||
{ id: 'find', label: 'Find' },
|
{ id: 'find', label: 'Find' },
|
||||||
{ id: 'browse', label: 'Browse' },
|
{ id: 'browse', label: 'Browse' },
|
||||||
|
|
@ -1294,14 +1245,6 @@ watch(
|
||||||
padding-bottom: var(--spacing-sm);
|
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 {
|
.tab-btn {
|
||||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
|
|
|
||||||
|
|
@ -1204,77 +1204,4 @@ export const DEFAULT_SENSORY_PREFERENCES: SensoryPreferences = {
|
||||||
max_noise: null,
|
max_noise: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Recipe Scanner (kiwi#9) ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ScannedIngredient {
|
|
||||||
name: string
|
|
||||||
qty: string | null
|
|
||||||
unit: string | null
|
|
||||||
raw: string | null
|
|
||||||
in_pantry: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScannedRecipe {
|
|
||||||
title: string | null
|
|
||||||
subtitle: string | null
|
|
||||||
servings: string | null
|
|
||||||
cook_time: string | null
|
|
||||||
source_note: string | null
|
|
||||||
ingredients: ScannedIngredient[]
|
|
||||||
steps: string[]
|
|
||||||
notes: string | null
|
|
||||||
tags: string[]
|
|
||||||
pantry_match_pct: number
|
|
||||||
confidence: 'high' | 'medium' | 'low'
|
|
||||||
warnings: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserRecipe {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
subtitle: string | null
|
|
||||||
servings: string | null
|
|
||||||
cook_time: string | null
|
|
||||||
source_note: string | null
|
|
||||||
ingredients: ScannedIngredient[]
|
|
||||||
steps: string[]
|
|
||||||
notes: string | null
|
|
||||||
tags: string[]
|
|
||||||
source: string
|
|
||||||
pantry_match_pct: number | null
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const recipeScanAPI = {
|
|
||||||
/** Scan 1-4 recipe photos. Returns structured recipe for review (not saved). */
|
|
||||||
scan(files: File[]): Promise<ScannedRecipe> {
|
|
||||||
const form = new FormData()
|
|
||||||
files.forEach((f) => form.append('files', f))
|
|
||||||
return api.post('/recipes/scan', form, {
|
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
|
||||||
timeout: 120_000, // VLM can be slow on first call
|
|
||||||
}).then((r) => r.data)
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Save a reviewed/edited scanned recipe to user_recipes. */
|
|
||||||
saveScanned(recipe: Omit<ScannedRecipe, 'pantry_match_pct' | 'confidence' | 'warnings'> & { source?: string }): Promise<UserRecipe> {
|
|
||||||
return api.post('/recipes/scan/save', recipe).then((r) => r.data)
|
|
||||||
},
|
|
||||||
|
|
||||||
/** List all user-created recipes (scan + manual). */
|
|
||||||
listUserRecipes(): Promise<UserRecipe[]> {
|
|
||||||
return api.get('/recipes/user').then((r) => r.data)
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Get a single user recipe by ID. */
|
|
||||||
getUserRecipe(id: number): Promise<UserRecipe> {
|
|
||||||
return api.get(`/recipes/user/${id}`).then((r) => r.data)
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Delete a user recipe. */
|
|
||||||
deleteUserRecipe(id: number): Promise<void> {
|
|
||||||
return api.delete(`/recipes/user/${id}`).then(() => undefined)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|
|
||||||
|
|
@ -436,24 +436,6 @@
|
||||||
display: none;
|
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
|
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