feat: recipe scanner — photo to structured recipe (kiwi#9)
New feature: photograph a recipe card, cookbook page, or handwritten note and have it extracted into a structured, editable recipe. Backend: - POST /recipes/scan: accept 1-4 photos, run VLM extraction, return structured JSON for review (not auto-saved) - POST /recipes/scan/save: persist a reviewed/edited recipe - GET/DELETE /recipes/user: user-created recipe CRUD - Vision backend priority: cf-orch -> local Qwen2.5-VL -> Anthropic BYOK - 503 with clear config hint when no vision backend available - Multi-photo support: facing pages (ingredients/directions) sent together - Pantry cross-reference: marks which ingredients are already on hand - migration 041: user_recipes table (title, servings, cook_time, steps, ingredients JSON, source, pantry_match_pct) - Tier gate: recipe_scan -> paid, BYOK-unlockable Frontend: - "Scan" button in the Recipes tab bar (camera icon) - RecipeScanModal: upload step (drag-drop + file picker, up to 4 photos, live previews), processing step (spinner), review/edit step (all fields inline-editable before save), pantry match badge, warning banner for low-confidence or incomplete scans Tests: 35 new tests (23 unit + 12 API), 404 total passing
This commit is contained in:
parent
c9fcfde694
commit
896b4e048c
12 changed files with 2335 additions and 12 deletions
256
app/api/endpoints/recipe_scan.py
Normal file
256
app/api/endpoints/recipe_scan.py
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
"""Recipe scanner endpoints (kiwi#9).
|
||||
|
||||
POST /recipes/scan -- scan photo(s) -> structured recipe JSON (not saved)
|
||||
POST /recipes/scan/save -- save a confirmed scanned recipe to user_recipes
|
||||
GET /recipes/user -- list user-created recipes
|
||||
GET /recipes/user/{id} -- get a single user recipe
|
||||
DELETE /recipes/user/{id} -- delete a user recipe
|
||||
|
||||
BSL 1.1 -- recipe_scan requires Paid tier or BYOK.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import aiofiles
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.cloud_session import CloudUser, get_session
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_store
|
||||
from app.db.store import Store
|
||||
from app.models.schemas.recipe_scan import (
|
||||
ScannedIngredientSchema,
|
||||
ScannedRecipeResponse,
|
||||
ScannedRecipeSaveRequest,
|
||||
UserRecipeResponse,
|
||||
)
|
||||
from app.tiers import can_use
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
_ALLOWED_MIME_TYPES = {
|
||||
"image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic", "image/heif"
|
||||
}
|
||||
_MAX_FILE_SIZE_MB = 20
|
||||
|
||||
|
||||
async def _save_upload_temp(file: UploadFile) -> Path:
|
||||
"""Write upload to a temp path under UPLOAD_DIR. Caller is responsible for cleanup."""
|
||||
settings.ensure_dirs()
|
||||
dest = settings.UPLOAD_DIR / f"scan_{uuid.uuid4()}_{file.filename}"
|
||||
async with aiofiles.open(dest, "wb") as f:
|
||||
await f.write(await file.read())
|
||||
return dest
|
||||
|
||||
|
||||
def _result_to_response(result) -> ScannedRecipeResponse:
|
||||
"""Convert ScannedRecipeResult (dataclass) to Pydantic response schema."""
|
||||
return ScannedRecipeResponse(
|
||||
title=result.title,
|
||||
subtitle=result.subtitle,
|
||||
servings=result.servings,
|
||||
cook_time=result.cook_time,
|
||||
source_note=result.source_note,
|
||||
ingredients=[
|
||||
ScannedIngredientSchema(
|
||||
name=i.name,
|
||||
qty=i.qty,
|
||||
unit=i.unit,
|
||||
raw=i.raw,
|
||||
in_pantry=i.in_pantry,
|
||||
)
|
||||
for i in result.ingredients
|
||||
],
|
||||
steps=result.steps,
|
||||
notes=result.notes,
|
||||
tags=result.tags,
|
||||
pantry_match_pct=result.pantry_match_pct,
|
||||
confidence=result.confidence,
|
||||
warnings=result.warnings,
|
||||
)
|
||||
|
||||
|
||||
def _row_to_user_recipe(row: dict) -> UserRecipeResponse:
|
||||
"""Convert a store row dict to UserRecipeResponse."""
|
||||
return UserRecipeResponse(
|
||||
id=row["id"],
|
||||
title=row["title"],
|
||||
subtitle=row.get("subtitle"),
|
||||
servings=row.get("servings"),
|
||||
cook_time=row.get("cook_time"),
|
||||
source_note=row.get("source_note"),
|
||||
ingredients=[
|
||||
ScannedIngredientSchema(**i) if isinstance(i, dict) else i
|
||||
for i in (row.get("ingredients") or [])
|
||||
],
|
||||
steps=row.get("steps") or [],
|
||||
notes=row.get("notes"),
|
||||
tags=row.get("tags") or [],
|
||||
source=row.get("source", "manual"),
|
||||
pantry_match_pct=row.get("pantry_match_pct"),
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
|
||||
|
||||
# ── Scan endpoint ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/scan", response_model=ScannedRecipeResponse)
|
||||
async def scan_recipe(
|
||||
files: Annotated[list[UploadFile], File(...)],
|
||||
store: Store = Depends(get_store),
|
||||
session: CloudUser = Depends(get_session),
|
||||
):
|
||||
"""Scan one or more recipe photos and return a structured recipe for review.
|
||||
|
||||
Accepts 1-4 images. Multi-page recipes (e.g. ingredients on page 1,
|
||||
directions on page 2) work best when all pages are submitted together.
|
||||
|
||||
The response is NOT saved automatically -- the user reviews and edits it,
|
||||
then calls POST /recipes/scan/save to persist.
|
||||
|
||||
Tier: Paid (or BYOK).
|
||||
"""
|
||||
if not can_use("recipe_scan", session.tier, session.has_byok):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
"Recipe scanning requires Paid tier or a configured vision backend (BYOK). "
|
||||
"Set ANTHROPIC_API_KEY or connect to a cf-orch vision service."
|
||||
),
|
||||
)
|
||||
|
||||
if not files:
|
||||
raise HTTPException(status_code=422, detail="At least one image file is required.")
|
||||
if len(files) > 4:
|
||||
raise HTTPException(status_code=422, detail="Maximum 4 images per scan request.")
|
||||
|
||||
for f in files:
|
||||
ct = (f.content_type or "").lower()
|
||||
if ct and ct not in _ALLOWED_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Unsupported file type: {ct}. Supported: JPEG, PNG, WebP, HEIC.",
|
||||
)
|
||||
|
||||
# Save uploads to temp files
|
||||
saved_paths: list[Path] = []
|
||||
try:
|
||||
for f in files:
|
||||
saved_paths.append(await _save_upload_temp(f))
|
||||
|
||||
# Get pantry item names for cross-reference
|
||||
inventory = await asyncio.to_thread(store.list_inventory)
|
||||
pantry_names = [item["product_name"] for item in inventory if item.get("product_name")]
|
||||
|
||||
# Run scanner (blocks on VLM -- use to_thread)
|
||||
from app.services.recipe.recipe_scanner import RecipeScanner
|
||||
|
||||
def _run_scan():
|
||||
scanner = RecipeScanner()
|
||||
return scanner.scan(saved_paths, pantry_names=pantry_names)
|
||||
|
||||
try:
|
||||
result = await asyncio.to_thread(_run_scan)
|
||||
except ValueError as exc:
|
||||
msg = str(exc)
|
||||
if "not_a_recipe" in msg:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="The image does not appear to contain a recipe. "
|
||||
"Please photograph a recipe card, cookbook page, or handwritten note.",
|
||||
)
|
||||
raise HTTPException(status_code=422, detail=msg)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
return _result_to_response(result)
|
||||
|
||||
finally:
|
||||
# Clean up temp files
|
||||
for p in saved_paths:
|
||||
try:
|
||||
p.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Save endpoint ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/scan/save", response_model=UserRecipeResponse, status_code=201)
|
||||
async def save_scanned_recipe(
|
||||
body: ScannedRecipeSaveRequest,
|
||||
store: Store = Depends(get_store),
|
||||
session: CloudUser = Depends(get_session),
|
||||
):
|
||||
"""Save a user-reviewed (possibly edited) scanned recipe.
|
||||
|
||||
The body is the ScannedRecipeResponse (or a user-edited version of it).
|
||||
Returns the persisted UserRecipe with an assigned ID.
|
||||
|
||||
Tier: Free (saving your own recipe doesn't require vision access).
|
||||
"""
|
||||
def _save():
|
||||
return store.create_user_recipe(
|
||||
title=body.title,
|
||||
subtitle=body.subtitle,
|
||||
servings=body.servings,
|
||||
cook_time=body.cook_time,
|
||||
source_note=body.source_note,
|
||||
ingredients=[i.model_dump() for i in body.ingredients],
|
||||
steps=body.steps,
|
||||
notes=body.notes,
|
||||
tags=body.tags,
|
||||
source=body.source,
|
||||
pantry_match_pct=None,
|
||||
)
|
||||
|
||||
row = await asyncio.to_thread(_save)
|
||||
return _row_to_user_recipe(row)
|
||||
|
||||
|
||||
# ── User recipe list / get / delete ───────────────────────────────────────────
|
||||
|
||||
@router.get("/user", response_model=list[UserRecipeResponse])
|
||||
async def list_user_recipes(
|
||||
store: Store = Depends(get_store),
|
||||
session: CloudUser = Depends(get_session),
|
||||
):
|
||||
"""List all user-created recipes (scanned + manually entered), newest first."""
|
||||
rows = await asyncio.to_thread(store.list_user_recipes)
|
||||
return [_row_to_user_recipe(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/user/{recipe_id}", response_model=UserRecipeResponse)
|
||||
async def get_user_recipe(
|
||||
recipe_id: int,
|
||||
store: Store = Depends(get_store),
|
||||
session: CloudUser = Depends(get_session),
|
||||
):
|
||||
"""Get a single user recipe by ID."""
|
||||
row = await asyncio.to_thread(store.get_user_recipe, recipe_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="User recipe not found.")
|
||||
return _row_to_user_recipe(row)
|
||||
|
||||
|
||||
@router.delete("/user/{recipe_id}", status_code=204)
|
||||
async def delete_user_recipe(
|
||||
recipe_id: int,
|
||||
store: Store = Depends(get_store),
|
||||
session: CloudUser = Depends(get_session),
|
||||
):
|
||||
"""Delete a user recipe by ID."""
|
||||
deleted = await asyncio.to_thread(store.delete_user_recipe, recipe_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="User recipe not found.")
|
||||
return JSONResponse(status_code=204, content=None)
|
||||
|
|
@ -2,6 +2,7 @@ from fastapi import APIRouter
|
|||
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, feedback_attach, household, saved_recipes, imitate, meal_plans, orch_usage, session, shopping
|
||||
from app.api.endpoints.community import router as community_router
|
||||
from app.api.endpoints.corrections import router as corrections_router
|
||||
from app.api.endpoints.recipe_scan import router as recipe_scan_router
|
||||
from app.api.endpoints.recipe_tags import router as recipe_tags_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
|
@ -13,6 +14,9 @@ api_router.include_router(ocr.router, prefix="/receipts", tags=
|
|||
api_router.include_router(export.router, tags=["export"])
|
||||
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
|
||||
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
|
||||
# recipe_scan_router registered BEFORE recipes.router so /recipes/scan and /recipes/user
|
||||
# take priority over /recipes/{recipe_id} (which would otherwise match them as int IDs).
|
||||
api_router.include_router(recipe_scan_router, prefix="/recipes", tags=["recipe-scan"])
|
||||
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
|
||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
|
||||
|
|
|
|||
23
app/db/migrations/041_user_recipes.sql
Normal file
23
app/db/migrations/041_user_recipes.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
-- Migration 041: user_recipes table for user-scanned and manually-entered recipes.
|
||||
--
|
||||
-- Separate from the food.com corpus (recipes table) -- user recipes are personal,
|
||||
-- not curated, and need different fields (servings as string, cook_time as string).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_recipes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
subtitle TEXT,
|
||||
servings TEXT, -- kept as string: "2", "4-6", "serves 8"
|
||||
cook_time TEXT, -- kept as string: "25 min", "1 hour"
|
||||
source_note TEXT, -- e.g. "Purple Carrot", "Betty Crocker"
|
||||
ingredients TEXT NOT NULL DEFAULT '[]', -- JSON: [{name, qty, unit, raw}]
|
||||
steps TEXT NOT NULL DEFAULT '[]', -- JSON: ["step 1", "step 2", ...]
|
||||
notes TEXT,
|
||||
tags TEXT DEFAULT '[]', -- JSON: ["vegan", "quick"]
|
||||
source TEXT NOT NULL DEFAULT 'manual', -- 'scan' | 'manual'
|
||||
pantry_match_pct INTEGER, -- 0-100, computed at scan time; null for manual
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_recipes_created ON user_recipes (created_at DESC);
|
||||
|
|
@ -61,6 +61,8 @@ class Store:
|
|||
"style_tags",
|
||||
# meal plan columns
|
||||
"meal_types",
|
||||
# user_recipes columns
|
||||
"steps", "tags",
|
||||
# captured_products columns
|
||||
"allergens"):
|
||||
if key in d and isinstance(d[key], str):
|
||||
|
|
@ -1802,3 +1804,54 @@ class Store:
|
|||
confidence, 1 if confirmed_by_user else 0, source,
|
||||
),
|
||||
)
|
||||
|
||||
# ── User Recipes (kiwi#9) ──────────────────────────────────────────────────
|
||||
|
||||
def create_user_recipe(
|
||||
self,
|
||||
title: str,
|
||||
ingredients: list[dict],
|
||||
steps: list[str],
|
||||
subtitle: str | None = None,
|
||||
servings: str | None = None,
|
||||
cook_time: str | None = None,
|
||||
source_note: str | None = None,
|
||||
notes: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
source: str = "manual",
|
||||
pantry_match_pct: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return self._insert_returning(
|
||||
"""INSERT INTO user_recipes
|
||||
(title, subtitle, servings, cook_time, source_note,
|
||||
ingredients, steps, notes, tags, source, pantry_match_pct)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING *""",
|
||||
(
|
||||
title, subtitle, servings, cook_time, source_note,
|
||||
self._dump(ingredients),
|
||||
self._dump(steps),
|
||||
notes,
|
||||
self._dump(tags or []),
|
||||
source,
|
||||
pantry_match_pct,
|
||||
),
|
||||
)
|
||||
|
||||
def get_user_recipe(self, recipe_id: int) -> dict[str, Any] | None:
|
||||
return self._fetch_one(
|
||||
"SELECT * FROM user_recipes WHERE id = ?",
|
||||
(recipe_id,),
|
||||
)
|
||||
|
||||
def list_user_recipes(self) -> list[dict[str, Any]]:
|
||||
return self._fetch_all(
|
||||
"SELECT * FROM user_recipes ORDER BY created_at DESC",
|
||||
)
|
||||
|
||||
def delete_user_recipe(self, recipe_id: int) -> bool:
|
||||
cur = self.conn.execute(
|
||||
"DELETE FROM user_recipes WHERE id = ?", (recipe_id,)
|
||||
)
|
||||
self.conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
|
|
|||
74
app/models/schemas/recipe_scan.py
Normal file
74
app/models/schemas/recipe_scan.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""Pydantic schemas for the recipe scanner (kiwi#9).
|
||||
|
||||
Scan input → photo(s).
|
||||
Scan output → ScannedRecipeResponse (for review + editing before save).
|
||||
Save input → ScannedRecipeSaveRequest.
|
||||
User recipe output → UserRecipeResponse (after save).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── Ingredient in a scanned recipe ────────────────────────────────────────────
|
||||
|
||||
class ScannedIngredientSchema(BaseModel):
|
||||
"""One ingredient line extracted from a recipe photo."""
|
||||
name: str # normalized generic name ("ranch dressing")
|
||||
qty: str | None = None # quantity as string, preserving fractions ("1/2", "¼")
|
||||
unit: str | None = None # unit of measure; null for countable items
|
||||
raw: str | None = None # verbatim original line from the image
|
||||
in_pantry: bool = False # True if this ingredient matches something in the pantry
|
||||
|
||||
|
||||
# ── Scan response (returned immediately, not persisted) ───────────────────────
|
||||
|
||||
class ScannedRecipeResponse(BaseModel):
|
||||
"""Structured recipe extracted from photo(s). Returned for user review before save."""
|
||||
title: str | None = None
|
||||
subtitle: str | None = None # e.g. "with Broccoli & Ranch Dressing"
|
||||
servings: str | None = None # kept as string: "2", "4-6", "serves 8"
|
||||
cook_time: str | None = None # kept as string: "25 min", "1 hour"
|
||||
source_note: str | None = None # e.g. "Purple Carrot", "Betty Crocker"
|
||||
ingredients: list[ScannedIngredientSchema] = Field(default_factory=list)
|
||||
steps: list[str] = Field(default_factory=list)
|
||||
notes: str | None = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
pantry_match_pct: int = 0 # 0-100: percentage of ingredients found in pantry
|
||||
confidence: str = "medium" # "high" | "medium" | "low"
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ── Save request ──────────────────────────────────────────────────────────────
|
||||
|
||||
class ScannedRecipeSaveRequest(BaseModel):
|
||||
"""User-reviewed (possibly edited) recipe data to persist as a user recipe."""
|
||||
title: str
|
||||
subtitle: str | None = None
|
||||
servings: str | None = None
|
||||
cook_time: str | None = None
|
||||
source_note: str | None = None
|
||||
ingredients: list[ScannedIngredientSchema]
|
||||
steps: list[str]
|
||||
notes: str | None = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
source: str = "scan" # "scan" | "manual"
|
||||
|
||||
|
||||
# ── User recipe (persisted) ───────────────────────────────────────────────────
|
||||
|
||||
class UserRecipeResponse(BaseModel):
|
||||
"""A user-created or user-scanned recipe stored in user_recipes table."""
|
||||
id: int
|
||||
title: str
|
||||
subtitle: str | None = None
|
||||
servings: str | None = None
|
||||
cook_time: str | None = None
|
||||
source_note: str | None = None
|
||||
ingredients: list[ScannedIngredientSchema]
|
||||
steps: list[str]
|
||||
notes: str | None = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
source: str
|
||||
pantry_match_pct: int | None = None
|
||||
created_at: str
|
||||
411
app/services/recipe/recipe_scanner.py
Normal file
411
app/services/recipe/recipe_scanner.py
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
"""Recipe scanner service (kiwi#9).
|
||||
|
||||
Extracts structured recipe data from one or more photos of recipe cards,
|
||||
cookbook pages, or handwritten notes.
|
||||
|
||||
Pipeline:
|
||||
photo(s) -> EXIF correction -> VLM extraction -> JSON parse -> pantry cross-ref
|
||||
|
||||
Vision backend priority (mirrors receipt OCR pattern):
|
||||
1. cf-orch vision service (if CF_ORCH_URL set)
|
||||
2. Local Qwen2.5-VL (if GPU available)
|
||||
3. Anthropic API (BYOK -- if ANTHROPIC_API_KEY set)
|
||||
|
||||
BSL 1.1 -- requires Paid tier or BYOK.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum number of photos per scan call (to limit VLM context / VRAM)
|
||||
MAX_IMAGES = 4
|
||||
|
||||
# VLM prompt -- adapted from tests/fixtures/recipe_scan/extract_test.py
|
||||
_EXTRACTION_PROMPT = """
|
||||
You are extracting a recipe from a photograph of a recipe card, cookbook page, or handwritten note.
|
||||
|
||||
If two or more images are provided, treat them as a single recipe across multiple pages
|
||||
(e.g. ingredients on page 1, directions on page 2).
|
||||
|
||||
Return a single JSON object with these fields:
|
||||
- title: recipe name (string)
|
||||
- subtitle: any secondary title or serving suggestion e.g. "with Broccoli & Ranch Dressing" (string or null)
|
||||
- servings: serving size if shown, as a string e.g. "2", "4-6" (string or null)
|
||||
- cook_time: total cook time if shown, e.g. "15 min", "1 hour" (string or null)
|
||||
- source_note: any attribution text like "From Betty Crocker" or "Purple Carrot" (string or null)
|
||||
- ingredients: array of ingredient objects, each with:
|
||||
- name: normalized generic ingredient name, lowercase, no quantities, no brand names
|
||||
(e.g. "Follow Your Heart Vegan Ranch" becomes "ranch dressing")
|
||||
- qty: quantity as a string, preserving fractions e.g. "1/2", a quarter symbol (string or null)
|
||||
- unit: unit of measure, null for countable items (e.g. "3 eggs" has unit: null)
|
||||
- raw: the original ingredient line verbatim, exactly as it appears
|
||||
- steps: ordered array of instruction strings, one distinct step per element
|
||||
- notes: any tips, substitutions, storage instructions, or variations (string or null)
|
||||
- confidence: "high" if text is clear and complete, "medium" if some parts are uncertain,
|
||||
"low" if mostly handwritten or significantly degraded
|
||||
- warnings: array of strings describing anything the user should double-check
|
||||
(e.g. "Directions appear to continue on another page not shown")
|
||||
|
||||
Return only valid JSON. No markdown fences. No explanation outside the JSON.
|
||||
If the image does not appear to be a recipe at all, return: {"error": "not_a_recipe"}
|
||||
""".strip()
|
||||
|
||||
|
||||
# ── Data types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class ScannedIngredient:
|
||||
name: str
|
||||
qty: str | None = None
|
||||
unit: str | None = None
|
||||
raw: str | None = None
|
||||
in_pantry: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScannedRecipeResult:
|
||||
title: str | None
|
||||
subtitle: str | None
|
||||
servings: str | None
|
||||
cook_time: str | None
|
||||
source_note: str | None
|
||||
ingredients: list[ScannedIngredient]
|
||||
steps: list[str]
|
||||
notes: str | None
|
||||
tags: list[str]
|
||||
pantry_match_pct: int
|
||||
confidence: str
|
||||
warnings: list[str]
|
||||
|
||||
|
||||
# ── Image helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_image_b64(path: Path) -> str:
|
||||
"""Load image, apply EXIF rotation, return base64-encoded JPEG bytes."""
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read()
|
||||
img = Image.open(io.BytesIO(raw))
|
||||
img = ImageOps.exif_transpose(img).convert("RGB")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=90)
|
||||
return base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
|
||||
# ── Vision backend ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _call_via_anthropic(image_paths: list[Path], prompt: str) -> str:
|
||||
"""Send image(s) + prompt to Anthropic API. Raises RuntimeError if unavailable."""
|
||||
try:
|
||||
import anthropic
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("anthropic package not installed") from exc
|
||||
|
||||
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
if not api_key:
|
||||
raise RuntimeError("ANTHROPIC_API_KEY not set")
|
||||
|
||||
client = anthropic.Anthropic(api_key=api_key)
|
||||
|
||||
content: list[dict] = []
|
||||
for i, path in enumerate(image_paths):
|
||||
if i > 0:
|
||||
content.append({"type": "text", "text": f"(Page {i + 1} of the same recipe:)"})
|
||||
content.append({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/jpeg",
|
||||
"data": _load_image_b64(path),
|
||||
},
|
||||
})
|
||||
content.append({"type": "text", "text": prompt})
|
||||
|
||||
msg = client.messages.create(
|
||||
# Haiku is cost-efficient for well-structured extraction prompts
|
||||
model="claude-haiku-4-5-20251001",
|
||||
max_tokens=2048,
|
||||
messages=[{"role": "user", "content": content}],
|
||||
)
|
||||
return msg.content[0].text.strip()
|
||||
|
||||
|
||||
def _call_via_local_vlm(image_paths: list[Path], prompt: str) -> str:
|
||||
"""Send image(s) + prompt to local Qwen2.5-VL. Raises RuntimeError if unavailable."""
|
||||
try:
|
||||
import torch
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("torch not installed") from exc
|
||||
|
||||
if not torch.cuda.is_available():
|
||||
raise RuntimeError("No CUDA device -- local VLM unavailable")
|
||||
|
||||
# Lazy import so the module loads fast when GPU is absent
|
||||
from transformers import Qwen2VLForConditionalGeneration, AutoProcessor
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
model_name = "Qwen/Qwen2.5-VL-7B-Instruct"
|
||||
logger.info("Loading local VLM for recipe scan: %s", model_name)
|
||||
|
||||
model = Qwen2VLForConditionalGeneration.from_pretrained(
|
||||
model_name,
|
||||
torch_dtype=torch.float16,
|
||||
device_map="auto",
|
||||
low_cpu_mem_usage=True,
|
||||
)
|
||||
processor = AutoProcessor.from_pretrained(model_name)
|
||||
model.train(False) # inference mode
|
||||
|
||||
images = []
|
||||
for path in image_paths:
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read()
|
||||
img = Image.open(io.BytesIO(raw))
|
||||
img = ImageOps.exif_transpose(img).convert("RGB")
|
||||
images.append(img)
|
||||
|
||||
inputs = processor(images=images, text=prompt, return_tensors="pt")
|
||||
inputs = {k: v.to("cuda", torch.float16) if isinstance(v, torch.Tensor) else v
|
||||
for k, v in inputs.items()}
|
||||
|
||||
with torch.no_grad():
|
||||
output_ids = model.generate(
|
||||
**inputs,
|
||||
max_new_tokens=2048,
|
||||
do_sample=False,
|
||||
temperature=0.0,
|
||||
)
|
||||
|
||||
output = processor.decode(output_ids[0], skip_special_tokens=True)
|
||||
output = output.replace(prompt, "").strip()
|
||||
|
||||
# Free VRAM
|
||||
del model
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def _call_vision_backend(image_paths: list[Path], prompt: str) -> str:
|
||||
"""Dispatch to the best available vision backend.
|
||||
|
||||
Priority: cf-orch vision -> local Qwen2.5-VL -> Anthropic API.
|
||||
Raises RuntimeError with a clear message when no backend is available.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
# 1. Try cf-orch vision allocation
|
||||
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
||||
if cf_orch_url:
|
||||
try:
|
||||
from circuitforge_orch.client import CFOrchClient
|
||||
from app.services.ocr.docuvision_client import DocuvisionClient
|
||||
|
||||
client = CFOrchClient(cf_orch_url)
|
||||
with client.allocate(
|
||||
service="cf-vision",
|
||||
model_candidates=["qwen2.5-vl-7b", "cf-docuvision"],
|
||||
ttl_s=90.0,
|
||||
caller="kiwi-recipe-scan",
|
||||
) as alloc:
|
||||
if alloc is not None:
|
||||
doc_client = DocuvisionClient(alloc.url)
|
||||
# docuvision takes a single image -- use first image only for now
|
||||
result = doc_client.extract_text(image_paths[0])
|
||||
if result.text:
|
||||
return result.text
|
||||
except Exception as exc:
|
||||
logger.debug("cf-orch vision failed for recipe scan: %s", exc)
|
||||
errors.append(f"cf-orch: {exc}")
|
||||
|
||||
# 2. Try local Qwen2.5-VL
|
||||
try:
|
||||
return _call_via_local_vlm(image_paths, prompt)
|
||||
except Exception as exc:
|
||||
logger.debug("Local VLM unavailable for recipe scan: %s", exc)
|
||||
errors.append(f"local VLM: {exc}")
|
||||
|
||||
# 3. Try Anthropic API (BYOK)
|
||||
try:
|
||||
return _call_via_anthropic(image_paths, prompt)
|
||||
except Exception as exc:
|
||||
logger.debug("Anthropic API failed for recipe scan: %s", exc)
|
||||
errors.append(f"Anthropic: {exc}")
|
||||
|
||||
raise RuntimeError(
|
||||
"No vision backend configured for recipe scanning. "
|
||||
"Options: cf-orch (CF_ORCH_URL), local GPU, or ANTHROPIC_API_KEY (BYOK). "
|
||||
f"Errors: {'; '.join(errors)}"
|
||||
)
|
||||
|
||||
|
||||
# ── Parsing helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def _normalize_ingredient_name(name: str) -> str:
|
||||
"""Lowercase + strip whitespace. Preserves multi-word names as-is."""
|
||||
return name.lower().strip()
|
||||
|
||||
|
||||
def _parse_scanner_json(raw_text: str) -> dict:
|
||||
"""Extract and return the JSON dict from VLM output.
|
||||
|
||||
Handles:
|
||||
- Pure JSON
|
||||
- JSON wrapped in ```json ... ``` markdown fences
|
||||
- JSON preceded by a line of prose ("Here is the recipe: {...}")
|
||||
|
||||
Raises ValueError on not_a_recipe or unparseable output.
|
||||
"""
|
||||
text = raw_text.strip()
|
||||
|
||||
# Strip markdown fences if present
|
||||
if text.startswith("```"):
|
||||
parts = text.split("```")
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if part.startswith("json"):
|
||||
part = part[4:].strip()
|
||||
if part.startswith("{"):
|
||||
text = part
|
||||
break
|
||||
|
||||
# Try direct parse first
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
# Extract first JSON object embedded in prose
|
||||
match = re.search(r"\{.*\}", text, re.DOTALL)
|
||||
if not match:
|
||||
raise ValueError(f"Could not parse JSON from VLM output: {text[:200]!r}")
|
||||
try:
|
||||
data = json.loads(match.group(0))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Could not parse JSON from VLM output: {exc}") from exc
|
||||
|
||||
if isinstance(data, dict) and data.get("error") == "not_a_recipe":
|
||||
raise ValueError("not_a_recipe: image does not appear to contain a recipe")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
# ── Pantry cross-reference ─────────────────────────────────────────────────────
|
||||
|
||||
def _cross_reference_pantry(
|
||||
ingredients: list[ScannedIngredient],
|
||||
pantry_names: list[str],
|
||||
) -> tuple[list[ScannedIngredient], int]:
|
||||
"""Mark ingredients found in the pantry and return updated list + match percent.
|
||||
|
||||
Matching is bidirectional by token:
|
||||
- "broccoli florets" matches pantry item "broccoli" (pantry token in ingredient)
|
||||
- "pumpkin seeds" matches pantry "pumpkin seeds" (exact)
|
||||
|
||||
Returns (updated_ingredients, pantry_match_pct).
|
||||
"""
|
||||
if not ingredients:
|
||||
return ingredients, 0
|
||||
|
||||
normalized_pantry = [_normalize_ingredient_name(p) for p in pantry_names]
|
||||
updated: list[ScannedIngredient] = []
|
||||
matched = 0
|
||||
|
||||
for ingr in ingredients:
|
||||
norm_ingr = _normalize_ingredient_name(ingr.name)
|
||||
in_pantry = any(
|
||||
(p_tok in norm_ingr or norm_ingr in p_tok)
|
||||
for p in normalized_pantry
|
||||
for p_tok in p.split()
|
||||
if len(p_tok) >= 4 # skip short stop-words like "of", "and", "the"
|
||||
)
|
||||
updated.append(ScannedIngredient(
|
||||
name=ingr.name,
|
||||
qty=ingr.qty,
|
||||
unit=ingr.unit,
|
||||
raw=ingr.raw,
|
||||
in_pantry=in_pantry,
|
||||
))
|
||||
if in_pantry:
|
||||
matched += 1
|
||||
|
||||
pct = round(matched / len(ingredients) * 100)
|
||||
return updated, pct
|
||||
|
||||
|
||||
# ── Main scanner class ─────────────────────────────────────────────────────────
|
||||
|
||||
class RecipeScanner:
|
||||
"""Stateless recipe scanner. One instance can be reused across requests."""
|
||||
|
||||
def scan(
|
||||
self,
|
||||
image_paths: list[Path],
|
||||
pantry_names: list[str] | None = None,
|
||||
) -> ScannedRecipeResult:
|
||||
"""Extract a structured recipe from one or more photos.
|
||||
|
||||
Args:
|
||||
image_paths: 1-4 image files (phone photos, scans).
|
||||
pantry_names: Flat list of product names from user's inventory.
|
||||
Pass [] or None to skip pantry cross-reference.
|
||||
|
||||
Returns:
|
||||
ScannedRecipeResult with all fields populated.
|
||||
|
||||
Raises:
|
||||
ValueError: Image is not a recipe, or JSON could not be parsed.
|
||||
RuntimeError: No vision backend is configured.
|
||||
"""
|
||||
if not image_paths:
|
||||
raise ValueError("At least one image is required")
|
||||
if len(image_paths) > MAX_IMAGES:
|
||||
raise ValueError(f"Maximum {MAX_IMAGES} images per scan (got {len(image_paths)})")
|
||||
|
||||
# Call vision backend
|
||||
raw_text = _call_vision_backend(image_paths, _EXTRACTION_PROMPT)
|
||||
|
||||
# Parse JSON from VLM output
|
||||
data = _parse_scanner_json(raw_text)
|
||||
|
||||
# Build ingredient list
|
||||
raw_ingredients = data.get("ingredients") or []
|
||||
ingredients: list[ScannedIngredient] = [
|
||||
ScannedIngredient(
|
||||
name=str(item.get("name") or "").strip() or "unknown",
|
||||
qty=str(item["qty"]) if item.get("qty") is not None else None,
|
||||
unit=str(item["unit"]) if item.get("unit") is not None else None,
|
||||
raw=str(item["raw"]) if item.get("raw") is not None else None,
|
||||
)
|
||||
for item in raw_ingredients
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
|
||||
# Pantry cross-reference
|
||||
ingredients, pct = _cross_reference_pantry(
|
||||
ingredients,
|
||||
pantry_names or [],
|
||||
)
|
||||
|
||||
return ScannedRecipeResult(
|
||||
title=data.get("title") or None,
|
||||
subtitle=data.get("subtitle") or None,
|
||||
servings=str(data["servings"]) if data.get("servings") is not None else None,
|
||||
cook_time=str(data["cook_time"]) if data.get("cook_time") is not None else None,
|
||||
source_note=data.get("source_note") or None,
|
||||
ingredients=ingredients,
|
||||
steps=[str(s) for s in (data.get("steps") or []) if s],
|
||||
notes=data.get("notes") or None,
|
||||
tags=list(data.get("tags") or []),
|
||||
pantry_match_pct=pct,
|
||||
confidence=data.get("confidence") or "medium",
|
||||
warnings=list(data.get("warnings") or []),
|
||||
)
|
||||
|
|
@ -15,6 +15,7 @@ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
|
|||
"recipe_suggestions",
|
||||
"expiry_llm_matching",
|
||||
"receipt_ocr",
|
||||
"recipe_scan",
|
||||
"style_classifier",
|
||||
"meal_plan_llm",
|
||||
"meal_plan_llm_timing",
|
||||
|
|
@ -58,6 +59,9 @@ KIWI_FEATURES: dict[str, str] = {
|
|||
"community_publish": "paid", # Publish plans/outcomes to community feed
|
||||
"community_fork_adapt": "paid", # Fork with LLM pantry adaptation (BYOK-unlockable)
|
||||
|
||||
# Paid tier (continued)
|
||||
"recipe_scan": "paid", # BYOK-unlockable: photo -> structured recipe
|
||||
|
||||
# Premium tier
|
||||
"multi_household": "premium",
|
||||
"background_monitoring": "premium",
|
||||
|
|
|
|||
844
frontend/src/components/RecipeScanModal.vue
Normal file
844
frontend/src/components/RecipeScanModal.vue
Normal file
|
|
@ -0,0 +1,844 @@
|
|||
<template>
|
||||
<div class="modal-overlay" @click.self="close" role="dialog" aria-modal="true" :aria-labelledby="titleId">
|
||||
<div class="modal-panel scan-modal">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="modal-header">
|
||||
<h2 :id="titleId" class="modal-title">
|
||||
<span v-if="phase === 'upload'">Scan a Recipe</span>
|
||||
<span v-else-if="phase === 'processing'">Scanning...</span>
|
||||
<span v-else>Review Recipe</span>
|
||||
</h2>
|
||||
<button class="btn-icon close-btn" @click="close" aria-label="Close">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Upload phase ── -->
|
||||
<div v-if="phase === 'upload'" class="modal-body">
|
||||
<p class="hint-text">
|
||||
Photograph a recipe card, cookbook page, or handwritten note.
|
||||
For multi-page recipes (ingredients on one page, directions on another)
|
||||
select both photos together — up to 4 images.
|
||||
</p>
|
||||
|
||||
<!-- Drop zone -->
|
||||
<div
|
||||
class="drop-zone"
|
||||
:class="{ 'drop-zone-active': isDragging, 'has-files': selectedFiles.length > 0 }"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave="isDragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
@click="fileInput?.click()"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@keydown.enter.space="fileInput?.click()"
|
||||
aria-label="Click or drop photos here"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/jpeg,image/jpg,image/png,image/webp,image/heic,image/heif"
|
||||
multiple
|
||||
class="hidden-input"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
|
||||
<div v-if="selectedFiles.length === 0" class="drop-zone-empty">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="camera-icon">
|
||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
|
||||
<circle cx="12" cy="13" r="4"/>
|
||||
</svg>
|
||||
<p class="drop-zone-label">Tap or drop photos here</p>
|
||||
<p class="drop-zone-sub">JPEG, PNG, WebP, HEIC — up to 4 photos</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="file-preview-grid">
|
||||
<div
|
||||
v-for="(_file, i) in selectedFiles"
|
||||
:key="i"
|
||||
class="file-preview-item"
|
||||
>
|
||||
<img :src="previewUrls[i]" :alt="`Photo ${i + 1}`" class="preview-img" />
|
||||
<button
|
||||
class="remove-file-btn"
|
||||
@click.stop="removeFile(i)"
|
||||
:aria-label="`Remove photo ${i + 1}`"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<p class="preview-label">Page {{ i + 1 }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedFiles.length < 4"
|
||||
class="file-preview-add"
|
||||
@click.stop="fileInput?.click()"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@keydown.enter.space.stop="fileInput?.click()"
|
||||
aria-label="Add another photo"
|
||||
>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="uploadError" class="status-badge status-error mt-sm" role="alert">
|
||||
{{ uploadError }}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="close">Cancel</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="selectedFiles.length === 0"
|
||||
@click="startScan"
|
||||
>
|
||||
Scan Recipe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Processing phase ── -->
|
||||
<div v-else-if="phase === 'processing'" class="modal-body processing-body">
|
||||
<div class="scan-spinner" aria-live="polite" aria-label="Scanning recipe">
|
||||
<svg class="spin-icon" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
|
||||
<circle cx="12" cy="13" r="4"/>
|
||||
</svg>
|
||||
<p class="processing-label">Extracting recipe from {{ selectedFiles.length > 1 ? selectedFiles.length + ' photos' : 'photo' }}...</p>
|
||||
<p class="processing-sub">This can take 10-30 seconds.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Review phase ── -->
|
||||
<div v-else-if="phase === 'review' && extracted" class="modal-body review-body">
|
||||
|
||||
<!-- Confidence banner -->
|
||||
<div
|
||||
v-if="extracted.confidence !== 'high' || extracted.warnings.length > 0"
|
||||
:class="['status-badge', extracted.confidence === 'low' ? 'status-warning' : 'status-info', 'mb-sm']"
|
||||
role="status"
|
||||
>
|
||||
<span v-if="extracted.confidence === 'low'">Low confidence scan — handwritten or degraded text. Please review carefully.</span>
|
||||
<span v-else>Medium confidence. Check the fields below.</span>
|
||||
<ul v-if="extracted.warnings.length > 0" class="warning-list">
|
||||
<li v-for="w in extracted.warnings" :key="w">{{ w }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Pantry match badge -->
|
||||
<div v-if="extracted.ingredients.length > 0" class="pantry-match-row mb-sm">
|
||||
<span class="pantry-badge" :class="pantryMatchClass">
|
||||
{{ extracted.pantry_match_pct }}% pantry match
|
||||
({{ pantryCount }} of {{ extracted.ingredients.length }} ingredients on hand)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Editable fields -->
|
||||
<div class="review-form">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="scan-title">Recipe name</label>
|
||||
<input
|
||||
id="scan-title"
|
||||
v-model="editTitle"
|
||||
class="form-input"
|
||||
type="text"
|
||||
placeholder="Recipe name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-row-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="scan-servings">Servings</label>
|
||||
<input id="scan-servings" v-model="editServings" class="form-input" type="text" placeholder="e.g. 2" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="scan-cooktime">Cook time</label>
|
||||
<input id="scan-cooktime" v-model="editCookTime" class="form-input" type="text" placeholder="e.g. 25 min" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ingredients -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">Ingredients</label>
|
||||
<div class="ingredient-list">
|
||||
<div
|
||||
v-for="(ingr, i) in editIngredients"
|
||||
:key="i"
|
||||
:class="['ingredient-row', ingr.in_pantry ? 'in-pantry' : '']"
|
||||
>
|
||||
<span v-if="ingr.in_pantry" class="pantry-dot" title="In your pantry" aria-label="In pantry"></span>
|
||||
<input
|
||||
v-model="ingr.qty"
|
||||
class="form-input ingr-qty"
|
||||
type="text"
|
||||
placeholder="qty"
|
||||
:aria-label="`Ingredient ${i + 1} quantity`"
|
||||
/>
|
||||
<input
|
||||
v-model="ingr.unit"
|
||||
class="form-input ingr-unit"
|
||||
type="text"
|
||||
placeholder="unit"
|
||||
:aria-label="`Ingredient ${i + 1} unit`"
|
||||
/>
|
||||
<input
|
||||
v-model="ingr.name"
|
||||
class="form-input ingr-name"
|
||||
type="text"
|
||||
placeholder="ingredient"
|
||||
:aria-label="`Ingredient ${i + 1} name`"
|
||||
/>
|
||||
<button
|
||||
class="btn-icon remove-ingr-btn"
|
||||
@click="removeIngredient(i)"
|
||||
:aria-label="`Remove ingredient ${i + 1}`"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm mt-xs" @click="addIngredient">+ Add ingredient</button>
|
||||
</div>
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">Steps</label>
|
||||
<div class="step-list">
|
||||
<div v-for="(_step, i) in editSteps" :key="i" class="step-row">
|
||||
<span class="step-num">{{ i + 1 }}</span>
|
||||
<textarea
|
||||
v-model="editSteps[i]"
|
||||
class="form-input step-textarea"
|
||||
rows="2"
|
||||
:aria-label="`Step ${i + 1}`"
|
||||
></textarea>
|
||||
<button
|
||||
class="btn-icon remove-step-btn"
|
||||
@click="removeStep(i)"
|
||||
:aria-label="`Remove step ${i + 1}`"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm mt-xs" @click="addStep">+ Add step</button>
|
||||
</div>
|
||||
|
||||
<!-- Notes (optional) -->
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="scan-notes">Notes <span class="optional-label">(optional)</span></label>
|
||||
<textarea id="scan-notes" v-model="editNotes" class="form-input" rows="2" placeholder="Tips, variations, storage..."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Source attribution -->
|
||||
<div v-if="extracted.source_note" class="source-note">
|
||||
Source: {{ extracted.source_note }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div v-if="saveError" class="status-badge status-error mt-sm" role="alert">
|
||||
{{ saveError }}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="phase = 'upload'">Re-scan</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="!editTitle.trim() || saving"
|
||||
@click="save"
|
||||
>
|
||||
{{ saving ? 'Saving...' : 'Save Recipe' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onBeforeUnmount } from 'vue'
|
||||
import { type ScannedRecipe, type ScannedIngredient, recipeScanAPI } from '@/services/api'
|
||||
|
||||
type Phase = 'upload' | 'processing' | 'review'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'saved', recipe: { id: number; title: string }): void
|
||||
}>()
|
||||
|
||||
const titleId = 'scan-modal-title'
|
||||
|
||||
// ── Upload state ──────────────────────────────────────────────────────────────
|
||||
const phase = ref<Phase>('upload')
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const selectedFiles = ref<File[]>([])
|
||||
const previewUrls = ref<string[]>([])
|
||||
const isDragging = ref(false)
|
||||
const uploadError = ref('')
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
isDragging.value = false
|
||||
const dt = e.dataTransfer
|
||||
if (!dt) return
|
||||
addFiles(Array.from(dt.files))
|
||||
}
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (!input.files) return
|
||||
addFiles(Array.from(input.files))
|
||||
// Reset so the same file can be re-selected after removal
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function addFiles(incoming: File[]) {
|
||||
uploadError.value = ''
|
||||
const combined = [...selectedFiles.value, ...incoming]
|
||||
if (combined.length > 4) {
|
||||
uploadError.value = 'Maximum 4 photos per scan.'
|
||||
return
|
||||
}
|
||||
// Revoke old preview URLs before replacing
|
||||
previewUrls.value.forEach((url) => URL.revokeObjectURL(url))
|
||||
selectedFiles.value = combined
|
||||
previewUrls.value = combined.map((f) => URL.createObjectURL(f))
|
||||
}
|
||||
|
||||
function removeFile(index: number) {
|
||||
URL.revokeObjectURL(previewUrls.value[index] ?? '')
|
||||
selectedFiles.value = selectedFiles.value.filter((_, i) => i !== index)
|
||||
previewUrls.value = previewUrls.value.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
// ── Scan ──────────────────────────────────────────────────────────────────────
|
||||
const extracted = ref<ScannedRecipe | null>(null)
|
||||
|
||||
async function startScan() {
|
||||
if (selectedFiles.value.length === 0) return
|
||||
uploadError.value = ''
|
||||
phase.value = 'processing'
|
||||
try {
|
||||
const result = await recipeScanAPI.scan(selectedFiles.value)
|
||||
extracted.value = result
|
||||
initEditState(result)
|
||||
phase.value = 'review'
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
uploadError.value = msg.includes('not appear to contain a recipe')
|
||||
? 'This photo does not look like a recipe. Please try a different photo.'
|
||||
: msg.includes('No vision backend')
|
||||
? 'Recipe scanning is not available right now. Check your BYOK settings.'
|
||||
: `Scan failed: ${msg}`
|
||||
phase.value = 'upload'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Review/edit state ─────────────────────────────────────────────────────────
|
||||
const editTitle = ref('')
|
||||
const editServings = ref('')
|
||||
const editCookTime = ref('')
|
||||
const editIngredients = ref<ScannedIngredient[]>([])
|
||||
const editSteps = ref<string[]>([])
|
||||
const editNotes = ref('')
|
||||
|
||||
function initEditState(r: ScannedRecipe) {
|
||||
editTitle.value = r.title ?? ''
|
||||
editServings.value = r.servings ?? ''
|
||||
editCookTime.value = r.cook_time ?? ''
|
||||
editIngredients.value = r.ingredients.map((i) => ({ ...i }))
|
||||
editSteps.value = [...r.steps]
|
||||
editNotes.value = r.notes ?? ''
|
||||
}
|
||||
|
||||
function removeIngredient(i: number) {
|
||||
editIngredients.value = editIngredients.value.filter((_, idx) => idx !== i)
|
||||
}
|
||||
|
||||
function addIngredient() {
|
||||
editIngredients.value = [...editIngredients.value, { name: '', qty: null, unit: null, raw: null, in_pantry: false }]
|
||||
}
|
||||
|
||||
function removeStep(i: number) {
|
||||
editSteps.value = editSteps.value.filter((_, idx) => idx !== i)
|
||||
}
|
||||
|
||||
function addStep() {
|
||||
editSteps.value = [...editSteps.value, '']
|
||||
}
|
||||
|
||||
// ── Pantry match display ──────────────────────────────────────────────────────
|
||||
const pantryCount = computed(() =>
|
||||
editIngredients.value.filter((i) => i.in_pantry).length
|
||||
)
|
||||
|
||||
const pantryMatchClass = computed(() => {
|
||||
const pct = extracted.value?.pantry_match_pct ?? 0
|
||||
if (pct >= 80) return 'pantry-high'
|
||||
if (pct >= 50) return 'pantry-mid'
|
||||
return 'pantry-low'
|
||||
})
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────────────────────
|
||||
const saving = ref(false)
|
||||
const saveError = ref('')
|
||||
|
||||
async function save() {
|
||||
if (!editTitle.value.trim()) return
|
||||
saving.value = true
|
||||
saveError.value = ''
|
||||
try {
|
||||
const payload = {
|
||||
title: editTitle.value.trim(),
|
||||
subtitle: extracted.value?.subtitle ?? null,
|
||||
servings: editServings.value || null,
|
||||
cook_time: editCookTime.value || null,
|
||||
source_note: extracted.value?.source_note ?? null,
|
||||
ingredients: editIngredients.value.filter((i) => i.name.trim()),
|
||||
steps: editSteps.value.filter((s) => s.trim()),
|
||||
notes: editNotes.value.trim() || null,
|
||||
tags: extracted.value?.tags ?? [],
|
||||
source: 'scan' as const,
|
||||
}
|
||||
const saved = await recipeScanAPI.saveScanned(payload)
|
||||
emit('saved', { id: saved.id, title: saved.title })
|
||||
close()
|
||||
} catch (err: unknown) {
|
||||
saveError.value = err instanceof Error ? err.message : 'Failed to save recipe.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cleanup ───────────────────────────────────────────────────────────────────
|
||||
function close() {
|
||||
previewUrls.value.forEach((url) => URL.revokeObjectURL(url))
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
previewUrls.value.forEach((url) => URL.revokeObjectURL(url))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal, 1000);
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.modal-panel {
|
||||
background: var(--bg-card, #fff);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
box-shadow: var(--shadow-xl, 0 20px 60px rgba(0,0,0,0.2));
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: var(--font-lg, 1.125rem);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #111);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--bg-hover, #f3f4f6);
|
||||
color: var(--text-primary, #111);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--spacing-lg);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-sm);
|
||||
padding-top: var(--spacing-md);
|
||||
border-top: 1px solid var(--border-color, #e5e7eb);
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* ── Upload ── */
|
||||
.hint-text {
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
margin-bottom: var(--spacing-md);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
border: 2px dashed var(--border-color, #d1d5db);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: var(--spacing-xl);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.drop-zone:hover,
|
||||
.drop-zone-active {
|
||||
border-color: var(--color-primary, #4f46e5);
|
||||
background: var(--bg-hover, #f5f3ff);
|
||||
}
|
||||
|
||||
.drop-zone.has-files {
|
||||
border-style: solid;
|
||||
border-color: var(--color-primary, #4f46e5);
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drop-zone-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.camera-icon {
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.drop-zone-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #111);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.drop-zone-sub {
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.file-preview-grid {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-preview-item {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
}
|
||||
|
||||
.remove-file-btn {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
background: var(--color-danger, #ef4444);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
text-align: center;
|
||||
font-size: var(--font-xs, 0.75rem);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.file-preview-add {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border: 2px dashed var(--border-color, #d1d5db);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.file-preview-add:hover {
|
||||
border-color: var(--color-primary, #4f46e5);
|
||||
color: var(--color-primary, #4f46e5);
|
||||
}
|
||||
|
||||
/* ── Processing ── */
|
||||
.processing-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.scan-spinner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.spin-icon {
|
||||
color: var(--color-primary, #4f46e5);
|
||||
animation: spin 1.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.processing-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #111);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.processing-sub {
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Review ── */
|
||||
.review-body {
|
||||
padding-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.pantry-match-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pantry-badge {
|
||||
display: inline-block;
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.pantry-high { background: var(--color-success-bg, #d1fae5); color: var(--color-success, #065f46); }
|
||||
.pantry-mid { background: var(--color-info-bg, #dbeafe); color: var(--color-info, #1e40af); }
|
||||
.pantry-low { background: var(--bg-secondary, #f3f4f6); color: var(--text-secondary, #374151); }
|
||||
|
||||
.review-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.form-row-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Ingredients */
|
||||
.ingredient-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ingredient-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pantry-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-success, #10b981);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.in-pantry {
|
||||
background: var(--color-success-bg-faint, #f0fdf4);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.ingr-qty { width: 60px; flex-shrink: 0; }
|
||||
.ingr-unit { width: 70px; flex-shrink: 0; }
|
||||
.ingr-name { flex: 1; }
|
||||
|
||||
.remove-ingr-btn,
|
||||
.remove-step-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.remove-ingr-btn:hover,
|
||||
.remove-step-btn:hover {
|
||||
background: var(--color-danger-bg, #fee2e2);
|
||||
color: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
/* Steps */
|
||||
.step-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.step-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-secondary, #f3f4f6);
|
||||
color: var(--text-secondary, #374151);
|
||||
font-size: var(--font-xs, 0.75rem);
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.step-textarea {
|
||||
flex: 1;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
/* Source */
|
||||
.source-note {
|
||||
font-size: var(--font-xs, 0.75rem);
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
text-align: right;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.optional-label {
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
font-weight: normal;
|
||||
font-size: var(--font-xs, 0.75rem);
|
||||
}
|
||||
|
||||
.warning-list {
|
||||
margin: 4px 0 0;
|
||||
padding-left: 16px;
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-primary, #4f46e5);
|
||||
padding: 4px 8px;
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--bg-hover, #f5f3ff);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: var(--font-sm, 0.875rem);
|
||||
}
|
||||
|
||||
.mt-xs { margin-top: var(--spacing-xs, 4px); }
|
||||
.mt-sm { margin-top: var(--spacing-sm, 8px); }
|
||||
.mb-sm { margin-bottom: var(--spacing-sm, 8px); }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.form-row-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.modal-panel {
|
||||
border-radius: var(--radius-md, 8px);
|
||||
max-height: 95vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
<template>
|
||||
<div class="recipes-view">
|
||||
|
||||
<!-- Tab bar: Find / Browse / Saved -->
|
||||
<div role="tablist" aria-label="Recipe sections" class="tab-bar flex gap-xs mb-md">
|
||||
<!-- Tab bar: Find / Browse / Saved + Scan action button -->
|
||||
<div class="tab-bar-row flex gap-xs mb-md" style="align-items:center;">
|
||||
<div role="tablist" aria-label="Recipe sections" class="flex gap-xs" style="flex:1;flex-wrap:wrap;">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
|
|
@ -15,6 +16,37 @@
|
|||
@keydown="onTabKeydown"
|
||||
>{{ tab.label }}</button>
|
||||
</div>
|
||||
<!-- Scan recipe button — opens modal to photograph a recipe card -->
|
||||
<button
|
||||
class="btn btn-secondary scan-btn"
|
||||
@click="scanModalOpen = true"
|
||||
title="Scan a recipe card or cookbook page"
|
||||
aria-label="Scan a recipe"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
|
||||
<circle cx="12" cy="13" r="4"/>
|
||||
</svg>
|
||||
Scan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scan success toast -->
|
||||
<div
|
||||
v-if="lastScannedTitle"
|
||||
class="undo-toast"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
Saved "{{ lastScannedTitle }}" to your recipes.
|
||||
</div>
|
||||
|
||||
<!-- Scan modal -->
|
||||
<RecipeScanModal
|
||||
v-if="scanModalOpen"
|
||||
@close="scanModalOpen = false"
|
||||
@saved="onScanSaved"
|
||||
/>
|
||||
|
||||
<!-- Browse tab -->
|
||||
<RecipeBrowserPanel
|
||||
|
|
@ -746,10 +778,22 @@ import SavedRecipesPanel from './SavedRecipesPanel.vue'
|
|||
import CommunityFeedPanel from './CommunityFeedPanel.vue'
|
||||
import BuildYourOwnTab from './BuildYourOwnTab.vue'
|
||||
import OrchUsagePill from './OrchUsagePill.vue'
|
||||
import RecipeScanModal from './RecipeScanModal.vue'
|
||||
import type { ForkResult } from '../stores/community'
|
||||
import type { RecipeSuggestion, GroceryLink, StreamTokenResponse } from '../services/api'
|
||||
import { recipesAPI } from '../services/api'
|
||||
|
||||
// ── Scan modal ────────────────────────────────────────────────────────────────
|
||||
const scanModalOpen = ref(false)
|
||||
const lastScannedTitle = ref<string | null>(null)
|
||||
|
||||
function onScanSaved(recipe: { id: number; title: string }) {
|
||||
lastScannedTitle.value = recipe.title
|
||||
scanModalOpen.value = false
|
||||
// Dismiss the toast after 4 seconds
|
||||
setTimeout(() => { lastScannedTitle.value = null }, 4000)
|
||||
}
|
||||
|
||||
// Streaming state
|
||||
const isStreaming = ref(false)
|
||||
const streamChunks = ref('')
|
||||
|
|
|
|||
|
|
@ -1204,4 +1204,77 @@ export const DEFAULT_SENSORY_PREFERENCES: SensoryPreferences = {
|
|||
max_noise: null,
|
||||
}
|
||||
|
||||
// ── Recipe Scanner (kiwi#9) ───────────────────────────────────────────────────
|
||||
|
||||
export interface ScannedIngredient {
|
||||
name: string
|
||||
qty: string | null
|
||||
unit: string | null
|
||||
raw: string | null
|
||||
in_pantry: boolean
|
||||
}
|
||||
|
||||
export interface ScannedRecipe {
|
||||
title: string | null
|
||||
subtitle: string | null
|
||||
servings: string | null
|
||||
cook_time: string | null
|
||||
source_note: string | null
|
||||
ingredients: ScannedIngredient[]
|
||||
steps: string[]
|
||||
notes: string | null
|
||||
tags: string[]
|
||||
pantry_match_pct: number
|
||||
confidence: 'high' | 'medium' | 'low'
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface UserRecipe {
|
||||
id: number
|
||||
title: string
|
||||
subtitle: string | null
|
||||
servings: string | null
|
||||
cook_time: string | null
|
||||
source_note: string | null
|
||||
ingredients: ScannedIngredient[]
|
||||
steps: string[]
|
||||
notes: string | null
|
||||
tags: string[]
|
||||
source: string
|
||||
pantry_match_pct: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export const recipeScanAPI = {
|
||||
/** Scan 1-4 recipe photos. Returns structured recipe for review (not saved). */
|
||||
scan(files: File[]): Promise<ScannedRecipe> {
|
||||
const form = new FormData()
|
||||
files.forEach((f) => form.append('files', f))
|
||||
return api.post('/recipes/scan', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 120_000, // VLM can be slow on first call
|
||||
}).then((r) => r.data)
|
||||
},
|
||||
|
||||
/** Save a reviewed/edited scanned recipe to user_recipes. */
|
||||
saveScanned(recipe: Omit<ScannedRecipe, 'pantry_match_pct' | 'confidence' | 'warnings'> & { source?: string }): Promise<UserRecipe> {
|
||||
return api.post('/recipes/scan/save', recipe).then((r) => r.data)
|
||||
},
|
||||
|
||||
/** List all user-created recipes (scan + manual). */
|
||||
listUserRecipes(): Promise<UserRecipe[]> {
|
||||
return api.get('/recipes/user').then((r) => r.data)
|
||||
},
|
||||
|
||||
/** Get a single user recipe by ID. */
|
||||
getUserRecipe(id: number): Promise<UserRecipe> {
|
||||
return api.get(`/recipes/user/${id}`).then((r) => r.data)
|
||||
},
|
||||
|
||||
/** Delete a user recipe. */
|
||||
deleteUserRecipe(id: number): Promise<void> {
|
||||
return api.delete(`/recipes/user/${id}`).then(() => undefined)
|
||||
},
|
||||
}
|
||||
|
||||
export default api
|
||||
|
|
|
|||
304
tests/api/test_recipe_scan.py
Normal file
304
tests/api/test_recipe_scan.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
"""API tests for recipe scan endpoints (kiwi#9).
|
||||
|
||||
VLM calls are mocked at the service level -- no GPU or API key needed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.cloud_session import get_session
|
||||
from app.db.session import get_store
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
_GOOD_SCAN_JSON = {
|
||||
"title": "Green Goddess Bowls",
|
||||
"subtitle": "with Broccoli & Ranch Dressing",
|
||||
"servings": "2",
|
||||
"cook_time": "15 min",
|
||||
"source_note": "Purple Carrot",
|
||||
"ingredients": [
|
||||
{"name": "brown rice", "qty": "1/2", "unit": "cup", "raw": "1/2 cup brown rice"},
|
||||
{"name": "broccoli florets", "qty": "8", "unit": "oz", "raw": "8 oz broccoli florets"},
|
||||
{"name": "avocado", "qty": "1", "unit": None, "raw": "1 avocado"},
|
||||
],
|
||||
"steps": ["Cook rice.", "Steam broccoli.", "Assemble bowls."],
|
||||
"notes": None,
|
||||
"confidence": "high",
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
|
||||
def _make_session(tier: str = "paid", has_byok: bool = False) -> MagicMock:
|
||||
mock = MagicMock()
|
||||
mock.tier = tier
|
||||
mock.has_byok = has_byok
|
||||
mock.db = ":memory:"
|
||||
return mock
|
||||
|
||||
|
||||
def _make_store() -> MagicMock:
|
||||
mock = MagicMock()
|
||||
mock.list_inventory.return_value = [
|
||||
{"product_name": "brown rice"},
|
||||
{"product_name": "avocado"},
|
||||
]
|
||||
mock.create_user_recipe.return_value = {
|
||||
"id": 1,
|
||||
"title": "Green Goddess Bowls",
|
||||
"subtitle": "with Broccoli & Ranch Dressing",
|
||||
"servings": "2",
|
||||
"cook_time": "15 min",
|
||||
"source_note": "Purple Carrot",
|
||||
"ingredients": _GOOD_SCAN_JSON["ingredients"],
|
||||
"steps": _GOOD_SCAN_JSON["steps"],
|
||||
"notes": None,
|
||||
"tags": [],
|
||||
"source": "scan",
|
||||
"pantry_match_pct": None,
|
||||
"created_at": "2026-04-27T00:00:00",
|
||||
}
|
||||
mock.list_user_recipes.return_value = []
|
||||
mock.get_user_recipe.return_value = None
|
||||
mock.delete_user_recipe.return_value = False
|
||||
return mock
|
||||
|
||||
|
||||
def _fake_image() -> bytes:
|
||||
return b"\xff\xd8\xff\xe0" + b"\x00" * 100 # minimal JPEG magic
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def override_deps():
|
||||
session_mock = _make_session()
|
||||
store_mock = _make_store()
|
||||
app.dependency_overrides[get_session] = lambda: session_mock
|
||||
app.dependency_overrides[get_store] = lambda: store_mock
|
||||
yield session_mock, store_mock
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ── POST /recipes/scan ─────────────────────────────────────────────────────────
|
||||
|
||||
def _make_scan_result(title: str = "Green Goddess Bowls"):
|
||||
"""Create a fake ScannedRecipeResult for tests."""
|
||||
from app.services.recipe.recipe_scanner import ScannedIngredient, ScannedRecipeResult
|
||||
return ScannedRecipeResult(
|
||||
title=title,
|
||||
subtitle="with Broccoli & Ranch Dressing",
|
||||
servings="2",
|
||||
cook_time="15 min",
|
||||
source_note="Purple Carrot",
|
||||
ingredients=[
|
||||
ScannedIngredient("brown rice", "1/2", "cup", in_pantry=True),
|
||||
ScannedIngredient("broccoli florets", "8", "oz"),
|
||||
ScannedIngredient("avocado", "1", None, in_pantry=True),
|
||||
],
|
||||
steps=["Cook rice.", "Steam broccoli.", "Assemble bowls."],
|
||||
notes=None,
|
||||
tags=[],
|
||||
pantry_match_pct=67,
|
||||
confidence="high",
|
||||
warnings=[],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_scan_infra(tmp_path):
|
||||
"""Patch file-saving and VLM calls so scan endpoint tests don't need disk or GPU."""
|
||||
fake_path = tmp_path / "recipe.jpg"
|
||||
fake_path.write_bytes(_fake_image())
|
||||
|
||||
async def _fake_save(upload_file):
|
||||
return fake_path
|
||||
|
||||
with patch("app.api.endpoints.recipe_scan._save_upload_temp", side_effect=_fake_save):
|
||||
with patch("app.api.endpoints.recipe_scan.asyncio.to_thread") as mock_thread:
|
||||
yield mock_thread, fake_path
|
||||
|
||||
|
||||
class TestScanEndpoint:
|
||||
def test_scan_returns_200(self, override_deps, mock_scan_infra):
|
||||
"""Happy path: paid tier, valid JPEG, VLM returns good JSON."""
|
||||
_, store_mock = override_deps
|
||||
mock_thread, _ = mock_scan_infra
|
||||
|
||||
scan_result = _make_scan_result()
|
||||
call_count = 0
|
||||
|
||||
def side_effect(fn, *args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return store_mock.list_inventory() if call_count == 1 else scan_result
|
||||
|
||||
mock_thread.side_effect = side_effect
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/recipes/scan",
|
||||
files=[("files", ("recipe.jpg", _fake_image(), "image/jpeg"))],
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["title"] == "Green Goddess Bowls"
|
||||
assert data["confidence"] == "high"
|
||||
assert data["pantry_match_pct"] == 67
|
||||
assert len(data["ingredients"]) == 3
|
||||
|
||||
def test_scan_requires_paid_tier(self, override_deps):
|
||||
"""Free tier without BYOK should get 403."""
|
||||
session_mock, _ = override_deps
|
||||
session_mock.tier = "free"
|
||||
session_mock.has_byok = False
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/recipes/scan",
|
||||
files=[("files", ("recipe.jpg", _fake_image(), "image/jpeg"))],
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_scan_byok_free_tier_allowed(self, override_deps, mock_scan_infra):
|
||||
"""Free tier WITH BYOK should be allowed through the tier gate."""
|
||||
session_mock, store_mock = override_deps
|
||||
session_mock.tier = "free"
|
||||
session_mock.has_byok = True
|
||||
|
||||
mock_thread, _ = mock_scan_infra
|
||||
scan_result = _make_scan_result("Simple Bowl")
|
||||
call_count = 0
|
||||
|
||||
def _side(fn, *a, **kw):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return store_mock.list_inventory() if call_count == 1 else scan_result
|
||||
|
||||
mock_thread.side_effect = _side
|
||||
resp = client.post(
|
||||
"/api/v1/recipes/scan",
|
||||
files=[("files", ("recipe.jpg", _fake_image(), "image/jpeg"))],
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_scan_no_files_rejected(self, override_deps):
|
||||
"""Missing files field returns 422."""
|
||||
resp = client.post("/api/v1/recipes/scan", files=[])
|
||||
assert resp.status_code in (422, 400)
|
||||
|
||||
def test_scan_too_many_files(self, override_deps, mock_scan_infra):
|
||||
"""More than 4 files should return 422."""
|
||||
mock_thread, _ = mock_scan_infra
|
||||
mock_thread.return_value = []
|
||||
files = [("files", (f"p{i}.jpg", _fake_image(), "image/jpeg")) for i in range(5)]
|
||||
resp = client.post("/api/v1/recipes/scan", files=files)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_scan_not_a_recipe_returns_422(self, override_deps, mock_scan_infra):
|
||||
_, store_mock = override_deps
|
||||
mock_thread, _ = mock_scan_infra
|
||||
call_count = 0
|
||||
|
||||
def _side(fn, *a, **kw):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return store_mock.list_inventory()
|
||||
raise ValueError("not_a_recipe: image does not appear to contain a recipe")
|
||||
|
||||
mock_thread.side_effect = _side
|
||||
resp = client.post(
|
||||
"/api/v1/recipes/scan",
|
||||
files=[("files", ("photo.jpg", _fake_image(), "image/jpeg"))],
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert "recipe" in resp.json()["detail"].lower()
|
||||
|
||||
def test_scan_backend_unavailable_returns_503(self, override_deps, mock_scan_infra):
|
||||
_, store_mock = override_deps
|
||||
mock_thread, _ = mock_scan_infra
|
||||
call_count = 0
|
||||
|
||||
def _side(fn, *a, **kw):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return store_mock.list_inventory()
|
||||
raise RuntimeError("No vision backend configured")
|
||||
|
||||
mock_thread.side_effect = _side
|
||||
resp = client.post(
|
||||
"/api/v1/recipes/scan",
|
||||
files=[("files", ("photo.jpg", _fake_image(), "image/jpeg"))],
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
# ── POST /recipes/scan/save ────────────────────────────────────────────────────
|
||||
|
||||
class TestSaveEndpoint:
|
||||
def test_save_returns_201(self, override_deps):
|
||||
_, store_mock = override_deps
|
||||
store_mock.create_user_recipe.return_value = {
|
||||
"id": 42,
|
||||
"title": "Green Goddess Bowls",
|
||||
"subtitle": None,
|
||||
"servings": "2",
|
||||
"cook_time": "15 min",
|
||||
"source_note": None,
|
||||
"ingredients": [{"name": "brown rice", "qty": "1", "unit": "cup", "raw": None, "in_pantry": False}],
|
||||
"steps": ["Cook it."],
|
||||
"notes": None,
|
||||
"tags": [],
|
||||
"source": "scan",
|
||||
"pantry_match_pct": None,
|
||||
"created_at": "2026-04-27T00:00:00",
|
||||
}
|
||||
|
||||
payload = {
|
||||
"title": "Green Goddess Bowls",
|
||||
"servings": "2",
|
||||
"cook_time": "15 min",
|
||||
"ingredients": [{"name": "brown rice", "qty": "1", "unit": "cup"}],
|
||||
"steps": ["Cook it."],
|
||||
"source": "scan",
|
||||
}
|
||||
resp = client.post("/api/v1/recipes/scan/save", json=payload)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["id"] == 42
|
||||
assert data["title"] == "Green Goddess Bowls"
|
||||
|
||||
def test_save_missing_title_rejected(self, override_deps):
|
||||
payload = {
|
||||
"ingredients": [{"name": "eggs", "qty": "2"}],
|
||||
"steps": ["Scramble."],
|
||||
}
|
||||
resp = client.post("/api/v1/recipes/scan/save", json=payload)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ── GET /recipes/user ──────────────────────────────────────────────────────────
|
||||
|
||||
class TestUserRecipeEndpoints:
|
||||
def test_list_empty(self, override_deps):
|
||||
_, store_mock = override_deps
|
||||
store_mock.list_user_recipes.return_value = []
|
||||
resp = client.get("/api/v1/recipes/user")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
def test_get_not_found(self, override_deps):
|
||||
_, store_mock = override_deps
|
||||
store_mock.get_user_recipe.return_value = None
|
||||
resp = client.get("/api/v1/recipes/user/999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_not_found(self, override_deps):
|
||||
_, store_mock = override_deps
|
||||
store_mock.delete_user_recipe.return_value = False
|
||||
resp = client.delete("/api/v1/recipes/user/999")
|
||||
assert resp.status_code == 404
|
||||
233
tests/services/recipe/test_recipe_scanner.py
Normal file
233
tests/services/recipe/test_recipe_scanner.py
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
"""Unit tests for the recipe scanner service.
|
||||
|
||||
VLM calls are mocked — these tests cover JSON parsing, pantry cross-reference,
|
||||
error handling, and result normalization. No GPU required.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.recipe.recipe_scanner import (
|
||||
RecipeScanner,
|
||||
ScannedIngredient,
|
||||
ScannedRecipeResult,
|
||||
_cross_reference_pantry,
|
||||
_parse_scanner_json,
|
||||
_normalize_ingredient_name,
|
||||
)
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
GOOD_JSON = {
|
||||
"title": "Green Goddess Bowls",
|
||||
"subtitle": "with Broccoli & Ranch Dressing",
|
||||
"servings": "2",
|
||||
"cook_time": "15 min",
|
||||
"source_note": "Purple Carrot",
|
||||
"ingredients": [
|
||||
{"name": "brown rice", "qty": "1/2", "unit": "cup", "raw": "1/2 cup brown rice"},
|
||||
{"name": "broccoli florets", "qty": "8", "unit": "oz", "raw": "8 oz broccoli florets"},
|
||||
{"name": "avocado", "qty": "1", "unit": None, "raw": "1 avocado"},
|
||||
{"name": "ranch dressing", "qty": "2", "unit": "tbsp", "raw": "2 tbsp Follow Your Heart Ranch"},
|
||||
{"name": "pumpkin seeds", "qty": "1", "unit": "tbsp", "raw": "1 tbsp pumpkin seeds"},
|
||||
],
|
||||
"steps": [
|
||||
"Cook rice according to package directions.",
|
||||
"Steam broccoli for 5 minutes until tender.",
|
||||
"Slice avocado. Assemble bowls and top with ranch.",
|
||||
],
|
||||
"notes": "Great leftover — keeps 3 days in the fridge.",
|
||||
"confidence": "high",
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
|
||||
def _fake_image_path(tmp_path: Path, name: str = "recipe.jpg") -> Path:
|
||||
"""Create a tiny placeholder file so path-existence checks pass."""
|
||||
p = tmp_path / name
|
||||
p.write_bytes(b"\xff\xd8\xff") # minimal JPEG magic bytes
|
||||
return p
|
||||
|
||||
|
||||
# ── _normalize_ingredient_name ─────────────────────────────────────────────────
|
||||
|
||||
class TestNormalizeIngredientName:
|
||||
def test_lowercases(self):
|
||||
assert _normalize_ingredient_name("Brown Rice") == "brown rice"
|
||||
|
||||
def test_strips_whitespace(self):
|
||||
assert _normalize_ingredient_name(" avocado ") == "avocado"
|
||||
|
||||
def test_removes_plural_s(self):
|
||||
# For matching purposes only — "pumpkin seeds" stays as-is (stop at spaces)
|
||||
assert _normalize_ingredient_name("avocados") == "avocados"
|
||||
|
||||
def test_passthrough(self):
|
||||
assert _normalize_ingredient_name("ranch dressing") == "ranch dressing"
|
||||
|
||||
|
||||
# ── _parse_scanner_json ───────────────────────────────────────────────────────
|
||||
|
||||
class TestParseScannerJson:
|
||||
def test_parses_good_json(self):
|
||||
result = _parse_scanner_json(json.dumps(GOOD_JSON))
|
||||
assert result["title"] == "Green Goddess Bowls"
|
||||
assert len(result["ingredients"]) == 5
|
||||
|
||||
def test_strips_markdown_fences(self):
|
||||
wrapped = f"```json\n{json.dumps(GOOD_JSON)}\n```"
|
||||
result = _parse_scanner_json(wrapped)
|
||||
assert result["title"] == "Green Goddess Bowls"
|
||||
|
||||
def test_not_a_recipe_error(self):
|
||||
with pytest.raises(ValueError, match="not_a_recipe"):
|
||||
_parse_scanner_json(json.dumps({"error": "not_a_recipe"}))
|
||||
|
||||
def test_missing_title_returns_none_title(self):
|
||||
data = dict(GOOD_JSON)
|
||||
data.pop("title")
|
||||
result = _parse_scanner_json(json.dumps(data))
|
||||
assert result.get("title") is None
|
||||
|
||||
def test_malformed_json_raises(self):
|
||||
with pytest.raises(ValueError, match="parse"):
|
||||
_parse_scanner_json("this is not json at all")
|
||||
|
||||
def test_json_inside_prose(self):
|
||||
"""Model sometimes adds leading text before the JSON object."""
|
||||
text = f"Here is the extracted recipe:\n{json.dumps(GOOD_JSON)}"
|
||||
result = _parse_scanner_json(text)
|
||||
assert result["title"] == "Green Goddess Bowls"
|
||||
|
||||
|
||||
# ── _cross_reference_pantry ───────────────────────────────────────────────────
|
||||
|
||||
class TestCrossReferencePantry:
|
||||
PANTRY = ["brown rice", "pumpkin seeds", "olive oil", "broccoli"]
|
||||
|
||||
def test_marks_exact_match(self):
|
||||
ingr = [
|
||||
ScannedIngredient(name="brown rice", qty="1/2", unit="cup"),
|
||||
ScannedIngredient(name="avocado", qty="1", unit=None),
|
||||
]
|
||||
result, pct = _cross_reference_pantry(ingr, self.PANTRY)
|
||||
assert result[0].in_pantry is True
|
||||
assert result[1].in_pantry is False
|
||||
assert pct == 50
|
||||
|
||||
def test_partial_word_match(self):
|
||||
"""'broccoli florets' should match pantry item 'broccoli'."""
|
||||
ingr = [ScannedIngredient(name="broccoli florets", qty="8", unit="oz")]
|
||||
result, pct = _cross_reference_pantry(ingr, self.PANTRY)
|
||||
assert result[0].in_pantry is True
|
||||
assert pct == 100
|
||||
|
||||
def test_empty_pantry_all_false(self):
|
||||
ingr = [ScannedIngredient(name="broccoli", qty="1", unit=None)]
|
||||
result, pct = _cross_reference_pantry(ingr, [])
|
||||
assert result[0].in_pantry is False
|
||||
assert pct == 0
|
||||
|
||||
def test_empty_ingredients_zero_pct(self):
|
||||
_, pct = _cross_reference_pantry([], self.PANTRY)
|
||||
assert pct == 0
|
||||
|
||||
def test_case_insensitive_match(self):
|
||||
ingr = [ScannedIngredient(name="Brown Rice", qty="1", unit="cup")]
|
||||
result, pct = _cross_reference_pantry(ingr, self.PANTRY)
|
||||
assert result[0].in_pantry is True
|
||||
|
||||
|
||||
# ── RecipeScanner ─────────────────────────────────────────────────────────────
|
||||
|
||||
class TestRecipeScanner:
|
||||
def _make_scanner(self) -> RecipeScanner:
|
||||
return RecipeScanner()
|
||||
|
||||
@patch("app.services.recipe.recipe_scanner._call_vision_backend")
|
||||
def test_scan_single_image_success(self, mock_call, tmp_path):
|
||||
mock_call.return_value = json.dumps(GOOD_JSON)
|
||||
img = _fake_image_path(tmp_path)
|
||||
|
||||
scanner = self._make_scanner()
|
||||
result = scanner.scan([img], pantry_names=["brown rice", "avocado"])
|
||||
|
||||
assert isinstance(result, ScannedRecipeResult)
|
||||
assert result.title == "Green Goddess Bowls"
|
||||
assert result.servings == "2"
|
||||
assert result.cook_time == "15 min"
|
||||
assert len(result.ingredients) == 5
|
||||
assert result.confidence == "high"
|
||||
assert result.pantry_match_pct == 40 # 2 of 5 in pantry
|
||||
|
||||
@patch("app.services.recipe.recipe_scanner._call_vision_backend")
|
||||
def test_scan_multi_image(self, mock_call, tmp_path):
|
||||
"""Two photos treated as one recipe — both passed to VLM."""
|
||||
mock_call.return_value = json.dumps(GOOD_JSON)
|
||||
img1 = _fake_image_path(tmp_path, "p1.jpg")
|
||||
img2 = _fake_image_path(tmp_path, "p2.jpg")
|
||||
|
||||
scanner = self._make_scanner()
|
||||
result = scanner.scan([img1, img2])
|
||||
|
||||
# Both images passed through
|
||||
call_args = mock_call.call_args
|
||||
assert len(call_args[0][0]) == 2 # image_paths list has 2 items
|
||||
assert result.title == "Green Goddess Bowls"
|
||||
|
||||
@patch("app.services.recipe.recipe_scanner._call_vision_backend")
|
||||
def test_scan_not_a_recipe_raises(self, mock_call, tmp_path):
|
||||
mock_call.return_value = json.dumps({"error": "not_a_recipe"})
|
||||
img = _fake_image_path(tmp_path)
|
||||
|
||||
scanner = self._make_scanner()
|
||||
with pytest.raises(ValueError, match="not_a_recipe"):
|
||||
scanner.scan([img])
|
||||
|
||||
@patch("app.services.recipe.recipe_scanner._call_vision_backend")
|
||||
def test_warnings_propagated(self, mock_call, tmp_path):
|
||||
data = dict(GOOD_JSON)
|
||||
data["warnings"] = ["Directions appear to continue on another page not shown"]
|
||||
mock_call.return_value = json.dumps(data)
|
||||
img = _fake_image_path(tmp_path)
|
||||
|
||||
scanner = self._make_scanner()
|
||||
result = scanner.scan([img])
|
||||
assert len(result.warnings) == 1
|
||||
assert "another page" in result.warnings[0]
|
||||
|
||||
@patch("app.services.recipe.recipe_scanner._call_vision_backend")
|
||||
def test_scan_no_pantry_names(self, mock_call, tmp_path):
|
||||
mock_call.return_value = json.dumps(GOOD_JSON)
|
||||
img = _fake_image_path(tmp_path)
|
||||
|
||||
scanner = self._make_scanner()
|
||||
result = scanner.scan([img])
|
||||
# No pantry passed — all in_pantry=False, pct=0
|
||||
assert result.pantry_match_pct == 0
|
||||
assert all(not i.in_pantry for i in result.ingredients)
|
||||
|
||||
def test_scan_too_many_images_raises(self, tmp_path):
|
||||
imgs = [_fake_image_path(tmp_path, f"p{i}.jpg") for i in range(5)]
|
||||
scanner = self._make_scanner()
|
||||
with pytest.raises(ValueError, match="4 images"):
|
||||
scanner.scan(imgs)
|
||||
|
||||
def test_scan_no_images_raises(self):
|
||||
scanner = self._make_scanner()
|
||||
with pytest.raises(ValueError, match="least one"):
|
||||
scanner.scan([])
|
||||
|
||||
@patch("app.services.recipe.recipe_scanner._call_vision_backend")
|
||||
def test_backend_unavailable_raises(self, mock_call, tmp_path):
|
||||
mock_call.side_effect = RuntimeError("No vision backend configured")
|
||||
img = _fake_image_path(tmp_path)
|
||||
|
||||
scanner = self._make_scanner()
|
||||
with pytest.raises(RuntimeError, match="No vision backend"):
|
||||
scanner.scan([img])
|
||||
Loading…
Reference in a new issue