Compare commits
25 commits
69e2ca7914
...
0bac494ecd
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bac494ecd | |||
| 17e62c451f | |||
| 3463aa1e17 | |||
| e45b07c203 | |||
| b5eb8e4772 | |||
| 91867f15f4 | |||
| 1182c6cffb | |||
| 7292c5e7fc | |||
| 63517d135b | |||
| 2547f80893 | |||
| 0996ea8c7a | |||
| c3e7dc1ea4 | |||
| 521cb419bc | |||
| 302285a1a5 | |||
| b1e187c779 | |||
| 70205ebb25 | |||
| 9697c7b64f | |||
| f962748073 | |||
| a507deddbf | |||
| 7a7eae4666 | |||
| b223325d77 | |||
| f1d35dd1ac | |||
| 1ac7e3d76a | |||
| 1a7a94a344 | |||
| 5d0ee2493e |
52 changed files with 6338 additions and 134 deletions
|
|
@ -37,14 +37,21 @@ from app.models.schemas.inventory import (
|
|||
TagCreate,
|
||||
TagResponse,
|
||||
)
|
||||
from app.models.schemas.label_capture import LabelConfirmRequest
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _enrich_item(item: dict) -> dict:
|
||||
"""Attach computed fields: opened_expiry_date, secondary_state/uses/warning."""
|
||||
def _user_constraints(store) -> list[str]:
|
||||
"""Load active dietary constraints from user settings (comma-separated string)."""
|
||||
raw = store.get_setting("dietary_constraints") or ""
|
||||
return [c.strip() for c in raw.split(",") if c.strip()]
|
||||
|
||||
|
||||
def _enrich_item(item: dict, user_constraints: list[str] | None = None) -> dict:
|
||||
"""Attach computed fields: opened_expiry_date, secondary_state/uses/warning/discard_signs."""
|
||||
from datetime import date, timedelta
|
||||
opened = item.get("opened_date")
|
||||
if opened:
|
||||
|
|
@ -58,13 +65,16 @@ def _enrich_item(item: dict) -> dict:
|
|||
if "opened_expiry_date" not in item:
|
||||
item = {**item, "opened_expiry_date": None}
|
||||
|
||||
# Secondary use window — check sell-by date (not opened expiry)
|
||||
# Secondary use window — check sell-by date (not opened expiry).
|
||||
# Apply dietary constraint filter (e.g. wine suppressed for halal/alcohol-free).
|
||||
sec = _predictor.secondary_state(item.get("category"), item.get("expiration_date"))
|
||||
sec = _predictor.filter_secondary_by_constraints(sec, user_constraints or [])
|
||||
item = {
|
||||
**item,
|
||||
"secondary_state": sec["label"] if sec else None,
|
||||
"secondary_uses": sec["uses"] if sec else None,
|
||||
"secondary_warning": sec["warning"] if sec else None,
|
||||
"secondary_discard_signs": sec["discard_signs"] if sec else None,
|
||||
}
|
||||
return item
|
||||
|
||||
|
|
@ -212,13 +222,15 @@ async def list_inventory_items(
|
|||
store: Store = Depends(get_store),
|
||||
):
|
||||
items = await asyncio.to_thread(store.list_inventory, location, item_status)
|
||||
return [InventoryItemResponse.model_validate(_enrich_item(i)) for i in items]
|
||||
constraints = await asyncio.to_thread(_user_constraints, store)
|
||||
return [InventoryItemResponse.model_validate(_enrich_item(i, constraints)) for i in items]
|
||||
|
||||
|
||||
@router.get("/items/expiring", response_model=List[InventoryItemResponse])
|
||||
async def get_expiring_items(days: int = 7, store: Store = Depends(get_store)):
|
||||
items = await asyncio.to_thread(store.expiring_soon, days)
|
||||
return [InventoryItemResponse.model_validate(_enrich_item(i)) for i in items]
|
||||
constraints = await asyncio.to_thread(_user_constraints, store)
|
||||
return [InventoryItemResponse.model_validate(_enrich_item(i, constraints)) for i in items]
|
||||
|
||||
|
||||
@router.get("/items/{item_id}", response_model=InventoryItemResponse)
|
||||
|
|
@ -226,7 +238,8 @@ async def get_inventory_item(item_id: int, store: Store = Depends(get_store)):
|
|||
item = await asyncio.to_thread(store.get_inventory_item, item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||
return InventoryItemResponse.model_validate(_enrich_item(item))
|
||||
constraints = await asyncio.to_thread(_user_constraints, store)
|
||||
return InventoryItemResponse.model_validate(_enrich_item(item, constraints))
|
||||
|
||||
|
||||
@router.patch("/items/{item_id}", response_model=InventoryItemResponse)
|
||||
|
|
@ -243,7 +256,8 @@ async def update_inventory_item(
|
|||
item = await asyncio.to_thread(store.update_inventory_item, item_id, **updates)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||
return InventoryItemResponse.model_validate(_enrich_item(item))
|
||||
constraints = await asyncio.to_thread(_user_constraints, store)
|
||||
return InventoryItemResponse.model_validate(_enrich_item(item, constraints))
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/open", response_model=InventoryItemResponse)
|
||||
|
|
@ -257,7 +271,8 @@ async def mark_item_opened(item_id: int, store: Store = Depends(get_store)):
|
|||
)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||
return InventoryItemResponse.model_validate(_enrich_item(item))
|
||||
constraints = await asyncio.to_thread(_user_constraints, store)
|
||||
return InventoryItemResponse.model_validate(_enrich_item(item, constraints))
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/consume", response_model=InventoryItemResponse)
|
||||
|
|
@ -286,7 +301,8 @@ async def consume_item(
|
|||
)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||
return InventoryItemResponse.model_validate(_enrich_item(item))
|
||||
constraints = await asyncio.to_thread(_user_constraints, store)
|
||||
return InventoryItemResponse.model_validate(_enrich_item(item, constraints))
|
||||
|
||||
|
||||
@router.post("/items/{item_id}/discard", response_model=InventoryItemResponse)
|
||||
|
|
@ -310,7 +326,8 @@ async def discard_item(
|
|||
)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||
return InventoryItemResponse.model_validate(_enrich_item(item))
|
||||
constraints = await asyncio.to_thread(_user_constraints, store)
|
||||
return InventoryItemResponse.model_validate(_enrich_item(item, constraints))
|
||||
|
||||
|
||||
@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
|
@ -333,6 +350,31 @@ class BarcodeScanTextRequest(BaseModel):
|
|||
auto_add_to_inventory: bool = True
|
||||
|
||||
|
||||
def _captured_to_product_info(row: dict) -> dict:
|
||||
"""Convert a captured_products row to the product_info dict shape used by
|
||||
the barcode scan flow (mirrors what OpenFoodFactsService returns)."""
|
||||
macros: dict = {}
|
||||
for field in ("calories", "fat_g", "saturated_fat_g", "carbs_g", "sugar_g",
|
||||
"fiber_g", "protein_g", "sodium_mg", "serving_size_g"):
|
||||
if row.get(field) is not None:
|
||||
macros[field] = row[field]
|
||||
return {
|
||||
"name": row.get("product_name") or row.get("barcode", "Unknown Product"),
|
||||
"brand": row.get("brand"),
|
||||
"category": None,
|
||||
"nutrition_data": macros,
|
||||
"ingredient_names": row.get("ingredient_names") or [],
|
||||
"allergens": row.get("allergens") or [],
|
||||
"source": "visual_capture",
|
||||
}
|
||||
|
||||
|
||||
def _gap_message(tier: str, has_visual_capture: bool) -> str:
|
||||
if has_visual_capture:
|
||||
return "We couldn't find this product. Photograph the nutrition label to add it."
|
||||
return "Not found in any product database — add manually"
|
||||
|
||||
|
||||
@router.post("/scan/text", response_model=BarcodeScanResponse)
|
||||
async def scan_barcode_text(
|
||||
body: BarcodeScanTextRequest,
|
||||
|
|
@ -343,10 +385,21 @@ async def scan_barcode_text(
|
|||
log.info("scan auth=%s tier=%s barcode=%r", _auth_label(session.user_id), session.tier, body.barcode)
|
||||
from app.services.openfoodfacts import OpenFoodFactsService
|
||||
from app.services.expiration_predictor import ExpirationPredictor
|
||||
from app.tiers import can_use
|
||||
|
||||
off = OpenFoodFactsService()
|
||||
predictor = ExpirationPredictor()
|
||||
product_info = await off.lookup_product(body.barcode)
|
||||
has_visual_capture = can_use("visual_label_capture", session.tier, session.has_byok)
|
||||
|
||||
# 1. Check local captured-products cache before hitting FDC/OFF
|
||||
cached = await asyncio.to_thread(store.get_captured_product, body.barcode)
|
||||
if cached and cached.get("confirmed_by_user"):
|
||||
product_info: dict | None = _captured_to_product_info(cached)
|
||||
product_source = "visual_capture"
|
||||
else:
|
||||
off = OpenFoodFactsService()
|
||||
product_info = await off.lookup_product(body.barcode)
|
||||
product_source = "openfoodfacts"
|
||||
|
||||
inventory_item = None
|
||||
|
||||
if product_info and body.auto_add_to_inventory:
|
||||
|
|
@ -357,7 +410,7 @@ async def scan_barcode_text(
|
|||
brand=product_info.get("brand"),
|
||||
category=product_info.get("category"),
|
||||
nutrition_data=product_info.get("nutrition_data", {}),
|
||||
source="openfoodfacts",
|
||||
source=product_source,
|
||||
source_data=product_info,
|
||||
)
|
||||
exp = predictor.predict_expiration(
|
||||
|
|
@ -383,6 +436,7 @@ async def scan_barcode_text(
|
|||
result_product = None
|
||||
|
||||
product_found = product_info is not None
|
||||
needs_capture = not product_found and has_visual_capture
|
||||
return BarcodeScanResponse(
|
||||
success=True,
|
||||
barcodes_found=1,
|
||||
|
|
@ -392,8 +446,9 @@ async def scan_barcode_text(
|
|||
"product": result_product,
|
||||
"inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None,
|
||||
"added_to_inventory": inventory_item is not None,
|
||||
"needs_manual_entry": not product_found,
|
||||
"message": "Added to inventory" if inventory_item else "Not found in any product database — add manually",
|
||||
"needs_manual_entry": not product_found and not needs_capture,
|
||||
"needs_visual_capture": needs_capture,
|
||||
"message": "Added to inventory" if inventory_item else _gap_message(session.tier, needs_capture),
|
||||
}],
|
||||
message="Barcode processed",
|
||||
)
|
||||
|
|
@ -410,6 +465,9 @@ async def scan_barcode_image(
|
|||
):
|
||||
"""Scan a barcode from an uploaded image. Requires Phase 2 scanner integration."""
|
||||
log.info("scan_image auth=%s tier=%s", _auth_label(session.user_id), session.tier)
|
||||
from app.tiers import can_use
|
||||
has_visual_capture = can_use("visual_label_capture", session.tier, session.has_byok)
|
||||
|
||||
temp_dir = Path("/tmp/kiwi_barcode_scans")
|
||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_file = temp_dir / f"{uuid.uuid4()}_{file.filename}"
|
||||
|
|
@ -432,7 +490,16 @@ async def scan_barcode_image(
|
|||
results = []
|
||||
for bc in barcodes:
|
||||
code = bc["data"]
|
||||
product_info = await off.lookup_product(code)
|
||||
|
||||
# Check local visual-capture cache before hitting FDC/OFF
|
||||
cached = await asyncio.to_thread(store.get_captured_product, code)
|
||||
if cached and cached.get("confirmed_by_user"):
|
||||
product_info: dict | None = _captured_to_product_info(cached)
|
||||
product_source = "visual_capture"
|
||||
else:
|
||||
product_info = await off.lookup_product(code)
|
||||
product_source = "openfoodfacts"
|
||||
|
||||
inventory_item = None
|
||||
if product_info and auto_add_to_inventory:
|
||||
product, _ = await asyncio.to_thread(
|
||||
|
|
@ -442,7 +509,7 @@ async def scan_barcode_image(
|
|||
brand=product_info.get("brand"),
|
||||
category=product_info.get("category"),
|
||||
nutrition_data=product_info.get("nutrition_data", {}),
|
||||
source="openfoodfacts",
|
||||
source=product_source,
|
||||
source_data=product_info,
|
||||
)
|
||||
exp = predictor.predict_expiration(
|
||||
|
|
@ -450,7 +517,7 @@ async def scan_barcode_image(
|
|||
location,
|
||||
product_name=product_info.get("name", code),
|
||||
tier=session.tier,
|
||||
has_byok=session.has_byok,
|
||||
has_byok=session.has_byok,
|
||||
)
|
||||
resolved_qty = product_info.get("pack_quantity") or quantity
|
||||
resolved_unit = product_info.get("pack_unit") or "count"
|
||||
|
|
@ -462,13 +529,17 @@ async def scan_barcode_image(
|
|||
expiration_date=str(exp) if exp else None,
|
||||
source="barcode_scan",
|
||||
)
|
||||
product_found = product_info is not None
|
||||
needs_capture = not product_found and has_visual_capture
|
||||
results.append({
|
||||
"barcode": code,
|
||||
"barcode_type": bc.get("type", "unknown"),
|
||||
"product": ProductResponse.model_validate(product) if product_info else None,
|
||||
"product": ProductResponse.model_validate(product_info) if product_info else None,
|
||||
"inventory_item": InventoryItemResponse.model_validate(inventory_item) if inventory_item else None,
|
||||
"added_to_inventory": inventory_item is not None,
|
||||
"message": "Added to inventory" if inventory_item else "Barcode scanned",
|
||||
"needs_manual_entry": not product_found and not needs_capture,
|
||||
"needs_visual_capture": needs_capture,
|
||||
"message": "Added to inventory" if inventory_item else _gap_message(session.tier, needs_capture),
|
||||
})
|
||||
return BarcodeScanResponse(
|
||||
success=True, barcodes_found=len(barcodes), results=results,
|
||||
|
|
@ -479,6 +550,143 @@ async def scan_barcode_image(
|
|||
temp_file.unlink()
|
||||
|
||||
|
||||
# ── Visual label capture (kiwi#79) ────────────────────────────────────────────
|
||||
|
||||
@router.post("/scan/label-capture")
|
||||
async def capture_nutrition_label(
|
||||
file: UploadFile = File(...),
|
||||
barcode: str = Form(...),
|
||||
store: Store = Depends(get_store),
|
||||
session: CloudUser = Depends(get_session),
|
||||
):
|
||||
"""Photograph a nutrition label for an unenriched product (paid tier).
|
||||
|
||||
Sends the image to the vision model and returns structured nutrition data
|
||||
for user review. Fields extracted with confidence < 0.7 should be
|
||||
highlighted in amber in the UI.
|
||||
"""
|
||||
from app.tiers import can_use
|
||||
from app.models.schemas.label_capture import LabelCaptureResponse
|
||||
from app.services.label_capture import extract_label, needs_review as _needs_review
|
||||
|
||||
if not can_use("visual_label_capture", session.tier, session.has_byok):
|
||||
raise HTTPException(status_code=403, detail="Visual label capture requires a Paid tier or higher.")
|
||||
log.info("label_capture tier=%s barcode=%r", session.tier, barcode)
|
||||
|
||||
image_bytes = await file.read()
|
||||
extraction = await asyncio.to_thread(extract_label, image_bytes)
|
||||
|
||||
return LabelCaptureResponse(
|
||||
barcode=barcode,
|
||||
product_name=extraction.get("product_name"),
|
||||
brand=extraction.get("brand"),
|
||||
serving_size_g=extraction.get("serving_size_g"),
|
||||
calories=extraction.get("calories"),
|
||||
fat_g=extraction.get("fat_g"),
|
||||
saturated_fat_g=extraction.get("saturated_fat_g"),
|
||||
carbs_g=extraction.get("carbs_g"),
|
||||
sugar_g=extraction.get("sugar_g"),
|
||||
fiber_g=extraction.get("fiber_g"),
|
||||
protein_g=extraction.get("protein_g"),
|
||||
sodium_mg=extraction.get("sodium_mg"),
|
||||
ingredient_names=extraction.get("ingredient_names") or [],
|
||||
allergens=extraction.get("allergens") or [],
|
||||
confidence=extraction.get("confidence", 0.0),
|
||||
needs_review=_needs_review(extraction),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/scan/label-confirm")
|
||||
async def confirm_nutrition_label(
|
||||
body: LabelConfirmRequest,
|
||||
store: Store = Depends(get_store),
|
||||
session: CloudUser = Depends(get_session),
|
||||
):
|
||||
"""Confirm and save a user-reviewed label extraction.
|
||||
|
||||
Saves the product to the local cache so future scans of the same barcode
|
||||
resolve instantly without another capture. Optionally adds the item to
|
||||
the user's inventory.
|
||||
"""
|
||||
from app.tiers import can_use
|
||||
from app.models.schemas.label_capture import LabelConfirmResponse
|
||||
from app.services.expiration_predictor import ExpirationPredictor
|
||||
|
||||
if not can_use("visual_label_capture", session.tier, session.has_byok):
|
||||
raise HTTPException(status_code=403, detail="Visual label capture requires a Paid tier or higher.")
|
||||
log.info("label_confirm tier=%s barcode=%r", session.tier, body.barcode)
|
||||
|
||||
# Persist to local visual-capture cache
|
||||
await asyncio.to_thread(
|
||||
store.save_captured_product,
|
||||
body.barcode,
|
||||
product_name=body.product_name,
|
||||
brand=body.brand,
|
||||
serving_size_g=body.serving_size_g,
|
||||
calories=body.calories,
|
||||
fat_g=body.fat_g,
|
||||
saturated_fat_g=body.saturated_fat_g,
|
||||
carbs_g=body.carbs_g,
|
||||
sugar_g=body.sugar_g,
|
||||
fiber_g=body.fiber_g,
|
||||
protein_g=body.protein_g,
|
||||
sodium_mg=body.sodium_mg,
|
||||
ingredient_names=body.ingredient_names,
|
||||
allergens=body.allergens,
|
||||
confidence=body.confidence,
|
||||
confirmed_by_user=True,
|
||||
)
|
||||
|
||||
product_id: int | None = None
|
||||
inventory_item_id: int | None = None
|
||||
|
||||
if body.auto_add:
|
||||
predictor = ExpirationPredictor()
|
||||
nutrition = {}
|
||||
for field in ("calories", "fat_g", "saturated_fat_g", "carbs_g", "sugar_g",
|
||||
"fiber_g", "protein_g", "sodium_mg", "serving_size_g"):
|
||||
val = getattr(body, field, None)
|
||||
if val is not None:
|
||||
nutrition[field] = val
|
||||
|
||||
product, _ = await asyncio.to_thread(
|
||||
store.get_or_create_product,
|
||||
body.product_name or body.barcode,
|
||||
body.barcode,
|
||||
brand=body.brand,
|
||||
category=None,
|
||||
nutrition_data=nutrition,
|
||||
source="visual_capture",
|
||||
source_data={},
|
||||
)
|
||||
product_id = product["id"]
|
||||
|
||||
exp = predictor.predict_expiration(
|
||||
"",
|
||||
body.location,
|
||||
product_name=body.product_name or body.barcode,
|
||||
tier=session.tier,
|
||||
has_byok=session.has_byok,
|
||||
)
|
||||
inv_item = await asyncio.to_thread(
|
||||
store.add_inventory_item,
|
||||
product_id, body.location,
|
||||
quantity=body.quantity,
|
||||
unit="count",
|
||||
expiration_date=str(exp) if exp else None,
|
||||
source="visual_capture",
|
||||
)
|
||||
inventory_item_id = inv_item["id"]
|
||||
|
||||
return LabelConfirmResponse(
|
||||
ok=True,
|
||||
barcode=body.barcode,
|
||||
product_id=product_id,
|
||||
inventory_item_id=inventory_item_id,
|
||||
message="Product saved" + (" and added to inventory" if body.auto_add else ""),
|
||||
)
|
||||
|
||||
|
||||
# ── Tags ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/tags", response_model=TagResponse, status_code=status.HTTP_201_CREATED)
|
||||
|
|
|
|||
166
app/api/endpoints/recipe_tags.py
Normal file
166
app/api/endpoints/recipe_tags.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
# app/api/endpoints/recipe_tags.py
|
||||
"""Community subcategory tagging for corpus recipes.
|
||||
|
||||
Users can tag a recipe they're viewing with a domain/category/subcategory
|
||||
from the browse taxonomy. Tags require a community pseudonym and reach
|
||||
public visibility once two independent users have tagged the same recipe
|
||||
to the same location (upvotes >= 2).
|
||||
|
||||
All tiers may submit and upvote tags — community contribution is free.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api.endpoints.community import _get_community_store
|
||||
from app.api.endpoints.session import get_session
|
||||
from app.cloud_session import CloudUser
|
||||
from app.services.recipe.browser_domains import DOMAINS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
ACCEPT_THRESHOLD = 2
|
||||
|
||||
|
||||
# ── Request / response models ──────────────────────────────────────────────────
|
||||
|
||||
class TagSubmitBody(BaseModel):
|
||||
recipe_id: int
|
||||
domain: str
|
||||
category: str
|
||||
subcategory: str | None = None
|
||||
pseudonym: str
|
||||
|
||||
|
||||
class TagResponse(BaseModel):
|
||||
id: int
|
||||
recipe_id: int
|
||||
domain: str
|
||||
category: str
|
||||
subcategory: str | None
|
||||
pseudonym: str
|
||||
upvotes: int
|
||||
accepted: bool
|
||||
|
||||
|
||||
def _to_response(row: dict) -> TagResponse:
|
||||
return TagResponse(
|
||||
id=row["id"],
|
||||
recipe_id=int(row["recipe_ref"]),
|
||||
domain=row["domain"],
|
||||
category=row["category"],
|
||||
subcategory=row.get("subcategory"),
|
||||
pseudonym=row["pseudonym"],
|
||||
upvotes=row["upvotes"],
|
||||
accepted=row["upvotes"] >= ACCEPT_THRESHOLD,
|
||||
)
|
||||
|
||||
|
||||
def _validate_location(domain: str, category: str, subcategory: str | None) -> None:
|
||||
"""Raise 422 if (domain, category, subcategory) isn't in the known taxonomy."""
|
||||
if domain not in DOMAINS:
|
||||
raise HTTPException(status_code=422, detail=f"Unknown domain '{domain}'.")
|
||||
cats = DOMAINS[domain].get("categories", {})
|
||||
if category not in cats:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Unknown category '{category}' in domain '{domain}'.",
|
||||
)
|
||||
if subcategory is not None:
|
||||
subcats = cats[category].get("subcategories", {})
|
||||
if subcategory not in subcats:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Unknown subcategory '{subcategory}' in '{domain}/{category}'.",
|
||||
)
|
||||
|
||||
|
||||
# ── Endpoints ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/recipes/community-tags/{recipe_id}", response_model=list[TagResponse])
|
||||
async def list_recipe_tags(
|
||||
recipe_id: int,
|
||||
session: CloudUser = Depends(get_session),
|
||||
) -> list[TagResponse]:
|
||||
"""Return all community tags for a corpus recipe, accepted ones first."""
|
||||
store = _get_community_store()
|
||||
if store is None:
|
||||
return []
|
||||
tags = store.list_tags_for_recipe(recipe_id)
|
||||
return [_to_response(r) for r in tags]
|
||||
|
||||
|
||||
@router.post("/recipes/community-tags", response_model=TagResponse, status_code=201)
|
||||
async def submit_recipe_tag(
|
||||
body: TagSubmitBody,
|
||||
session: CloudUser = Depends(get_session),
|
||||
) -> TagResponse:
|
||||
"""Tag a corpus recipe with a browse taxonomy location.
|
||||
|
||||
Requires the user to have a community pseudonym set. Returns 409 if this
|
||||
user has already tagged this recipe to this exact location.
|
||||
"""
|
||||
store = _get_community_store()
|
||||
if store is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Community features are not available on this instance.",
|
||||
)
|
||||
|
||||
_validate_location(body.domain, body.category, body.subcategory)
|
||||
|
||||
try:
|
||||
import psycopg2.errors # type: ignore[import]
|
||||
row = store.submit_recipe_tag(
|
||||
recipe_id=body.recipe_id,
|
||||
domain=body.domain,
|
||||
category=body.category,
|
||||
subcategory=body.subcategory,
|
||||
pseudonym=body.pseudonym,
|
||||
)
|
||||
return _to_response(row)
|
||||
except Exception as exc:
|
||||
if "unique" in str(exc).lower() or "UniqueViolation" in type(exc).__name__:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="You have already tagged this recipe to this location.",
|
||||
)
|
||||
logger.error("submit_recipe_tag failed: %s", exc)
|
||||
raise HTTPException(status_code=500, detail="Failed to submit tag.")
|
||||
|
||||
|
||||
@router.post("/recipes/community-tags/{tag_id}/upvote", response_model=TagResponse)
|
||||
async def upvote_recipe_tag(
|
||||
tag_id: int,
|
||||
pseudonym: str,
|
||||
session: CloudUser = Depends(get_session),
|
||||
) -> TagResponse:
|
||||
"""Upvote an existing community tag.
|
||||
|
||||
Returns 409 if this pseudonym has already voted on this tag.
|
||||
Returns 404 if the tag doesn't exist.
|
||||
"""
|
||||
store = _get_community_store()
|
||||
if store is None:
|
||||
raise HTTPException(status_code=503, detail="Community features unavailable.")
|
||||
|
||||
tag_row = store.get_recipe_tag_by_id(tag_id)
|
||||
if tag_row is None:
|
||||
raise HTTPException(status_code=404, detail=f"Tag {tag_id} not found.")
|
||||
|
||||
try:
|
||||
new_upvotes = store.upvote_recipe_tag(tag_id, pseudonym)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail=f"Tag {tag_id} not found.")
|
||||
except Exception as exc:
|
||||
if "unique" in str(exc).lower() or "UniqueViolation" in type(exc).__name__:
|
||||
raise HTTPException(status_code=409, detail="You have already voted on this tag.")
|
||||
logger.error("upvote_recipe_tag failed: %s", exc)
|
||||
raise HTTPException(status_code=500, detail="Failed to upvote tag.")
|
||||
|
||||
tag_row["upvotes"] = new_upvotes
|
||||
return _to_response(tag_row)
|
||||
|
|
@ -21,7 +21,11 @@ from app.models.schemas.recipe import (
|
|||
RecipeResult,
|
||||
RecipeSuggestion,
|
||||
RoleCandidatesResponse,
|
||||
StreamTokenRequest,
|
||||
StreamTokenResponse,
|
||||
)
|
||||
from app.services.coordinator_proxy import CoordinatorError, coordinator_authorize
|
||||
from app.api.endpoints.imitate import _build_recipe_prompt
|
||||
from app.services.recipe.assembly_recipes import (
|
||||
build_from_selection,
|
||||
get_role_candidates,
|
||||
|
|
@ -37,6 +41,8 @@ from app.services.recipe.browser_domains import (
|
|||
get_subcategory_names,
|
||||
)
|
||||
from app.services.recipe.recipe_engine import RecipeEngine
|
||||
from app.services.recipe.time_effort import parse_time_effort
|
||||
from app.services.recipe.sensory import build_sensory_exclude
|
||||
from app.services.heimdall_orch import check_orch_budget
|
||||
from app.tiers import can_use
|
||||
|
||||
|
|
@ -58,6 +64,44 @@ def _suggest_in_thread(db_path: Path, req: RecipeRequest) -> RecipeResult:
|
|||
store.close()
|
||||
|
||||
|
||||
def _build_stream_prompt(db_path: Path, level: int) -> str:
|
||||
"""Fetch pantry + user settings from DB and build the recipe prompt.
|
||||
|
||||
Runs in a thread (called via asyncio.to_thread) so it can use sync Store.
|
||||
"""
|
||||
import datetime
|
||||
|
||||
store = Store(db_path)
|
||||
try:
|
||||
items = store.list_inventory(status="available")
|
||||
pantry_names = [i["product_name"] for i in items if i.get("product_name")]
|
||||
|
||||
today = datetime.date.today()
|
||||
expiring_names = [
|
||||
i["product_name"]
|
||||
for i in items
|
||||
if i.get("product_name")
|
||||
and i.get("expiry_date")
|
||||
and (datetime.date.fromisoformat(i["expiry_date"]) - today).days <= 3
|
||||
]
|
||||
|
||||
settings: dict = {}
|
||||
try:
|
||||
rows = store.conn.execute("SELECT key, value FROM user_settings").fetchall()
|
||||
settings = {r["key"]: r["value"] for r in rows}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
constraints_raw = settings.get("dietary_constraints", "")
|
||||
constraints = [c.strip() for c in constraints_raw.split(",") if c.strip()] if constraints_raw else []
|
||||
allergies_raw = settings.get("allergies", "")
|
||||
allergies = [a.strip() for a in allergies_raw.split(",") if a.strip()] if allergies_raw else []
|
||||
|
||||
return _build_recipe_prompt(pantry_names, expiring_names, constraints, allergies, level)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
async def _enqueue_recipe_job(session: CloudUser, req: RecipeRequest):
|
||||
"""Queue an async recipe_llm job and return 202 with job_id.
|
||||
|
||||
|
|
@ -143,6 +187,42 @@ async def suggest_recipes(
|
|||
return result
|
||||
|
||||
|
||||
@router.post("/stream-token", response_model=StreamTokenResponse)
|
||||
async def get_stream_token(
|
||||
req: StreamTokenRequest,
|
||||
session: CloudUser = Depends(get_session),
|
||||
) -> StreamTokenResponse:
|
||||
"""Issue a one-time stream token for LLM recipe generation.
|
||||
|
||||
Tier-gated (Paid or BYOK). Builds the prompt from pantry + user settings,
|
||||
then calls the cf-orch coordinator to obtain a stream URL. Returns
|
||||
immediately — the frontend opens EventSource to the stream URL directly.
|
||||
"""
|
||||
if not can_use("recipe_suggestions", session.tier, session.has_byok):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Streaming recipe generation requires Paid tier or a configured LLM backend.",
|
||||
)
|
||||
if req.level == 4 and not req.wildcard_confirmed:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Level 4 (Wildcard) streaming requires wildcard_confirmed=true.",
|
||||
)
|
||||
|
||||
prompt = await asyncio.to_thread(_build_stream_prompt, session.db, req.level)
|
||||
|
||||
try:
|
||||
result = await coordinator_authorize(prompt=prompt, caller="kiwi-recipe", ttl_s=300)
|
||||
except CoordinatorError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=str(exc))
|
||||
|
||||
return StreamTokenResponse(
|
||||
stream_url=result.stream_url,
|
||||
token=result.token,
|
||||
expires_in_s=result.expires_in_s,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}", response_model=RecipeJobStatus)
|
||||
async def get_recipe_job_status(
|
||||
job_id: str,
|
||||
|
|
@ -245,14 +325,15 @@ async def browse_recipes(
|
|||
pantry_items: Annotated[str | None, Query()] = None,
|
||||
subcategory: Annotated[str | None, Query()] = None,
|
||||
q: Annotated[str | None, Query(max_length=200)] = None,
|
||||
sort: Annotated[str, Query(pattern="^(default|alpha|alpha_desc)$")] = "default",
|
||||
sort: Annotated[str, Query(pattern="^(default|alpha|alpha_desc|match)$")] = "default",
|
||||
session: CloudUser = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Return a paginated list of recipes for a domain/category.
|
||||
|
||||
Pass pantry_items as a comma-separated string to receive match_pct badges.
|
||||
Pass subcategory to narrow within a category that has subcategories.
|
||||
Pass q to filter by title substring. Pass sort for ordering (default/alpha/alpha_desc).
|
||||
Pass q to filter by title substring. Pass sort for ordering (default/alpha/alpha_desc/match).
|
||||
sort=match orders by pantry coverage DESC; falls back to default when no pantry_items.
|
||||
"""
|
||||
if domain not in DOMAINS:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
|
||||
|
|
@ -283,6 +364,10 @@ async def browse_recipes(
|
|||
def _browse(db_path: Path) -> dict:
|
||||
store = Store(db_path)
|
||||
try:
|
||||
# Load sensory preferences
|
||||
sensory_prefs_json = store.get_setting("sensory_preferences")
|
||||
sensory_exclude = build_sensory_exclude(sensory_prefs_json)
|
||||
|
||||
result = store.browse_recipes(
|
||||
keywords=keywords,
|
||||
page=page,
|
||||
|
|
@ -290,7 +375,70 @@ async def browse_recipes(
|
|||
pantry_items=pantry_list,
|
||||
q=q or None,
|
||||
sort=sort,
|
||||
sensory_exclude=sensory_exclude,
|
||||
)
|
||||
|
||||
# ── Attach time/effort signals to each browse result ────────────────
|
||||
import json as _json
|
||||
for recipe_row in result.get("recipes", []):
|
||||
directions_raw = recipe_row.get("directions") or []
|
||||
if isinstance(directions_raw, str):
|
||||
try:
|
||||
directions_raw = _json.loads(directions_raw)
|
||||
except Exception:
|
||||
directions_raw = []
|
||||
if directions_raw:
|
||||
_profile = parse_time_effort(directions_raw)
|
||||
recipe_row["active_min"] = _profile.active_min
|
||||
recipe_row["passive_min"] = _profile.passive_min
|
||||
else:
|
||||
recipe_row["active_min"] = None
|
||||
recipe_row["passive_min"] = None
|
||||
# Remove directions from browse payload — not needed by the card UI
|
||||
recipe_row.pop("directions", None)
|
||||
|
||||
# Community tag fallback: if FTS returned nothing for a subcategory,
|
||||
# check whether accepted community tags exist for this location and
|
||||
# fetch those corpus recipes directly by ID.
|
||||
if result["total"] == 0 and subcategory and keywords:
|
||||
try:
|
||||
from app.api.endpoints.community import _get_community_store
|
||||
cs = _get_community_store()
|
||||
if cs is not None:
|
||||
community_ids = cs.get_accepted_recipe_ids_for_subcategory(
|
||||
domain=domain,
|
||||
category=category,
|
||||
subcategory=subcategory,
|
||||
)
|
||||
if community_ids:
|
||||
offset = (page - 1) * page_size
|
||||
paged_ids = community_ids[offset: offset + page_size]
|
||||
recipes = store.fetch_recipes_by_ids(paged_ids, pantry_list)
|
||||
import json as _json_c
|
||||
for recipe_row in recipes:
|
||||
directions_raw = recipe_row.get("directions") or []
|
||||
if isinstance(directions_raw, str):
|
||||
try:
|
||||
directions_raw = _json_c.loads(directions_raw)
|
||||
except Exception:
|
||||
directions_raw = []
|
||||
if directions_raw:
|
||||
_profile = parse_time_effort(directions_raw)
|
||||
recipe_row["active_min"] = _profile.active_min
|
||||
recipe_row["passive_min"] = _profile.passive_min
|
||||
else:
|
||||
recipe_row["active_min"] = None
|
||||
recipe_row["passive_min"] = None
|
||||
recipe_row.pop("directions", None)
|
||||
result = {
|
||||
"recipes": recipes,
|
||||
"total": len(community_ids),
|
||||
"page": page,
|
||||
"community_tagged": True,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("community tag fallback failed: %s", exc)
|
||||
|
||||
store.log_browser_telemetry(
|
||||
domain=domain,
|
||||
category=category,
|
||||
|
|
@ -406,4 +554,57 @@ async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session))
|
|||
recipe = await asyncio.to_thread(_get, session.db, recipe_id)
|
||||
if not recipe:
|
||||
raise HTTPException(status_code=404, detail="Recipe not found.")
|
||||
return recipe
|
||||
|
||||
# Normalize corpus record into RecipeSuggestion shape so RecipeDetailPanel
|
||||
# can render it without knowing it came from a direct DB lookup.
|
||||
ingredient_names = recipe.get("ingredient_names") or []
|
||||
if isinstance(ingredient_names, str):
|
||||
import json as _json
|
||||
try:
|
||||
ingredient_names = _json.loads(ingredient_names)
|
||||
except Exception:
|
||||
ingredient_names = []
|
||||
|
||||
_directions_for_te = recipe.get("directions") or []
|
||||
if isinstance(_directions_for_te, str):
|
||||
import json as _json2
|
||||
try:
|
||||
_directions_for_te = _json2.loads(_directions_for_te)
|
||||
except Exception:
|
||||
_directions_for_te = []
|
||||
|
||||
if _directions_for_te:
|
||||
_te = parse_time_effort(_directions_for_te)
|
||||
_time_effort_out: dict | None = {
|
||||
"active_min": _te.active_min,
|
||||
"passive_min": _te.passive_min,
|
||||
"total_min": _te.total_min,
|
||||
"effort_label": _te.effort_label,
|
||||
"equipment": _te.equipment,
|
||||
"step_analyses": [
|
||||
{"is_passive": sa.is_passive, "detected_minutes": sa.detected_minutes}
|
||||
for sa in _te.step_analyses
|
||||
],
|
||||
}
|
||||
else:
|
||||
_time_effort_out = None
|
||||
|
||||
return {
|
||||
"id": recipe.get("id"),
|
||||
"title": recipe.get("title", ""),
|
||||
"match_count": 0,
|
||||
"matched_ingredients": ingredient_names,
|
||||
"missing_ingredients": [],
|
||||
"directions": recipe.get("directions") or [],
|
||||
"prep_notes": [],
|
||||
"swap_candidates": [],
|
||||
"element_coverage": {},
|
||||
"notes": recipe.get("notes") or "",
|
||||
"level": 1,
|
||||
"is_wildcard": False,
|
||||
"nutrition": None,
|
||||
"source_url": recipe.get("source_url") or None,
|
||||
"complexity": None,
|
||||
"estimated_time_min": None,
|
||||
"time_effort": _time_effort_out,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from app.db.store import Store
|
|||
|
||||
router = APIRouter()
|
||||
|
||||
_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system"})
|
||||
_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system", "shopping_locale", "sensory_preferences", "time_first_layout"})
|
||||
|
||||
|
||||
class SettingBody(BaseModel):
|
||||
|
|
|
|||
|
|
@ -57,12 +57,18 @@ def _in_thread(db_path, fn):
|
|||
|
||||
# ── List ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _locale_from_store(store: Store) -> str:
|
||||
return store.get_setting("shopping_locale") or "us"
|
||||
|
||||
|
||||
@router.get("", response_model=list[ShoppingItemResponse])
|
||||
async def list_shopping_items(
|
||||
include_checked: bool = True,
|
||||
session: CloudUser = Depends(get_session),
|
||||
store: Store = Depends(get_store),
|
||||
):
|
||||
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok)
|
||||
locale = await asyncio.to_thread(_in_thread, session.db, _locale_from_store)
|
||||
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok, locale=locale)
|
||||
items = await asyncio.to_thread(
|
||||
_in_thread, session.db, lambda s: s.list_shopping_items(include_checked)
|
||||
)
|
||||
|
|
@ -75,8 +81,9 @@ async def list_shopping_items(
|
|||
async def add_shopping_item(
|
||||
body: ShoppingItemCreate,
|
||||
session: CloudUser = Depends(get_session),
|
||||
store: Store = Depends(get_store),
|
||||
):
|
||||
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok)
|
||||
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok, locale=_locale_from_store(store))
|
||||
item = await asyncio.to_thread(
|
||||
_in_thread,
|
||||
session.db,
|
||||
|
|
@ -100,6 +107,7 @@ async def add_shopping_item(
|
|||
async def add_from_recipe(
|
||||
body: BulkAddFromRecipeRequest,
|
||||
session: CloudUser = Depends(get_session),
|
||||
store: Store = Depends(get_store),
|
||||
):
|
||||
"""Add missing ingredients from a recipe to the shopping list.
|
||||
|
||||
|
|
@ -132,7 +140,7 @@ async def add_from_recipe(
|
|||
added.append(item)
|
||||
return added
|
||||
|
||||
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok)
|
||||
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok, locale=_locale_from_store(store))
|
||||
items = await asyncio.to_thread(_in_thread, session.db, _run)
|
||||
return [_enrich(i, builder) for i in items]
|
||||
|
||||
|
|
@ -144,8 +152,9 @@ async def update_shopping_item(
|
|||
item_id: int,
|
||||
body: ShoppingItemUpdate,
|
||||
session: CloudUser = Depends(get_session),
|
||||
store: Store = Depends(get_store),
|
||||
):
|
||||
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok)
|
||||
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok, locale=_locale_from_store(store))
|
||||
item = await asyncio.to_thread(
|
||||
_in_thread,
|
||||
session.db,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,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.recipe_tags import router as recipe_tags_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
|
|
@ -22,3 +23,4 @@ api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=
|
|||
api_router.include_router(orch_usage.router, prefix="/orch-usage", tags=["orch-usage"])
|
||||
api_router.include_router(shopping.router, prefix="/shopping", tags=["shopping"])
|
||||
api_router.include_router(community_router)
|
||||
api_router.include_router(recipe_tags_router)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,14 @@ class Settings:
|
|||
# Database
|
||||
DB_PATH: Path = Path(os.environ.get("DB_PATH", str(DATA_DIR / "kiwi.db")))
|
||||
|
||||
# Pre-computed browse counts cache (small SQLite, separate from corpus).
|
||||
# Written by the nightly refresh task and by infer_recipe_tags.py.
|
||||
# Set BROWSE_COUNTS_PATH to a bind-mounted path if you want the host
|
||||
# pipeline to share counts with the container without re-running FTS.
|
||||
BROWSE_COUNTS_PATH: Path = Path(
|
||||
os.environ.get("BROWSE_COUNTS_PATH", str(DATA_DIR / "browse_counts.db"))
|
||||
)
|
||||
|
||||
# Community feature settings
|
||||
COMMUNITY_DB_URL: str | None = os.environ.get("COMMUNITY_DB_URL") or None
|
||||
COMMUNITY_PSEUDONYM_SALT: str = os.environ.get(
|
||||
|
|
|
|||
12
app/db/migrations/035_sensory_tags.sql
Normal file
12
app/db/migrations/035_sensory_tags.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
-- Migration 035: add sensory_tags column for sensory profile filtering
|
||||
--
|
||||
-- sensory_tags holds a JSON object with texture, smell, and noise signals:
|
||||
-- {"textures": ["mushy", "creamy"], "smell": "pungent", "noise": "moderate"}
|
||||
--
|
||||
-- Empty object '{}' means untagged — these recipes pass ALL sensory filters
|
||||
-- (graceful degradation when tag_sensory_profiles.py has not yet been run).
|
||||
--
|
||||
-- Populated offline by: python scripts/tag_sensory_profiles.py [path/to/kiwi.db]
|
||||
-- No FTS rebuild needed — sensory_tags is filtered in Python after candidate fetch.
|
||||
|
||||
ALTER TABLE recipes ADD COLUMN sensory_tags TEXT NOT NULL DEFAULT '{}';
|
||||
26
app/db/migrations/036_captured_products.sql
Normal file
26
app/db/migrations/036_captured_products.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
-- Migration 036: captured_products local cache
|
||||
-- Products captured via visual label scanning (kiwi#79).
|
||||
-- Keyed by barcode; checked before FDC/OFF on future scans so each product
|
||||
-- is only captured once per device.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS captured_products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
barcode TEXT UNIQUE NOT NULL,
|
||||
product_name TEXT,
|
||||
brand TEXT,
|
||||
serving_size_g REAL,
|
||||
calories REAL,
|
||||
fat_g REAL,
|
||||
saturated_fat_g REAL,
|
||||
carbs_g REAL,
|
||||
sugar_g REAL,
|
||||
fiber_g REAL,
|
||||
protein_g REAL,
|
||||
sodium_mg REAL,
|
||||
ingredient_names TEXT NOT NULL DEFAULT '[]', -- JSON array
|
||||
allergens TEXT NOT NULL DEFAULT '[]', -- JSON array
|
||||
confidence REAL,
|
||||
source TEXT NOT NULL DEFAULT 'visual_capture',
|
||||
captured_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
confirmed_by_user INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
266
app/db/store.py
266
app/db/store.py
|
|
@ -11,6 +11,7 @@ from typing import Any
|
|||
|
||||
from circuitforge_core.db.base import get_connection
|
||||
from circuitforge_core.db.migrations import run_migrations
|
||||
from app.services.recipe.sensory import SensoryExclude, passes_sensory_filter
|
||||
|
||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||
|
||||
|
|
@ -59,7 +60,9 @@ class Store:
|
|||
# saved recipe columns
|
||||
"style_tags",
|
||||
# meal plan columns
|
||||
"meal_types"):
|
||||
"meal_types",
|
||||
# captured_products columns
|
||||
"allergens"):
|
||||
if key in d and isinstance(d[key], str):
|
||||
try:
|
||||
d[key] = json.loads(d[key])
|
||||
|
|
@ -1153,6 +1156,7 @@ class Store:
|
|||
pantry_items: list[str] | None = None,
|
||||
q: str | None = None,
|
||||
sort: str = "default",
|
||||
sensory_exclude: SensoryExclude | None = None,
|
||||
) -> dict:
|
||||
"""Return a page of recipes matching the keyword set.
|
||||
|
||||
|
|
@ -1161,24 +1165,37 @@ class Store:
|
|||
is provided. match_pct is the fraction of ingredient_names covered by
|
||||
the pantry set — computed deterministically, no LLM needed.
|
||||
|
||||
q: optional title substring filter (case-insensitive LIKE).
|
||||
sort: "default" (corpus order) | "alpha" (A→Z) | "alpha_desc" (Z→A).
|
||||
q: optional title substring filter (case-insensitive LIKE).
|
||||
sort: "default" (corpus order) | "alpha" (A→Z) | "alpha_desc" (Z→A)
|
||||
| "match" (pantry coverage DESC — falls back to default when no pantry).
|
||||
"""
|
||||
if keywords is not None and not keywords:
|
||||
return {"recipes": [], "total": 0, "page": page}
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
c = self._cp
|
||||
pantry_set = {p.lower() for p in pantry_items} if pantry_items else None
|
||||
|
||||
# "match" sort requires pantry items; fall back gracefully when absent.
|
||||
effective_sort = sort if (sort != "match" or pantry_set) else "default"
|
||||
|
||||
order_clause = {
|
||||
"alpha": "ORDER BY title ASC",
|
||||
"alpha_desc": "ORDER BY title DESC",
|
||||
}.get(sort, "ORDER BY id ASC")
|
||||
}.get(effective_sort, "ORDER BY id ASC")
|
||||
|
||||
q_param = f"%{q.strip()}%" if q and q.strip() else None
|
||||
|
||||
# ── match sort: push match_pct computation into SQL so ORDER BY works ──
|
||||
if effective_sort == "match" and pantry_set:
|
||||
return self._browse_by_match(
|
||||
keywords, page, page_size, offset, pantry_set, q_param, c,
|
||||
sensory_exclude=sensory_exclude,
|
||||
)
|
||||
|
||||
cols = (
|
||||
f"SELECT id, title, category, keywords, ingredient_names,"
|
||||
f" calories, fat_g, protein_g, sodium_mg FROM {c}recipes"
|
||||
f" calories, fat_g, protein_g, sodium_mg, directions, sensory_tags FROM {c}recipes"
|
||||
)
|
||||
|
||||
if keywords is None:
|
||||
|
|
@ -1216,27 +1233,180 @@ class Store:
|
|||
f"{cols} WHERE {fts_sub} {order_clause} LIMIT ? OFFSET ?",
|
||||
(match_expr, page_size, offset),
|
||||
)
|
||||
# Community tag fallback: if FTS found nothing, check whether
|
||||
# community-tagged recipe IDs exist for this keyword context.
|
||||
# browse_recipes doesn't know domain/category directly, so the
|
||||
# fallback is triggered by the caller via community_ids= when needed.
|
||||
# (See browse_recipes_with_community_fallback in the endpoint layer.)
|
||||
|
||||
pantry_set = {p.lower() for p in pantry_items} if pantry_items else None
|
||||
recipes = []
|
||||
for r in rows:
|
||||
# Apply sensory filter -- untagged recipes (empty {}) always pass
|
||||
if sensory_exclude and not sensory_exclude.is_empty():
|
||||
if not passes_sensory_filter(r.get("sensory_tags"), sensory_exclude):
|
||||
continue
|
||||
entry = {
|
||||
"id": r["id"],
|
||||
"title": r["title"],
|
||||
"category": r["category"],
|
||||
"match_pct": None,
|
||||
"id": r["id"],
|
||||
"title": r["title"],
|
||||
"category": r["category"],
|
||||
"match_pct": None,
|
||||
}
|
||||
if pantry_set:
|
||||
names = r.get("ingredient_names") or []
|
||||
if names:
|
||||
matched = sum(
|
||||
1 for n in names if n.lower() in pantry_set
|
||||
)
|
||||
matched = sum(1 for n in names if n.lower() in pantry_set)
|
||||
entry["match_pct"] = round(matched / len(names), 3)
|
||||
recipes.append(entry)
|
||||
|
||||
return {"recipes": recipes, "total": total, "page": page}
|
||||
|
||||
def fetch_recipes_by_ids(
|
||||
self,
|
||||
recipe_ids: list[int],
|
||||
pantry_items: list[str] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Fetch a specific set of corpus recipes by ID for community tag fallback.
|
||||
|
||||
Returns recipes in the same shape as browse_recipes rows, with match_pct
|
||||
populated when pantry_items are provided.
|
||||
"""
|
||||
if not recipe_ids:
|
||||
return []
|
||||
c = self._cp
|
||||
pantry_set = {p.lower() for p in pantry_items} if pantry_items else None
|
||||
ph = ",".join("?" * len(recipe_ids))
|
||||
rows = self._fetch_all(
|
||||
f"SELECT id, title, category, keywords, ingredient_names,"
|
||||
f" calories, fat_g, protein_g, sodium_mg, directions"
|
||||
f" FROM {c}recipes WHERE id IN ({ph}) ORDER BY id ASC",
|
||||
tuple(recipe_ids),
|
||||
)
|
||||
result = []
|
||||
for r in rows:
|
||||
entry: dict = {
|
||||
"id": r["id"],
|
||||
"title": r["title"],
|
||||
"category": r["category"],
|
||||
"match_pct": None,
|
||||
}
|
||||
entry["directions"] = r.get("directions")
|
||||
if pantry_set:
|
||||
names = r.get("ingredient_names") or []
|
||||
if names:
|
||||
matched = sum(1 for n in names if n.lower() in pantry_set)
|
||||
entry["match_pct"] = round(matched / len(names), 3)
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
# How many FTS candidates to fetch before Python-scoring for match sort.
|
||||
# Large enough to cover several pages with good diversity; small enough
|
||||
# that json-parsing + dict-lookup stays sub-second even for big categories.
|
||||
_MATCH_POOL_SIZE = 800
|
||||
|
||||
def _browse_by_match(
|
||||
self,
|
||||
keywords: list[str] | None,
|
||||
page: int,
|
||||
page_size: int,
|
||||
offset: int,
|
||||
pantry_set: set[str],
|
||||
q_param: str | None,
|
||||
c: str,
|
||||
sensory_exclude: SensoryExclude | None = None,
|
||||
) -> dict:
|
||||
"""Browse recipes sorted by pantry match percentage.
|
||||
|
||||
Fetches up to _MATCH_POOL_SIZE FTS candidates, scores each against the
|
||||
pantry set in Python (fast dict lookup on a bounded list), then sorts
|
||||
and paginates in-memory. This avoids correlated json_each() subqueries
|
||||
that are prohibitively slow over 50k+ row result sets.
|
||||
|
||||
The reported total is the full FTS count (from cache), not pool size.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
pantry_lower = {p.lower() for p in pantry_set}
|
||||
|
||||
# ── Fetch candidate pool from FTS ────────────────────────────────────
|
||||
base_cols = (
|
||||
f"SELECT r.id, r.title, r.category, r.ingredient_names, r.directions, r.sensory_tags"
|
||||
f" FROM {c}recipes r"
|
||||
)
|
||||
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
|
||||
if keywords is None:
|
||||
if q_param:
|
||||
total = self.conn.execute(
|
||||
f"SELECT COUNT(*) FROM {c}recipes WHERE LOWER(title) LIKE LOWER(?)",
|
||||
(q_param,),
|
||||
).fetchone()[0]
|
||||
rows = self.conn.execute(
|
||||
f"{base_cols} WHERE LOWER(r.title) LIKE LOWER(?)"
|
||||
f" ORDER BY r.id ASC LIMIT ?",
|
||||
(q_param, self._MATCH_POOL_SIZE),
|
||||
).fetchall()
|
||||
else:
|
||||
total = self.conn.execute(
|
||||
f"SELECT COUNT(*) FROM {c}recipes"
|
||||
).fetchone()[0]
|
||||
rows = self.conn.execute(
|
||||
f"{base_cols} ORDER BY r.id ASC LIMIT ?",
|
||||
(self._MATCH_POOL_SIZE,),
|
||||
).fetchall()
|
||||
else:
|
||||
match_expr = self._browser_fts_query(keywords)
|
||||
fts_sub = (
|
||||
f"r.id IN (SELECT rowid FROM {c}recipe_browser_fts"
|
||||
f" WHERE recipe_browser_fts MATCH ?)"
|
||||
)
|
||||
if q_param:
|
||||
total = self.conn.execute(
|
||||
f"SELECT COUNT(*) FROM {c}recipes r"
|
||||
f" WHERE {fts_sub} AND LOWER(r.title) LIKE LOWER(?)",
|
||||
(match_expr, q_param),
|
||||
).fetchone()[0]
|
||||
rows = self.conn.execute(
|
||||
f"{base_cols} WHERE {fts_sub} AND LOWER(r.title) LIKE LOWER(?)"
|
||||
f" ORDER BY r.id ASC LIMIT ?",
|
||||
(match_expr, q_param, self._MATCH_POOL_SIZE),
|
||||
).fetchall()
|
||||
else:
|
||||
total = self._count_recipes_for_keywords(keywords)
|
||||
rows = self.conn.execute(
|
||||
f"{base_cols} WHERE {fts_sub} ORDER BY r.id ASC LIMIT ?",
|
||||
(match_expr, self._MATCH_POOL_SIZE),
|
||||
).fetchall()
|
||||
|
||||
# ── Score in Python, sort, paginate ──────────────────────────────────
|
||||
scored = []
|
||||
for r in rows:
|
||||
row = dict(r)
|
||||
# Sensory filter applied before scoring to keep hot path clean
|
||||
if sensory_exclude and not sensory_exclude.is_empty():
|
||||
if not passes_sensory_filter(row.get("sensory_tags"), sensory_exclude):
|
||||
continue
|
||||
try:
|
||||
names = _json.loads(row["ingredient_names"] or "[]")
|
||||
except Exception:
|
||||
names = []
|
||||
if names:
|
||||
matched = sum(1 for n in names if n.lower() in pantry_lower)
|
||||
match_pct = round(matched / len(names), 3)
|
||||
else:
|
||||
match_pct = None
|
||||
scored.append({
|
||||
"id": row["id"],
|
||||
"title": row["title"],
|
||||
"category": row["category"],
|
||||
"match_pct": match_pct,
|
||||
"directions": row.get("directions"),
|
||||
})
|
||||
|
||||
scored.sort(key=lambda r: (-(r["match_pct"] or 0), r["id"]))
|
||||
page_slice = scored[offset: offset + page_size]
|
||||
return {"recipes": page_slice, "total": total, "page": page}
|
||||
|
||||
def log_browser_telemetry(
|
||||
self,
|
||||
domain: str,
|
||||
|
|
@ -1471,3 +1641,73 @@ class Store:
|
|||
cur = self.conn.execute("DELETE FROM shopping_list_items")
|
||||
self.conn.commit()
|
||||
return cur.rowcount
|
||||
|
||||
# ── Captured products (visual label cache) ────────────────────────────────
|
||||
|
||||
def get_captured_product(self, barcode: str) -> dict | None:
|
||||
"""Look up a locally-captured product by barcode.
|
||||
|
||||
Returns the row dict (ingredient_names and allergens already decoded as
|
||||
lists) or None if the barcode has not been captured yet.
|
||||
"""
|
||||
return self._fetch_one(
|
||||
"SELECT * FROM captured_products WHERE barcode = ?", (barcode,)
|
||||
)
|
||||
|
||||
def save_captured_product(
|
||||
self,
|
||||
barcode: str,
|
||||
*,
|
||||
product_name: str | None = None,
|
||||
brand: str | None = None,
|
||||
serving_size_g: float | None = None,
|
||||
calories: float | None = None,
|
||||
fat_g: float | None = None,
|
||||
saturated_fat_g: float | None = None,
|
||||
carbs_g: float | None = None,
|
||||
sugar_g: float | None = None,
|
||||
fiber_g: float | None = None,
|
||||
protein_g: float | None = None,
|
||||
sodium_mg: float | None = None,
|
||||
ingredient_names: list[str] | None = None,
|
||||
allergens: list[str] | None = None,
|
||||
confidence: float | None = None,
|
||||
confirmed_by_user: bool = True,
|
||||
source: str = "visual_capture",
|
||||
) -> dict:
|
||||
"""Insert or replace a captured product row, returning the saved dict."""
|
||||
return self._insert_returning(
|
||||
"""INSERT INTO captured_products
|
||||
(barcode, product_name, brand, serving_size_g, calories,
|
||||
fat_g, saturated_fat_g, carbs_g, sugar_g, fiber_g,
|
||||
protein_g, sodium_mg, ingredient_names, allergens,
|
||||
confidence, confirmed_by_user, source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(barcode) DO UPDATE SET
|
||||
product_name = excluded.product_name,
|
||||
brand = excluded.brand,
|
||||
serving_size_g = excluded.serving_size_g,
|
||||
calories = excluded.calories,
|
||||
fat_g = excluded.fat_g,
|
||||
saturated_fat_g = excluded.saturated_fat_g,
|
||||
carbs_g = excluded.carbs_g,
|
||||
sugar_g = excluded.sugar_g,
|
||||
fiber_g = excluded.fiber_g,
|
||||
protein_g = excluded.protein_g,
|
||||
sodium_mg = excluded.sodium_mg,
|
||||
ingredient_names = excluded.ingredient_names,
|
||||
allergens = excluded.allergens,
|
||||
confidence = excluded.confidence,
|
||||
confirmed_by_user = excluded.confirmed_by_user,
|
||||
source = excluded.source,
|
||||
captured_at = datetime('now')
|
||||
RETURNING *""",
|
||||
(
|
||||
barcode, product_name, brand, serving_size_g, calories,
|
||||
fat_g, saturated_fat_g, carbs_g, sugar_g, fiber_g,
|
||||
protein_g, sodium_mg,
|
||||
self._dump(ingredient_names or []),
|
||||
self._dump(allergens or []),
|
||||
confidence, 1 if confirmed_by_user else 0, source,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
43
app/main.py
43
app/main.py
|
|
@ -1,7 +1,9 @@
|
|||
#!/usr/bin/env python
|
||||
# app/main.py
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
|
@ -16,6 +18,26 @@ from app.services.meal_plan.affiliates import register_kiwi_programs
|
|||
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BROWSE_REFRESH_INTERVAL_H = 24
|
||||
|
||||
|
||||
async def _browse_counts_refresh_loop(corpus_path: str) -> None:
|
||||
"""Refresh browse counts every 24 h while the container is running."""
|
||||
from app.db.store import _COUNT_CACHE
|
||||
from app.services.recipe.browse_counts_cache import load_into_memory, refresh
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(_BROWSE_REFRESH_INTERVAL_H * 3600)
|
||||
try:
|
||||
logger.info("browse_counts: starting scheduled refresh...")
|
||||
computed = await asyncio.to_thread(
|
||||
refresh, corpus_path, settings.BROWSE_COUNTS_PATH
|
||||
)
|
||||
load_into_memory(settings.BROWSE_COUNTS_PATH, _COUNT_CACHE, corpus_path)
|
||||
logger.info("browse_counts: scheduled refresh complete (%d sets)", computed)
|
||||
except Exception as exc:
|
||||
logger.warning("browse_counts: scheduled refresh failed: %s", exc)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
|
|
@ -32,6 +54,27 @@ async def lifespan(app: FastAPI):
|
|||
from app.api.endpoints.community import init_community_store
|
||||
init_community_store(settings.COMMUNITY_DB_URL)
|
||||
|
||||
# Browse counts cache — warm in-memory cache from disk, refresh if stale.
|
||||
# Uses the corpus path the store will attach to at request time.
|
||||
corpus_path = os.environ.get("RECIPE_DB_PATH", str(settings.DB_PATH))
|
||||
try:
|
||||
from app.db.store import _COUNT_CACHE
|
||||
from app.services.recipe.browse_counts_cache import (
|
||||
is_stale, load_into_memory, refresh,
|
||||
)
|
||||
if is_stale(settings.BROWSE_COUNTS_PATH):
|
||||
logger.info("browse_counts: cache stale — refreshing in background...")
|
||||
asyncio.create_task(
|
||||
asyncio.to_thread(refresh, corpus_path, settings.BROWSE_COUNTS_PATH)
|
||||
)
|
||||
else:
|
||||
load_into_memory(settings.BROWSE_COUNTS_PATH, _COUNT_CACHE, corpus_path)
|
||||
except Exception as exc:
|
||||
logger.warning("browse_counts: startup init failed (live FTS fallback active): %s", exc)
|
||||
|
||||
# Nightly background refresh loop
|
||||
asyncio.create_task(_browse_counts_refresh_loop(corpus_path))
|
||||
|
||||
yield
|
||||
|
||||
# Graceful scheduler shutdown
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ class InventoryItemResponse(BaseModel):
|
|||
secondary_state: Optional[str] = None
|
||||
secondary_uses: Optional[List[str]] = None
|
||||
secondary_warning: Optional[str] = None
|
||||
secondary_discard_signs: Optional[str] = None
|
||||
status: str
|
||||
notes: Optional[str]
|
||||
disposal_reason: Optional[str] = None
|
||||
|
|
@ -141,6 +142,7 @@ class BarcodeScanResult(BaseModel):
|
|||
inventory_item: Optional[InventoryItemResponse]
|
||||
added_to_inventory: bool
|
||||
needs_manual_entry: bool = False
|
||||
needs_visual_capture: bool = False # Paid tier offer when no product data found
|
||||
message: str
|
||||
|
||||
|
||||
|
|
|
|||
59
app/models/schemas/label_capture.py
Normal file
59
app/models/schemas/label_capture.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Pydantic schemas for visual label capture (kiwi#79)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LabelCaptureResponse(BaseModel):
|
||||
"""Extraction result returned after the user photographs a nutrition label."""
|
||||
barcode: str
|
||||
product_name: Optional[str] = None
|
||||
brand: Optional[str] = None
|
||||
serving_size_g: Optional[float] = None
|
||||
calories: Optional[float] = None
|
||||
fat_g: Optional[float] = None
|
||||
saturated_fat_g: Optional[float] = None
|
||||
carbs_g: Optional[float] = None
|
||||
sugar_g: Optional[float] = None
|
||||
fiber_g: Optional[float] = None
|
||||
protein_g: Optional[float] = None
|
||||
sodium_mg: Optional[float] = None
|
||||
ingredient_names: List[str] = Field(default_factory=list)
|
||||
allergens: List[str] = Field(default_factory=list)
|
||||
confidence: float = 0.0
|
||||
needs_review: bool = True # True when confidence < REVIEW_THRESHOLD
|
||||
|
||||
|
||||
class LabelConfirmRequest(BaseModel):
|
||||
"""User-confirmed extraction to save to the local product cache."""
|
||||
barcode: str
|
||||
product_name: Optional[str] = None
|
||||
brand: Optional[str] = None
|
||||
serving_size_g: Optional[float] = None
|
||||
calories: Optional[float] = None
|
||||
fat_g: Optional[float] = None
|
||||
saturated_fat_g: Optional[float] = None
|
||||
carbs_g: Optional[float] = None
|
||||
sugar_g: Optional[float] = None
|
||||
fiber_g: Optional[float] = None
|
||||
protein_g: Optional[float] = None
|
||||
sodium_mg: Optional[float] = None
|
||||
ingredient_names: List[str] = Field(default_factory=list)
|
||||
allergens: List[str] = Field(default_factory=list)
|
||||
confidence: float = 0.0
|
||||
# When True the confirmed product is also added to inventory
|
||||
location: str = "pantry"
|
||||
quantity: float = 1.0
|
||||
auto_add: bool = True
|
||||
|
||||
|
||||
class LabelConfirmResponse(BaseModel):
|
||||
"""Result of confirming a captured product."""
|
||||
ok: bool
|
||||
barcode: str
|
||||
product_id: Optional[int] = None
|
||||
inventory_item_id: Optional[int] = None
|
||||
message: str
|
||||
|
|
@ -43,6 +43,7 @@ class RecipeSuggestion(BaseModel):
|
|||
source_url: str | None = None
|
||||
complexity: str | None = None # 'easy' | 'moderate' | 'involved'
|
||||
estimated_time_min: int | None = None # derived from step count + method signals
|
||||
rerank_score: float | None = None # cross-encoder relevance score (paid+ only, None for free tier)
|
||||
|
||||
|
||||
class GroceryLink(BaseModel):
|
||||
|
|
@ -100,10 +101,12 @@ class RecipeRequest(BaseModel):
|
|||
allergies: list[str] = Field(default_factory=list)
|
||||
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
|
||||
excluded_ids: list[int] = Field(default_factory=list)
|
||||
exclude_ingredients: list[str] = Field(default_factory=list)
|
||||
shopping_mode: bool = False
|
||||
pantry_match_only: bool = False # when True, only return recipes with zero missing ingredients
|
||||
complexity_filter: str | None = None # 'easy' | 'moderate' | 'involved' — None = any
|
||||
max_time_min: int | None = None # filter by estimated cooking time ceiling
|
||||
max_total_min: int | None = None # filter by parsed total time from recipe directions
|
||||
unit_system: str = "metric" # "metric" | "imperial"
|
||||
|
||||
|
||||
|
|
@ -150,3 +153,24 @@ class BuildRequest(BaseModel):
|
|||
|
||||
template_id: str
|
||||
role_overrides: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class StreamTokenRequest(BaseModel):
|
||||
"""Request body for POST /recipes/stream-token.
|
||||
|
||||
Pantry items and dietary constraints are fetched from the DB at request
|
||||
time — the client does not supply them here.
|
||||
"""
|
||||
level: int = Field(4, ge=3, le=4, description="Recipe level: 3=styled, 4=wildcard")
|
||||
wildcard_confirmed: bool = Field(False, description="Required true for level 4")
|
||||
|
||||
|
||||
class StreamTokenResponse(BaseModel):
|
||||
"""Response from POST /recipes/stream-token.
|
||||
|
||||
The frontend opens EventSource at stream_url?token=<token> to receive
|
||||
SSE chunks directly from the coordinator.
|
||||
"""
|
||||
stream_url: str
|
||||
token: str
|
||||
expires_in_s: int
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@
|
|||
Business logic services for Kiwi.
|
||||
"""
|
||||
|
||||
from app.services.receipt_service import ReceiptService
|
||||
__all__ = ["ReceiptService"]
|
||||
|
||||
__all__ = ["ReceiptService"]
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "ReceiptService":
|
||||
from app.services.receipt_service import ReceiptService
|
||||
return ReceiptService
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
|
|
|||
94
app/services/coordinator_proxy.py
Normal file
94
app/services/coordinator_proxy.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""cf-orch coordinator proxy client.
|
||||
|
||||
Calls the coordinator's /proxy/authorize endpoint to obtain a one-time
|
||||
stream URL + token for LLM streaming. Always raises CoordinatorError on
|
||||
failure — callers decide how to handle it (stream-token endpoint returns
|
||||
503 or 403 as appropriate).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CoordinatorError(Exception):
|
||||
"""Raised when the coordinator returns an error or is unreachable."""
|
||||
def __init__(self, message: str, status_code: int = 503):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StreamTokenResult:
|
||||
stream_url: str
|
||||
token: str
|
||||
expires_in_s: int
|
||||
|
||||
|
||||
def _coordinator_url() -> str:
|
||||
return os.environ.get("COORDINATOR_URL", "http://10.1.10.71:7700")
|
||||
|
||||
|
||||
def _product_key() -> str:
|
||||
return os.environ.get("COORDINATOR_KIWI_KEY", "")
|
||||
|
||||
|
||||
async def coordinator_authorize(
|
||||
prompt: str,
|
||||
caller: str = "kiwi-recipe",
|
||||
ttl_s: int = 300,
|
||||
) -> StreamTokenResult:
|
||||
"""Call POST /proxy/authorize on the coordinator.
|
||||
|
||||
Returns a StreamTokenResult with the stream URL and one-time token.
|
||||
Raises CoordinatorError on any failure (network, auth, capacity).
|
||||
"""
|
||||
url = f"{_coordinator_url()}/proxy/authorize"
|
||||
key = _product_key()
|
||||
if not key:
|
||||
raise CoordinatorError(
|
||||
"COORDINATOR_KIWI_KEY env var is not set — streaming unavailable",
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"product": "kiwi",
|
||||
"product_key": key,
|
||||
"caller": caller,
|
||||
"prompt": prompt,
|
||||
"params": {},
|
||||
"ttl_s": ttl_s,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(url, json=payload)
|
||||
except httpx.RequestError as exc:
|
||||
log.warning("coordinator_authorize network error: %s", exc)
|
||||
raise CoordinatorError(f"Coordinator unreachable: {exc}", status_code=503)
|
||||
|
||||
if resp.status_code == 401:
|
||||
raise CoordinatorError("Invalid product key", status_code=401)
|
||||
if resp.status_code == 429:
|
||||
raise CoordinatorError("Too many concurrent streams", status_code=429)
|
||||
if resp.status_code == 503:
|
||||
raise CoordinatorError("No GPU available for streaming", status_code=503)
|
||||
if not resp.is_success:
|
||||
raise CoordinatorError(
|
||||
f"Coordinator error {resp.status_code}: {resp.text[:200]}",
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
data = resp.json()
|
||||
# Use public_stream_url if coordinator provides it (cloud mode), else stream_url
|
||||
stream_url = data.get("public_stream_url") or data["stream_url"]
|
||||
return StreamTokenResult(
|
||||
stream_url=stream_url,
|
||||
token=data["token"],
|
||||
expires_in_s=data["expires_in_s"],
|
||||
)
|
||||
|
|
@ -157,54 +157,160 @@ class ExpirationPredictor:
|
|||
# These are NOT spoilage extensions — they describe a qualitative state
|
||||
# change where the ingredient is specifically suited for certain preparations.
|
||||
# Sources: USDA FoodKeeper, food science, culinary tradition.
|
||||
#
|
||||
# Fields:
|
||||
# window_days — days past nominal expiry still usable in secondary state
|
||||
# label — short UI label for the state
|
||||
# uses — recipe contexts suited to this state (shown in UI)
|
||||
# warning — safety note, calm tone, None if none needed
|
||||
# discard_signs — qualitative signs the item has gone past the secondary window
|
||||
# constraints_exclude — dietary constraint labels that suppress this entry entirely
|
||||
# (e.g. alcohol-containing items suppressed for halal/alcohol-free)
|
||||
SECONDARY_WINDOW: dict[str, dict] = {
|
||||
'bread': {
|
||||
'window_days': 5,
|
||||
'label': 'stale',
|
||||
'uses': ['croutons', 'stuffing', 'bread pudding', 'French toast', 'panzanella'],
|
||||
'warning': 'Check for mold before use — discard if any is visible.',
|
||||
'discard_signs': 'Visible mold (any colour), or unpleasant smell beyond dry/yeasty.',
|
||||
'constraints_exclude': [],
|
||||
},
|
||||
'bakery': {
|
||||
'window_days': 3,
|
||||
'label': 'day-old',
|
||||
'uses': ['French toast', 'bread pudding', 'crumbles'],
|
||||
'uses': ['French toast', 'bread pudding', 'crumbles', 'trifle base', 'cake pops', 'streusel topping', 'bread crumbs'],
|
||||
'warning': 'Check for mold before use — discard if any is visible.',
|
||||
'discard_signs': 'Visible mold, sliminess, or strong sour smell.',
|
||||
'constraints_exclude': [],
|
||||
},
|
||||
'bananas': {
|
||||
'window_days': 5,
|
||||
'label': 'overripe',
|
||||
'uses': ['banana bread', 'smoothies', 'pancakes', 'muffins'],
|
||||
'warning': None,
|
||||
'discard_signs': 'Leaking liquid, fermented smell, or mold on skin.',
|
||||
'constraints_exclude': [],
|
||||
},
|
||||
'milk': {
|
||||
'window_days': 3,
|
||||
'label': 'sour',
|
||||
'uses': ['pancakes', 'quick breads', 'baking', 'sauces'],
|
||||
'uses': ['pancakes', 'scones', 'waffles', 'muffins', 'quick breads', 'béchamel', 'baked mac and cheese'],
|
||||
'warning': 'Use only in cooked recipes — do not drink.',
|
||||
'discard_signs': 'Chunky texture, strong unpleasant smell beyond tangy, or visible separation with grey colour.',
|
||||
'constraints_exclude': [],
|
||||
},
|
||||
'dairy': {
|
||||
'window_days': 2,
|
||||
'label': 'sour',
|
||||
'uses': ['pancakes', 'quick breads', 'baking'],
|
||||
'uses': ['pancakes', 'scones', 'quick breads', 'muffins', 'waffles'],
|
||||
'warning': 'Use only in cooked recipes — do not drink.',
|
||||
'discard_signs': 'Strong unpleasant smell, unusual colour, or chunky texture.',
|
||||
'constraints_exclude': [],
|
||||
},
|
||||
'cheese': {
|
||||
'window_days': 14,
|
||||
'label': 'well-aged',
|
||||
'uses': ['broth', 'soups', 'risotto', 'gratins'],
|
||||
'label': 'rind-ready',
|
||||
'uses': ['parmesan broth', 'minestrone', 'ribollita', 'risotto', 'polenta', 'bean soups', 'gratins'],
|
||||
'warning': None,
|
||||
'discard_signs': 'Soft or wet texture on hard cheese, pink or black mold (white/green surface mold on hard cheese can be cut off with 1cm margin).',
|
||||
'constraints_exclude': [],
|
||||
},
|
||||
'rice': {
|
||||
'window_days': 2,
|
||||
'label': 'day-old',
|
||||
'uses': ['fried rice', 'rice bowls', 'rice porridge'],
|
||||
'uses': ['fried rice', 'onigiri', 'rice porridge', 'congee', 'arancini', 'stuffed peppers', 'rice fritters'],
|
||||
'warning': 'Refrigerate immediately after cooking — do not leave at room temp.',
|
||||
'discard_signs': 'Slimy texture, unusual smell, or more than 4 days since cooking.',
|
||||
'constraints_exclude': [],
|
||||
},
|
||||
'tortillas': {
|
||||
'window_days': 5,
|
||||
'label': 'stale',
|
||||
'uses': ['chilaquiles', 'migas', 'tortilla soup', 'casserole'],
|
||||
'warning': 'Check for mold, especially if stored in a sealed bag — discard if any is visible.',
|
||||
'discard_signs': 'Visible mold (check seams and edges), or strong sour smell.',
|
||||
'constraints_exclude': [],
|
||||
},
|
||||
# ── New entries ──────────────────────────────────────────────────────
|
||||
'apples': {
|
||||
'window_days': 7,
|
||||
'label': 'soft',
|
||||
'uses': ['applesauce', 'apple butter', 'baked apples', 'apple crisp', 'smoothies', 'chutney'],
|
||||
'warning': None,
|
||||
'discard_signs': 'Large bruised areas with fermented smell, visible mold, or liquid leaking from skin.',
|
||||
'constraints_exclude': [],
|
||||
},
|
||||
'leafy_greens': {
|
||||
'window_days': 2,
|
||||
'label': 'wilting',
|
||||
'uses': ['sautéed greens', 'soups', 'smoothies', 'frittata', 'pasta add-in', 'stir fry'],
|
||||
'warning': None,
|
||||
'discard_signs': 'Slimy texture, strong unpleasant smell, or yellowed and mushy leaves.',
|
||||
'constraints_exclude': [],
|
||||
},
|
||||
'tomatoes': {
|
||||
'window_days': 4,
|
||||
'label': 'soft',
|
||||
'uses': ['roasted tomatoes', 'tomato sauce', 'shakshuka', 'bruschetta', 'soup', 'salsa'],
|
||||
'warning': None,
|
||||
'discard_signs': 'Broken skin with liquid pooling, mold, or fermented smell.',
|
||||
'constraints_exclude': [],
|
||||
},
|
||||
'cooked_pasta': {
|
||||
'window_days': 3,
|
||||
'label': 'day-old',
|
||||
'uses': ['pasta frittata', 'pasta salad', 'baked pasta', 'soup add-in', 'fried pasta cakes'],
|
||||
'warning': 'Refrigerate within 2 hours of cooking.',
|
||||
'discard_signs': 'Slimy texture, off smell, or more than 4 days since cooking.',
|
||||
'constraints_exclude': [],
|
||||
},
|
||||
'cooked_potatoes': {
|
||||
'window_days': 3,
|
||||
'label': 'day-old',
|
||||
'uses': ['potato pancakes', 'hash browns', 'potato soup', 'gnocchi', 'twice-baked potatoes', 'croquettes'],
|
||||
'warning': 'Refrigerate within 2 hours of cooking.',
|
||||
'discard_signs': 'Slimy texture, off smell, or more than 4 days since cooking.',
|
||||
'constraints_exclude': [],
|
||||
},
|
||||
'yogurt': {
|
||||
'window_days': 7,
|
||||
'label': 'tangy',
|
||||
'uses': ['marinades', 'flatbreads', 'smoothies', 'tzatziki', 'baked goods', 'salad dressings'],
|
||||
'warning': None,
|
||||
'discard_signs': 'Pink or orange discolouration, visible mold, or strongly unpleasant smell (not just tangy).',
|
||||
'constraints_exclude': [],
|
||||
},
|
||||
'cream': {
|
||||
'window_days': 2,
|
||||
'label': 'sour',
|
||||
'uses': ['soups', 'sauces', 'scones', 'quick breads', 'mashed potatoes'],
|
||||
'warning': 'Use in cooked recipes only. Discard if the smell is strongly unpleasant rather than tangy.',
|
||||
'discard_signs': 'Strong unpleasant smell beyond tangy, unusual colour, or chunky texture.',
|
||||
'constraints_exclude': [],
|
||||
},
|
||||
'wine': {
|
||||
'window_days': 4,
|
||||
'label': 'open',
|
||||
'uses': ['pan sauces', 'braises', 'risotto', 'marinades', 'poaching liquid', 'wine reduction'],
|
||||
'warning': None,
|
||||
'discard_signs': 'Strong vinegar smell (still usable in braises/marinades), or visible cloudiness with off-smell.',
|
||||
'constraints_exclude': ['halal', 'alcohol-free'],
|
||||
},
|
||||
'cooked_beans': {
|
||||
'window_days': 3,
|
||||
'label': 'day-old',
|
||||
'uses': ['refried beans', 'bean soup', 'bean fritters', 'hummus', 'bean dip', 'grain bowls'],
|
||||
'warning': 'Refrigerate within 2 hours of cooking.',
|
||||
'discard_signs': 'Slimy texture, off smell, or more than 4 days since cooking.',
|
||||
'constraints_exclude': [],
|
||||
},
|
||||
'cooked_meat': {
|
||||
'window_days': 2,
|
||||
'label': 'leftover',
|
||||
'uses': ['grain bowls', 'tacos', 'soups', 'fried rice', 'sandwiches', 'hash', 'pasta add-in'],
|
||||
'warning': 'Refrigerate within 2 hours of cooking.',
|
||||
'discard_signs': 'Off smell, slimy texture, or more than 3–4 days since cooking.',
|
||||
'constraints_exclude': [],
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -223,10 +329,15 @@ class ExpirationPredictor:
|
|||
) -> dict | None:
|
||||
"""Return secondary use info if the item is in its post-expiry secondary window.
|
||||
|
||||
Returns a dict with label, uses, warning, days_past, and window_days when the
|
||||
item is past its nominal expiry date but still within the secondary use window.
|
||||
Returns a dict with label, uses, warning, discard_signs, constraints_exclude,
|
||||
days_past, and window_days when the item is past its nominal expiry date but
|
||||
still within the secondary use window.
|
||||
Returns None in all other cases (unknown category, no window defined, not yet
|
||||
expired, or past the secondary window).
|
||||
|
||||
Callers should apply constraints_exclude against user dietary constraints
|
||||
and suppress the result entirely if any excluded constraint is active.
|
||||
See filter_secondary_by_constraints().
|
||||
"""
|
||||
if not category or not expiry_date:
|
||||
return None
|
||||
|
|
@ -243,6 +354,8 @@ class ExpirationPredictor:
|
|||
'label': entry['label'],
|
||||
'uses': list(entry['uses']),
|
||||
'warning': entry['warning'],
|
||||
'discard_signs': entry.get('discard_signs'),
|
||||
'constraints_exclude': list(entry.get('constraints_exclude') or []),
|
||||
'days_past': days_past,
|
||||
'window_days': entry['window_days'],
|
||||
}
|
||||
|
|
@ -250,6 +363,23 @@ class ExpirationPredictor:
|
|||
pass
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def filter_secondary_by_constraints(
|
||||
sec: dict | None,
|
||||
user_constraints: list[str],
|
||||
) -> dict | None:
|
||||
"""Suppress secondary state entirely if any excluded constraint is active.
|
||||
|
||||
Call after secondary_state() when user dietary constraints are available.
|
||||
Returns sec unchanged when no constraints match, or None when suppressed.
|
||||
"""
|
||||
if sec is None:
|
||||
return None
|
||||
excluded = sec.get('constraints_exclude') or []
|
||||
if any(c.lower() in [e.lower() for e in excluded] for c in user_constraints):
|
||||
return None
|
||||
return sec
|
||||
|
||||
# Keyword lists are checked in declaration order — most specific first.
|
||||
# Rules:
|
||||
# - canned/processed goods BEFORE raw-meat terms (canned chicken != raw chicken)
|
||||
|
|
|
|||
140
app/services/label_capture.py
Normal file
140
app/services/label_capture.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"""Visual label capture service for unenriched products (kiwi#79).
|
||||
|
||||
Wraps the cf-core VisionRouter to extract structured nutrition data from a
|
||||
photographed nutrition facts panel. When the VisionRouter is not yet wired
|
||||
(NotImplementedError) the service falls back to a mock extraction so the
|
||||
barcode scan flow can be exercised end-to-end in development.
|
||||
|
||||
JSON contract returned by the vision model (and mock):
|
||||
{
|
||||
"product_name": str | null,
|
||||
"brand": str | null,
|
||||
"serving_size_g": number | null,
|
||||
"calories": number | null,
|
||||
"fat_g": number | null,
|
||||
"saturated_fat_g": number | null,
|
||||
"carbs_g": number | null,
|
||||
"sugar_g": number | null,
|
||||
"fiber_g": number | null,
|
||||
"protein_g": number | null,
|
||||
"sodium_mg": number | null,
|
||||
"ingredient_names": [str],
|
||||
"allergens": [str],
|
||||
"confidence": number (0.0–1.0)
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Confidence below this threshold surfaces amber highlights in the UI.
|
||||
REVIEW_THRESHOLD = 0.7
|
||||
|
||||
_MOCK_EXTRACTION: dict[str, Any] = {
|
||||
"product_name": "Unknown Product",
|
||||
"brand": None,
|
||||
"serving_size_g": None,
|
||||
"calories": None,
|
||||
"fat_g": None,
|
||||
"saturated_fat_g": None,
|
||||
"carbs_g": None,
|
||||
"sugar_g": None,
|
||||
"fiber_g": None,
|
||||
"protein_g": None,
|
||||
"sodium_mg": None,
|
||||
"ingredient_names": [],
|
||||
"allergens": [],
|
||||
"confidence": 0.0,
|
||||
}
|
||||
|
||||
_EXTRACTION_PROMPT = """You are reading a nutrition facts label photograph.
|
||||
Extract the following fields as a JSON object with no extra text:
|
||||
|
||||
{
|
||||
"product_name": <product name or null>,
|
||||
"brand": <brand name or null>,
|
||||
"serving_size_g": <serving size in grams as a number or null>,
|
||||
"calories": <calories per serving as a number or null>,
|
||||
"fat_g": <total fat grams or null>,
|
||||
"saturated_fat_g": <saturated fat grams or null>,
|
||||
"carbs_g": <total carbohydrates grams or null>,
|
||||
"sugar_g": <sugars grams or null>,
|
||||
"fiber_g": <dietary fiber grams or null>,
|
||||
"protein_g": <protein grams or null>,
|
||||
"sodium_mg": <sodium milligrams or null>,
|
||||
"ingredient_names": [list of individual ingredients as strings],
|
||||
"allergens": [list of allergens explicitly stated on label],
|
||||
"confidence": <your confidence this extraction is correct, 0.0 to 1.0>
|
||||
}
|
||||
|
||||
Use null for any field you cannot read clearly. Do not guess values.
|
||||
Respond with JSON only."""
|
||||
|
||||
|
||||
def extract_label(image_bytes: bytes) -> dict[str, Any]:
|
||||
"""Run vision model extraction on raw label image bytes.
|
||||
|
||||
Returns a dict matching the nutrition JSON contract above.
|
||||
Falls back to a zero-confidence mock if the VisionRouter is not yet
|
||||
implemented (stub) or if the model returns unparseable output.
|
||||
"""
|
||||
# Allow unit tests to bypass the vision model entirely.
|
||||
if os.environ.get("KIWI_LABEL_CAPTURE_MOCK") == "1":
|
||||
log.debug("label_capture: mock mode active")
|
||||
return dict(_MOCK_EXTRACTION)
|
||||
|
||||
try:
|
||||
from circuitforge_core.vision import caption as vision_caption
|
||||
result = vision_caption(image_bytes, prompt=_EXTRACTION_PROMPT)
|
||||
raw = result.caption or ""
|
||||
return _parse_extraction(raw)
|
||||
except Exception as exc:
|
||||
log.warning("label_capture: extraction failed (%s) — returning mock extraction", exc)
|
||||
return dict(_MOCK_EXTRACTION)
|
||||
|
||||
|
||||
def _parse_extraction(raw: str) -> dict[str, Any]:
|
||||
"""Parse the JSON string returned by the vision model.
|
||||
|
||||
Strips markdown code fences if present. Validates required shape.
|
||||
Returns the mock on any parse error.
|
||||
"""
|
||||
text = raw.strip()
|
||||
if text.startswith("```"):
|
||||
# Strip ```json ... ``` fences
|
||||
lines = text.splitlines()
|
||||
text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
|
||||
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
log.warning("label_capture: could not parse vision response: %s", exc)
|
||||
return dict(_MOCK_EXTRACTION)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
log.warning("label_capture: vision response is not a dict")
|
||||
return dict(_MOCK_EXTRACTION)
|
||||
|
||||
# Normalise list fields — model may return None instead of []
|
||||
for list_key in ("ingredient_names", "allergens"):
|
||||
if not isinstance(data.get(list_key), list):
|
||||
data[list_key] = []
|
||||
|
||||
# Clamp confidence to [0, 1]
|
||||
confidence = data.get("confidence")
|
||||
if not isinstance(confidence, (int, float)):
|
||||
confidence = 0.0
|
||||
data["confidence"] = max(0.0, min(1.0, float(confidence)))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def needs_review(extraction: dict[str, Any]) -> bool:
|
||||
"""Return True when the extraction confidence is below REVIEW_THRESHOLD."""
|
||||
return float(extraction.get("confidence", 0.0)) < REVIEW_THRESHOLD
|
||||
256
app/services/recipe/browse_counts_cache.py
Normal file
256
app/services/recipe/browse_counts_cache.py
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
"""
|
||||
Browse counts cache — pre-computes and persists recipe counts for all
|
||||
browse domain keyword sets so category/subcategory page loads never
|
||||
hit the 3.8 GB FTS index at request time.
|
||||
|
||||
Counts change only when the corpus changes (after a pipeline run).
|
||||
The cache is a small SQLite file separate from both the read-only
|
||||
corpus DB and per-user kiwi.db files, so the container can write it.
|
||||
|
||||
Refresh triggers:
|
||||
1. Startup — if cache is missing or older than STALE_DAYS
|
||||
2. Nightly — asyncio background task started in main.py lifespan
|
||||
3. Pipeline — infer_recipe_tags.py calls refresh() at end of run
|
||||
|
||||
The in-memory _COUNT_CACHE in store.py is pre-warmed from this file
|
||||
on startup, so FTS queries are never needed for known keyword sets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STALE_DAYS = 7
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _kw_key(keywords: list[str]) -> str:
|
||||
"""Stable string key for a keyword list — sorted and pipe-joined."""
|
||||
return "|".join(sorted(keywords))
|
||||
|
||||
|
||||
def _fts_match_expr(keywords: list[str]) -> str:
|
||||
phrases = ['"' + kw.replace('"', '""') + '"' for kw in keywords]
|
||||
return " OR ".join(phrases)
|
||||
|
||||
|
||||
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS browse_counts (
|
||||
keywords_key TEXT PRIMARY KEY,
|
||||
count INTEGER NOT NULL,
|
||||
computed_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS browse_counts_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_stale(cache_path: Path, max_age_days: int = STALE_DAYS) -> bool:
|
||||
"""Return True if the cache is missing, empty, or older than max_age_days."""
|
||||
if not cache_path.exists():
|
||||
return True
|
||||
try:
|
||||
conn = sqlite3.connect(cache_path)
|
||||
row = conn.execute(
|
||||
"SELECT value FROM browse_counts_meta WHERE key = 'refreshed_at'"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if row is None:
|
||||
return True
|
||||
age = (datetime.now(timezone.utc) - datetime.fromisoformat(row[0])).days
|
||||
return age >= max_age_days
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def load_into_memory(cache_path: Path, count_cache: dict, corpus_path: str) -> int:
|
||||
"""
|
||||
Load all rows from the cache file into the in-memory count_cache dict.
|
||||
|
||||
Uses corpus_path (the current RECIPE_DB_PATH env value) as the cache key,
|
||||
not what was stored in the file — the file may have been built against a
|
||||
different mount path (e.g. pipeline ran on host, container sees a different
|
||||
path). Counts are corpus-content-derived and path-independent.
|
||||
|
||||
Returns the number of entries loaded.
|
||||
"""
|
||||
if not cache_path.exists():
|
||||
return 0
|
||||
try:
|
||||
conn = sqlite3.connect(cache_path)
|
||||
rows = conn.execute("SELECT keywords_key, count FROM browse_counts").fetchall()
|
||||
conn.close()
|
||||
loaded = 0
|
||||
for kw_key, count in rows:
|
||||
keywords = kw_key.split("|") if kw_key else []
|
||||
cache_key = (corpus_path, *sorted(keywords))
|
||||
count_cache[cache_key] = count
|
||||
loaded += 1
|
||||
logger.info("browse_counts: warmed %d entries from %s", loaded, cache_path)
|
||||
return loaded
|
||||
except Exception as exc:
|
||||
logger.warning("browse_counts: load failed: %s", exc)
|
||||
return 0
|
||||
|
||||
|
||||
def refresh(corpus_path: str, cache_path: Path) -> int:
|
||||
"""
|
||||
Run FTS5 queries for every keyword set in browser_domains.DOMAINS
|
||||
and write results to cache_path.
|
||||
|
||||
Safe to call from both the host pipeline script and the in-container
|
||||
nightly task. The corpus_path must be reachable and readable from
|
||||
the calling process.
|
||||
|
||||
Returns the number of keyword sets computed.
|
||||
"""
|
||||
from app.services.recipe.browser_domains import DOMAINS # local import — avoid circular
|
||||
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_conn = sqlite3.connect(cache_path)
|
||||
_ensure_schema(cache_conn)
|
||||
|
||||
# Collect every unique keyword list across all domains/categories/subcategories.
|
||||
# DOMAINS structure: {domain: {label: str, categories: {cat_name: {keywords, subcategories}}}}
|
||||
seen: dict[str, list[str]] = {}
|
||||
for domain_data in DOMAINS.values():
|
||||
for cat_data in domain_data.get("categories", {}).values():
|
||||
if not isinstance(cat_data, dict):
|
||||
continue
|
||||
top_kws = cat_data.get("keywords", [])
|
||||
if top_kws:
|
||||
seen[_kw_key(top_kws)] = top_kws
|
||||
for subcat_kws in cat_data.get("subcategories", {}).values():
|
||||
if subcat_kws:
|
||||
seen[_kw_key(subcat_kws)] = subcat_kws
|
||||
|
||||
try:
|
||||
corpus_conn = sqlite3.connect(f"file:{corpus_path}?mode=ro", uri=True)
|
||||
except Exception as exc:
|
||||
logger.error("browse_counts: cannot open corpus %s: %s", corpus_path, exc)
|
||||
cache_conn.close()
|
||||
return 0
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
computed = 0
|
||||
|
||||
try:
|
||||
for kw_key, kws in seen.items():
|
||||
try:
|
||||
row = corpus_conn.execute(
|
||||
"SELECT count(*) FROM recipe_browser_fts WHERE recipe_browser_fts MATCH ?",
|
||||
(_fts_match_expr(kws),),
|
||||
).fetchone()
|
||||
count = row[0] if row else 0
|
||||
cache_conn.execute(
|
||||
"INSERT OR REPLACE INTO browse_counts (keywords_key, count, computed_at)"
|
||||
" VALUES (?, ?, ?)",
|
||||
(kw_key, count, now),
|
||||
)
|
||||
computed += 1
|
||||
except Exception as exc:
|
||||
logger.warning("browse_counts: query failed key=%r: %s", kw_key[:60], exc)
|
||||
|
||||
# Merge accepted community tags into counts.
|
||||
# For each (domain, category, subcategory) that has accepted community
|
||||
# tags, add the count of distinct tagged recipe_ids to the FTS count.
|
||||
# The two overlap rarely (community tags exist precisely because FTS
|
||||
# missed those recipes), so simple addition is accurate enough.
|
||||
try:
|
||||
_merge_community_tag_counts(cache_conn, DOMAINS, now)
|
||||
except Exception as exc:
|
||||
logger.warning("browse_counts: community merge skipped: %s", exc)
|
||||
|
||||
cache_conn.execute(
|
||||
"INSERT OR REPLACE INTO browse_counts_meta (key, value) VALUES ('refreshed_at', ?)",
|
||||
(now,),
|
||||
)
|
||||
cache_conn.execute(
|
||||
"INSERT OR REPLACE INTO browse_counts_meta (key, value) VALUES ('corpus_path', ?)",
|
||||
(corpus_path,),
|
||||
)
|
||||
cache_conn.commit()
|
||||
logger.info("browse_counts: wrote %d counts → %s", computed, cache_path)
|
||||
finally:
|
||||
corpus_conn.close()
|
||||
cache_conn.close()
|
||||
|
||||
return computed
|
||||
|
||||
|
||||
def _merge_community_tag_counts(
|
||||
cache_conn: sqlite3.Connection,
|
||||
domains: dict,
|
||||
now: str,
|
||||
threshold: int = 2,
|
||||
) -> None:
|
||||
"""Add accepted community tag counts on top of FTS counts in the cache.
|
||||
|
||||
Queries the community PostgreSQL store (if available) for accepted tags
|
||||
grouped by (domain, category, subcategory), maps each back to its keyword
|
||||
set key, then increments the cached count.
|
||||
|
||||
Silently skips if community features are unavailable.
|
||||
"""
|
||||
try:
|
||||
from app.api.endpoints.community import _get_community_store
|
||||
store = _get_community_store()
|
||||
if store is None:
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
|
||||
for domain_id, domain_data in domains.items():
|
||||
for cat_name, cat_data in domain_data.get("categories", {}).items():
|
||||
if not isinstance(cat_data, dict):
|
||||
continue
|
||||
# Check subcategories
|
||||
for subcat_name, subcat_kws in cat_data.get("subcategories", {}).items():
|
||||
if not subcat_kws:
|
||||
continue
|
||||
ids = store.get_accepted_recipe_ids_for_subcategory(
|
||||
domain=domain_id,
|
||||
category=cat_name,
|
||||
subcategory=subcat_name,
|
||||
threshold=threshold,
|
||||
)
|
||||
if not ids:
|
||||
continue
|
||||
kw_key = _kw_key(subcat_kws)
|
||||
cache_conn.execute(
|
||||
"UPDATE browse_counts SET count = count + ? WHERE keywords_key = ?",
|
||||
(len(ids), kw_key),
|
||||
)
|
||||
# Check category-level tags (subcategory IS NULL)
|
||||
top_kws = cat_data.get("keywords", [])
|
||||
if top_kws:
|
||||
ids = store.get_accepted_recipe_ids_for_subcategory(
|
||||
domain=domain_id,
|
||||
category=cat_name,
|
||||
subcategory=None,
|
||||
threshold=threshold,
|
||||
)
|
||||
if ids:
|
||||
kw_key = _kw_key(top_kws)
|
||||
cache_conn.execute(
|
||||
"UPDATE browse_counts SET count = count + ? WHERE keywords_key = ?",
|
||||
(len(ids), kw_key),
|
||||
)
|
||||
logger.info("browse_counts: community tag counts merged")
|
||||
|
|
@ -214,28 +214,40 @@ DOMAINS: dict[str, dict] = {
|
|||
},
|
||||
},
|
||||
"BBQ & Smoke": {
|
||||
"keywords": ["bbq", "barbecue", "smoked", "pit", "smoke ring",
|
||||
"low and slow", "brisket", "pulled pork", "ribs"],
|
||||
# Top-level keywords use broad corpus-friendly terms that appear in
|
||||
# food.com keyword/category fields (e.g. "BBQ", "Oven BBQ", "Smoker").
|
||||
# Subcategory keywords remain specific for drill-down filtering.
|
||||
"keywords": ["bbq", "barbecue", "barbeque", "smoked", "smoky",
|
||||
"smoke", "pit", "smoke ring", "low and slow",
|
||||
"brisket", "pulled pork", "ribs", "spare ribs",
|
||||
"baby back", "baby back ribs", "dry rub", "wet rub",
|
||||
"cookout", "smoker", "smoked meat", "smoked chicken",
|
||||
"smoked pork", "smoked beef", "smoked turkey",
|
||||
"pit smoked", "wood smoked", "slow smoked",
|
||||
"charcoal", "chargrilled", "burnt ends"],
|
||||
"subcategories": {
|
||||
"Texas BBQ": ["texas bbq", "central texas bbq", "brisket",
|
||||
"beef ribs", "post oak", "salt and pepper rub",
|
||||
"beef brisket", "beef ribs", "smoked brisket",
|
||||
"post oak", "salt and pepper rub",
|
||||
"east texas bbq", "lockhart", "franklin style"],
|
||||
"Carolina BBQ": ["carolina bbq", "north carolina bbq", "whole hog",
|
||||
"vinegar sauce", "lexington style", "eastern nc",
|
||||
"south carolina bbq", "mustard sauce"],
|
||||
"vinegar sauce", "vinegar bbq", "lexington style",
|
||||
"eastern nc", "south carolina bbq", "mustard sauce",
|
||||
"carolina pulled pork"],
|
||||
"Kansas City BBQ": ["kansas city bbq", "kc bbq", "burnt ends",
|
||||
"sweet bbq sauce", "tomato molasses sauce",
|
||||
"baby back ribs kc"],
|
||||
"baby back ribs", "kansas city ribs"],
|
||||
"Memphis BBQ": ["memphis bbq", "dry rub ribs", "wet ribs",
|
||||
"memphis style", "dry rub pork"],
|
||||
"memphis style", "dry rub pork", "memphis ribs"],
|
||||
"Alabama BBQ": ["alabama bbq", "white sauce", "alabama white sauce",
|
||||
"smoked chicken alabama"],
|
||||
"smoked chicken", "white bbq sauce"],
|
||||
"Kentucky BBQ": ["kentucky bbq", "mutton bbq", "owensboro bbq",
|
||||
"black dip", "western kentucky barbecue"],
|
||||
"St. Louis BBQ": ["st louis bbq", "st. louis ribs", "st louis cut ribs",
|
||||
"st louis style spare ribs"],
|
||||
"black dip", "western kentucky barbecue", "mutton"],
|
||||
"St. Louis BBQ": ["st louis bbq", "st louis ribs", "st. louis ribs",
|
||||
"st louis cut ribs", "spare ribs st louis"],
|
||||
"Backyard Grill": ["backyard bbq", "cookout", "grilled burgers",
|
||||
"charcoal grill", "kettle grill", "tailgate"],
|
||||
"charcoal grill", "kettle grill", "tailgate",
|
||||
"grill out", "backyard grilling"],
|
||||
},
|
||||
},
|
||||
"European": {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ Walmart is kept inline until cf-core adds Impact network support:
|
|||
|
||||
Links are always generated (plain URLs are useful even without affiliate IDs).
|
||||
Walmart links only appear when WALMART_AFFILIATE_ID is set.
|
||||
Instacart and Walmart are US/CA-only; other locales get Amazon only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -23,19 +24,27 @@ from urllib.parse import quote_plus
|
|||
from circuitforge_core.affiliates import wrap_url
|
||||
|
||||
from app.models.schemas.recipe import GroceryLink
|
||||
from app.services.recipe.locale_config import get_locale
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _amazon_fresh_link(ingredient: str) -> GroceryLink:
|
||||
def _amazon_link(ingredient: str, locale: str) -> GroceryLink:
|
||||
cfg = get_locale(locale)
|
||||
q = quote_plus(ingredient)
|
||||
base = f"https://www.amazon.com/s?k={q}&i=amazonfresh"
|
||||
return GroceryLink(ingredient=ingredient, retailer="Amazon Fresh", url=wrap_url(base, "amazon"))
|
||||
domain = cfg["amazon_domain"]
|
||||
dept = cfg["amazon_grocery_dept"]
|
||||
base = f"https://www.{domain}/s?k={q}&{dept}"
|
||||
retailer = "Amazon" if locale != "us" else "Amazon Fresh"
|
||||
return GroceryLink(ingredient=ingredient, retailer=retailer, url=wrap_url(base, "amazon"))
|
||||
|
||||
|
||||
def _instacart_link(ingredient: str) -> GroceryLink:
|
||||
def _instacart_link(ingredient: str, locale: str) -> GroceryLink:
|
||||
q = quote_plus(ingredient)
|
||||
base = f"https://www.instacart.com/store/s?k={q}"
|
||||
if locale == "ca":
|
||||
base = f"https://www.instacart.ca/store/s?k={q}"
|
||||
else:
|
||||
base = f"https://www.instacart.com/store/s?k={q}"
|
||||
return GroceryLink(ingredient=ingredient, retailer="Instacart", url=wrap_url(base, "instacart"))
|
||||
|
||||
|
||||
|
|
@ -50,26 +59,28 @@ def _walmart_link(ingredient: str, affiliate_id: str) -> GroceryLink:
|
|||
|
||||
|
||||
class GroceryLinkBuilder:
|
||||
def __init__(self, tier: str = "free", has_byok: bool = False) -> None:
|
||||
def __init__(self, tier: str = "free", has_byok: bool = False, locale: str = "us") -> None:
|
||||
self._tier = tier
|
||||
self._locale = locale
|
||||
self._locale_cfg = get_locale(locale)
|
||||
self._walmart_id = os.environ.get("WALMART_AFFILIATE_ID", "").strip()
|
||||
|
||||
def build_links(self, ingredient: str) -> list[GroceryLink]:
|
||||
"""Build grocery deeplinks for a single ingredient.
|
||||
|
||||
Amazon Fresh and Instacart links are always included; wrap_url handles
|
||||
affiliate ID injection (or returns a plain URL if none is configured).
|
||||
Walmart requires WALMART_AFFILIATE_ID to be set (Impact network uses a
|
||||
path-based redirect that doesn't degrade cleanly to a plain URL).
|
||||
Amazon link is always included, routed to the user's locale domain.
|
||||
Instacart and Walmart are only shown where they operate (US/CA).
|
||||
wrap_url handles affiliate ID injection for supported programs.
|
||||
"""
|
||||
if not ingredient.strip():
|
||||
return []
|
||||
|
||||
links: list[GroceryLink] = [
|
||||
_amazon_fresh_link(ingredient),
|
||||
_instacart_link(ingredient),
|
||||
]
|
||||
if self._walmart_id:
|
||||
links: list[GroceryLink] = [_amazon_link(ingredient, self._locale)]
|
||||
|
||||
if self._locale_cfg["instacart"]:
|
||||
links.append(_instacart_link(ingredient, self._locale))
|
||||
|
||||
if self._locale_cfg["walmart"] and self._walmart_id:
|
||||
links.append(_walmart_link(ingredient, self._walmart_id))
|
||||
|
||||
return links
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ class LLMRecipeGenerator:
|
|||
if allergy_list:
|
||||
lines.append(f"IMPORTANT — must NOT contain: {', '.join(allergy_list)}")
|
||||
|
||||
if req.exclude_ingredients:
|
||||
lines.append(f"IMPORTANT — user does not want these today: {', '.join(req.exclude_ingredients)}. Do not include them.")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"Covered culinary elements: {', '.join(covered_elements) or 'none'}")
|
||||
|
||||
|
|
@ -124,6 +127,9 @@ class LLMRecipeGenerator:
|
|||
if allergy_list:
|
||||
lines.append(f"Must NOT contain: {', '.join(allergy_list)}")
|
||||
|
||||
if req.exclude_ingredients:
|
||||
lines.append(f"Do not use today: {', '.join(req.exclude_ingredients)}")
|
||||
|
||||
unit_line = (
|
||||
"Use metric units (grams, ml, Celsius) for all quantities and temperatures."
|
||||
if req.unit_system == "metric"
|
||||
|
|
@ -143,7 +149,8 @@ class LLMRecipeGenerator:
|
|||
|
||||
return "\n".join(lines)
|
||||
|
||||
_SERVICE_TYPE = "cf-text"
|
||||
_SERVICE_TYPE = "vllm"
|
||||
_MODEL_CANDIDATES = ["Qwen2.5-3B-Instruct", "Phi-4-mini-instruct"]
|
||||
_TTL_S = 300.0
|
||||
_CALLER = "kiwi-recipe"
|
||||
|
||||
|
|
@ -161,8 +168,10 @@ class LLMRecipeGenerator:
|
|||
client = CFOrchClient(cf_orch_url)
|
||||
return client.allocate(
|
||||
service=self._SERVICE_TYPE,
|
||||
model_candidates=self._MODEL_CANDIDATES,
|
||||
ttl_s=self._TTL_S,
|
||||
caller=self._CALLER,
|
||||
pipeline=os.environ.get("CF_APP_NAME") or None,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("CFOrchClient init failed, falling back to direct URL: %s", exc)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ from app.models.schemas.recipe import GroceryLink, NutritionPanel, RecipeRequest
|
|||
from app.services.recipe.element_classifier import ElementClassifier
|
||||
from app.services.recipe.grocery_links import GroceryLinkBuilder
|
||||
from app.services.recipe.substitution_engine import SubstitutionEngine
|
||||
from app.services.recipe.sensory import SensoryExclude, build_sensory_exclude, passes_sensory_filter
|
||||
from app.services.recipe.time_effort import parse_time_effort
|
||||
from app.services.recipe.reranker import rerank_suggestions
|
||||
|
||||
_LEFTOVER_DAILY_MAX_FREE = 5
|
||||
|
||||
|
|
@ -162,14 +165,46 @@ _PANTRY_LABEL_SYNONYMS: dict[str, str] = {
|
|||
# Each key is (category_in_SECONDARY_WINDOW, label_returned_by_secondary_state).
|
||||
# Values are additional strings added to the pantry set for FTS coverage.
|
||||
_SECONDARY_STATE_SYNONYMS: dict[tuple[str, str], list[str]] = {
|
||||
("bread", "stale"): ["stale bread", "day-old bread", "old bread", "dried bread"],
|
||||
("bakery", "day-old"): ["day-old bread", "stale bread", "stale pastry"],
|
||||
("bananas", "overripe"): ["overripe bananas", "very ripe banana", "ripe bananas", "mashed banana"],
|
||||
("milk", "sour"): ["sour milk", "slightly sour milk", "buttermilk"],
|
||||
("dairy", "sour"): ["sour milk", "slightly sour milk"],
|
||||
("cheese", "well-aged"): ["parmesan rind", "cheese rind", "aged cheese"],
|
||||
("rice", "day-old"): ["day-old rice", "leftover rice", "cold rice", "cooked rice"],
|
||||
("tortillas", "stale"): ["stale tortillas", "dried tortillas", "day-old tortillas"],
|
||||
# ── Existing entries (corrected) ─────────────────────────────────────────
|
||||
("bread", "stale"): ["stale bread", "day-old bread", "old bread", "dried bread"],
|
||||
("bakery", "day-old"): ["day-old bread", "stale bread", "stale pastry",
|
||||
"day-old croissant", "stale croissant", "day-old muffin",
|
||||
"stale cake", "old pastry", "day-old baguette"],
|
||||
("bananas", "overripe"): ["overripe bananas", "very ripe bananas", "spotty bananas",
|
||||
"brown bananas", "black bananas", "mushy bananas",
|
||||
"mashed banana", "ripe bananas"],
|
||||
("milk", "sour"): ["sour milk", "slightly sour milk", "buttermilk",
|
||||
"soured milk", "off milk", "milk gone sour"],
|
||||
("dairy", "sour"): ["sour milk", "slightly sour milk", "soured milk"],
|
||||
("cheese", "rind-ready"): ["parmesan rind", "cheese rind", "aged cheese",
|
||||
"hard cheese rind", "parmigiano rind", "grana padano rind",
|
||||
"pecorino rind", "dry cheese"],
|
||||
("rice", "day-old"): ["day-old rice", "leftover rice", "cold rice", "cooked rice",
|
||||
"old rice"],
|
||||
("tortillas", "stale"): ["stale tortillas", "dried tortillas", "day-old tortillas"],
|
||||
# ── New entries ──────────────────────────────────────────────────────────
|
||||
("apples", "soft"): ["soft apples", "mealy apples", "overripe apples",
|
||||
"bruised apples", "mushy apple"],
|
||||
("leafy_greens", "wilting"):["wilted spinach", "wilted greens", "limp lettuce",
|
||||
"wilted kale", "tired greens"],
|
||||
("tomatoes", "soft"): ["overripe tomatoes", "very ripe tomatoes", "ripe tomatoes",
|
||||
"soft tomatoes", "bruised tomatoes"],
|
||||
("cooked_pasta", "day-old"):["leftover pasta", "cooked pasta", "day-old pasta",
|
||||
"cold pasta", "pre-cooked pasta"],
|
||||
("cooked_potatoes", "day-old"): ["leftover potatoes", "cooked potatoes", "day-old potatoes",
|
||||
"mashed potatoes", "baked potatoes"],
|
||||
("yogurt", "tangy"): ["sour yogurt", "tangy yogurt", "past-date yogurt",
|
||||
"older yogurt", "well-cultured yogurt"],
|
||||
("cream", "sour"): ["slightly soured cream", "cultured cream",
|
||||
"heavy cream gone sour", "soured cream"],
|
||||
("wine", "open"): ["open wine", "leftover wine", "day-old wine",
|
||||
"cooking wine", "red wine", "white wine"],
|
||||
("cooked_beans", "day-old"):["leftover beans", "cooked beans", "day-old beans",
|
||||
"cold beans", "pre-cooked beans",
|
||||
"cooked chickpeas", "cooked lentils"],
|
||||
("cooked_meat", "leftover"):["leftover chicken", "shredded chicken", "leftover beef",
|
||||
"cooked chicken", "pulled chicken", "leftover pork",
|
||||
"cooked meat", "rotisserie chicken"],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -612,6 +647,21 @@ def _estimate_time_min(directions: list[str], complexity: str) -> int:
|
|||
return max(10, 20 + steps * 4) # moderate
|
||||
|
||||
|
||||
def _within_time(directions: list[str], max_total_min: int) -> bool:
|
||||
"""Return True if parsed total time (active + passive) is within max_total_min.
|
||||
|
||||
Graceful degradation:
|
||||
- Empty directions -> True (no data, don't hide)
|
||||
- total_min == 0 (no time signals found) -> True (unparseable, don't hide)
|
||||
"""
|
||||
if not directions:
|
||||
return True
|
||||
profile = parse_time_effort(directions)
|
||||
if profile.total_min == 0:
|
||||
return True
|
||||
return profile.total_min <= max_total_min
|
||||
|
||||
|
||||
def _classify_method_complexity(
|
||||
directions: list[str],
|
||||
available_equipment: list[str] | None = None,
|
||||
|
|
@ -672,6 +722,7 @@ class RecipeEngine:
|
|||
profiles = self._classifier.classify_batch(req.pantry_items)
|
||||
gaps = self._classifier.identify_gaps(profiles)
|
||||
pantry_set = _expand_pantry_set(req.pantry_items, req.secondary_pantry_items or None)
|
||||
exclude_set = _expand_pantry_set(req.exclude_ingredients) if req.exclude_ingredients else set()
|
||||
|
||||
if req.level >= 3:
|
||||
from app.services.recipe.llm_recipe import LLMRecipeGenerator
|
||||
|
|
@ -704,8 +755,12 @@ class RecipeEngine:
|
|||
if _l1 and effective_max_missing is None:
|
||||
effective_max_missing = _L1_MAX_MISSING_DEFAULT
|
||||
|
||||
# Load sensory preferences -- applied as silent post-score filter
|
||||
_sensory_prefs_json = self._store.get_setting("sensory_preferences")
|
||||
_sensory_exclude = build_sensory_exclude(_sensory_prefs_json)
|
||||
|
||||
suggestions = []
|
||||
hard_day_tier_map: dict[int, int] = {} # recipe_id → tier when hard_day_mode
|
||||
hard_day_tier_map: dict[int, int] = {} # recipe_id -> tier when hard_day_mode
|
||||
|
||||
for row in rows:
|
||||
ingredient_names: list[str] = row.get("ingredient_names") or []
|
||||
|
|
@ -715,6 +770,15 @@ class RecipeEngine:
|
|||
except Exception:
|
||||
ingredient_names = []
|
||||
|
||||
# Skip recipes that require any ingredient the user has excluded.
|
||||
if exclude_set and any(_ingredient_in_pantry(n, exclude_set) for n in ingredient_names):
|
||||
continue
|
||||
|
||||
# Sensory filter -- silent exclusion of recipes exceeding user tolerance
|
||||
if not _sensory_exclude.is_empty():
|
||||
if not passes_sensory_filter(row.get("sensory_tags"), _sensory_exclude):
|
||||
continue
|
||||
|
||||
# Compute missing ingredients, detecting pantry coverage first.
|
||||
# When covered, collect any prep-state annotations (e.g. "melted butter"
|
||||
# → note "Melt the butter before starting.") to surface separately.
|
||||
|
|
@ -792,6 +856,10 @@ class RecipeEngine:
|
|||
if req.max_time_min is not None and row_time_min > req.max_time_min:
|
||||
continue
|
||||
|
||||
# Total time filter (kiwi#52) — uses parsed time from directions
|
||||
if req.max_total_min is not None and not _within_time(directions, req.max_total_min):
|
||||
continue
|
||||
|
||||
# Level 2: also add dietary constraint swaps from substitution_pairs
|
||||
if req.level == 2 and req.constraints:
|
||||
for ing in ingredient_names:
|
||||
|
|
@ -845,11 +913,21 @@ class RecipeEngine:
|
|||
estimated_time_min=row_time_min,
|
||||
))
|
||||
|
||||
# Sort corpus results — assembly templates are now served from a dedicated tab.
|
||||
# Hard day mode: primary sort by tier (0=premade, 1=simple, 2=moderate),
|
||||
# then by match_count within each tier.
|
||||
# Normal mode: sort by match_count descending.
|
||||
if req.hard_day_mode and hard_day_tier_map:
|
||||
# Sort corpus results.
|
||||
# Paid+ tier: cross-encoder reranker orders by full pantry + dietary fit.
|
||||
# Free tier (or reranker failure): overlap sort with hard_day_mode tier grouping.
|
||||
reranked = rerank_suggestions(req, suggestions)
|
||||
if reranked is not None:
|
||||
# Reranker provided relevance order. In hard_day_mode, still respect
|
||||
# tier grouping as primary sort; reranker order applies within each tier.
|
||||
if req.hard_day_mode and hard_day_tier_map:
|
||||
suggestions = sorted(
|
||||
reranked,
|
||||
key=lambda s: hard_day_tier_map.get(s.id, 1),
|
||||
)
|
||||
else:
|
||||
suggestions = reranked
|
||||
elif req.hard_day_mode and hard_day_tier_map:
|
||||
suggestions = sorted(
|
||||
suggestions,
|
||||
key=lambda s: (hard_day_tier_map.get(s.id, 1), -s.match_count),
|
||||
|
|
|
|||
175
app/services/recipe/reranker.py
Normal file
175
app/services/recipe/reranker.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"""
|
||||
Reranker integration for recipe suggestions.
|
||||
|
||||
Wraps circuitforge_core.reranker to score recipe candidates against a
|
||||
natural-language query built from the user's pantry, constraints, and
|
||||
preferences. Paid+ tier only; free tier returns None (caller keeps
|
||||
existing sort). All exceptions are caught and logged — the reranker
|
||||
must never break recipe suggestions.
|
||||
|
||||
Environment:
|
||||
CF_RERANKER_MOCK=1 — force mock backend (tests, no model required)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.models.schemas.recipe import RecipeRequest, RecipeSuggestion
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Tiers that get reranker access.
|
||||
_RERANKER_TIERS: frozenset[str] = frozenset({"paid", "premium", "local"})
|
||||
|
||||
# Minimum candidates worth reranking — below this the cross-encoder
|
||||
# overhead is not justified and the overlap sort is fine.
|
||||
_MIN_CANDIDATES: int = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RerankerInput:
|
||||
"""Intermediate representation passed to the reranker."""
|
||||
query: str
|
||||
candidates: list[str]
|
||||
suggestion_ids: list[int] # parallel to candidates, for re-mapping
|
||||
|
||||
|
||||
# ── Query builder ─────────────────────────────────────────────────────────────
|
||||
|
||||
def build_query(req: RecipeRequest) -> str:
|
||||
"""Build a natural-language query string from the recipe request.
|
||||
|
||||
Encodes the user's full context so the cross-encoder can score
|
||||
relevance, dietary fit, and expiry urgency in a single pass.
|
||||
Only non-empty segments are included.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
|
||||
if req.pantry_items:
|
||||
parts.append(f"Recipe using: {', '.join(req.pantry_items)}")
|
||||
|
||||
if req.exclude_ingredients:
|
||||
parts.append(f"Avoid: {', '.join(req.exclude_ingredients)}")
|
||||
|
||||
if req.allergies:
|
||||
parts.append(f"Allergies: {', '.join(req.allergies)}")
|
||||
|
||||
if req.constraints:
|
||||
parts.append(f"Dietary: {', '.join(req.constraints)}")
|
||||
|
||||
if req.category:
|
||||
parts.append(f"Category: {req.category}")
|
||||
|
||||
if req.style_id:
|
||||
parts.append(f"Style: {req.style_id}")
|
||||
|
||||
if req.complexity_filter:
|
||||
parts.append(f"Prefer: {req.complexity_filter}")
|
||||
|
||||
if req.hard_day_mode:
|
||||
parts.append("Prefer: easy, minimal effort")
|
||||
|
||||
# Secondary pantry items carry a state label (e.g. "stale", "overripe")
|
||||
# that helps the reranker favour recipes suited to those specific states.
|
||||
if req.secondary_pantry_items:
|
||||
expiry_parts = [f"{name} ({state})" for name, state in req.secondary_pantry_items.items()]
|
||||
parts.append(f"Use soon: {', '.join(expiry_parts)}")
|
||||
elif req.expiry_first:
|
||||
parts.append("Prefer: recipes that use expiring items first")
|
||||
|
||||
return ". ".join(parts) + "." if parts else "Recipe."
|
||||
|
||||
|
||||
# ── Candidate builder ─────────────────────────────────────────────────────────
|
||||
|
||||
def build_candidate_string(suggestion: RecipeSuggestion) -> str:
|
||||
"""Build a candidate string for a single recipe suggestion.
|
||||
|
||||
Format: "{title}. Ingredients: {comma-joined ingredients}"
|
||||
Matched ingredients appear before missing ones.
|
||||
Directions excluded to stay within BGE's 512-token window.
|
||||
"""
|
||||
ingredients = suggestion.matched_ingredients + suggestion.missing_ingredients
|
||||
if not ingredients:
|
||||
return suggestion.title
|
||||
return f"{suggestion.title}. Ingredients: {', '.join(ingredients)}"
|
||||
|
||||
|
||||
# ── Input assembler ───────────────────────────────────────────────────────────
|
||||
|
||||
def build_reranker_input(
|
||||
req: RecipeRequest,
|
||||
suggestions: list[RecipeSuggestion],
|
||||
) -> RerankerInput:
|
||||
"""Assemble query and candidate strings for the reranker."""
|
||||
query = build_query(req)
|
||||
candidates: list[str] = []
|
||||
ids: list[int] = []
|
||||
for s in suggestions:
|
||||
candidates.append(build_candidate_string(s))
|
||||
ids.append(s.id)
|
||||
return RerankerInput(query=query, candidates=candidates, suggestion_ids=ids)
|
||||
|
||||
|
||||
# ── cf-core seam (isolated for monkeypatching in tests) ──────────────────────
|
||||
|
||||
def _do_rerank(query: str, candidates: list[str], top_n: int = 0):
|
||||
"""Thin wrapper around cf-core rerank(). Extracted so tests can patch it."""
|
||||
from circuitforge_core.reranker import rerank
|
||||
return rerank(query, candidates, top_n=top_n)
|
||||
|
||||
|
||||
# ── Public entry point ────────────────────────────────────────────────────────
|
||||
|
||||
def rerank_suggestions(
|
||||
req: RecipeRequest,
|
||||
suggestions: list[RecipeSuggestion],
|
||||
) -> list[RecipeSuggestion] | None:
|
||||
"""Rerank suggestions using the cf-core cross-encoder.
|
||||
|
||||
Returns a reordered list with rerank_score populated, or None when:
|
||||
- Tier is not paid+ (free tier keeps overlap sort)
|
||||
- Fewer than _MIN_CANDIDATES suggestions (not worth the overhead)
|
||||
- Any exception is raised (graceful fallback to existing sort)
|
||||
|
||||
The caller should treat None as "keep existing sort order".
|
||||
Original suggestions are never mutated.
|
||||
"""
|
||||
if req.tier not in _RERANKER_TIERS:
|
||||
return None
|
||||
|
||||
if len(suggestions) < _MIN_CANDIDATES:
|
||||
return None
|
||||
|
||||
try:
|
||||
rinput = build_reranker_input(req, suggestions)
|
||||
results = _do_rerank(rinput.query, rinput.candidates, top_n=0)
|
||||
|
||||
# Map reranked results back to RecipeSuggestion objects using the
|
||||
# candidate string as key (build_candidate_string is deterministic).
|
||||
candidate_map: dict[str, RecipeSuggestion] = {
|
||||
build_candidate_string(s): s for s in suggestions
|
||||
}
|
||||
|
||||
reranked: list[RecipeSuggestion] = []
|
||||
for rr in results:
|
||||
suggestion = candidate_map.get(rr.candidate)
|
||||
if suggestion is not None:
|
||||
reranked.append(suggestion.model_copy(
|
||||
update={"rerank_score": round(float(rr.score), 4)}
|
||||
))
|
||||
|
||||
if len(reranked) < len(suggestions):
|
||||
log.warning(
|
||||
"Reranker lost %d/%d suggestions during mapping, falling back",
|
||||
len(suggestions) - len(reranked),
|
||||
len(suggestions),
|
||||
)
|
||||
return None
|
||||
|
||||
return reranked
|
||||
|
||||
except Exception:
|
||||
log.exception("Reranker failed, falling back to overlap sort")
|
||||
return None
|
||||
133
app/services/recipe/sensory.py
Normal file
133
app/services/recipe/sensory.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""
|
||||
Sensory filter dataclass and helpers.
|
||||
|
||||
SensoryExclude bridges user preferences (from user_settings) to the
|
||||
store browse methods and recipe engine suggest flow.
|
||||
|
||||
Recipes with sensory_tags = '{}' (untagged) pass ALL filters --
|
||||
graceful degradation when tag_sensory_profiles.py has not run.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
_SMELL_LEVELS: tuple[str, ...] = ("mild", "aromatic", "pungent", "fermented")
|
||||
_NOISE_LEVELS: tuple[str, ...] = ("quiet", "moderate", "loud", "very_loud")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SensoryExclude:
|
||||
"""Derived filter criteria from user sensory preferences.
|
||||
|
||||
textures: texture tags to exclude (empty tuple = no texture filter)
|
||||
smell_above: if set, exclude recipes whose smell level is strictly above
|
||||
this level in the smell spectrum
|
||||
noise_above: if set, exclude recipes whose noise level is strictly above
|
||||
this level in the noise spectrum
|
||||
"""
|
||||
textures: tuple[str, ...] = field(default_factory=tuple)
|
||||
smell_above: str | None = None
|
||||
noise_above: str | None = None
|
||||
|
||||
@classmethod
|
||||
def empty(cls) -> "SensoryExclude":
|
||||
"""No filtering -- pass-through for users with no preferences set."""
|
||||
return cls()
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
"""True when no filtering will be applied."""
|
||||
return not self.textures and self.smell_above is None and self.noise_above is None
|
||||
|
||||
|
||||
def build_sensory_exclude(prefs_json: str | None) -> SensoryExclude:
|
||||
"""Parse user_settings value for 'sensory_preferences' into a SensoryExclude.
|
||||
|
||||
Expected JSON shape:
|
||||
{
|
||||
"avoid_textures": ["mushy", "slimy"],
|
||||
"max_smell": "pungent",
|
||||
"max_noise": "loud"
|
||||
}
|
||||
|
||||
Returns SensoryExclude.empty() on missing, null, or malformed input.
|
||||
"""
|
||||
if not prefs_json:
|
||||
return SensoryExclude.empty()
|
||||
try:
|
||||
prefs = json.loads(prefs_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return SensoryExclude.empty()
|
||||
if not isinstance(prefs, dict):
|
||||
return SensoryExclude.empty()
|
||||
|
||||
avoid_textures = tuple(
|
||||
t for t in (prefs.get("avoid_textures") or [])
|
||||
if isinstance(t, str)
|
||||
)
|
||||
max_smell: str | None = prefs.get("max_smell") or None
|
||||
max_noise: str | None = prefs.get("max_noise") or None
|
||||
|
||||
if max_smell and max_smell not in _SMELL_LEVELS:
|
||||
max_smell = None
|
||||
if max_noise and max_noise not in _NOISE_LEVELS:
|
||||
max_noise = None
|
||||
|
||||
return SensoryExclude(
|
||||
textures=avoid_textures,
|
||||
smell_above=max_smell,
|
||||
noise_above=max_noise,
|
||||
)
|
||||
|
||||
|
||||
def passes_sensory_filter(
|
||||
sensory_tags_raw: str | dict | None,
|
||||
exclude: SensoryExclude,
|
||||
) -> bool:
|
||||
"""Return True if the recipe passes the sensory exclude criteria.
|
||||
|
||||
sensory_tags_raw: the sensory_tags column value (JSON string or already-parsed dict).
|
||||
exclude: derived filter criteria.
|
||||
|
||||
Untagged recipes (empty dict or '{}') always pass -- graceful degradation.
|
||||
Empty SensoryExclude always passes -- no preferences set.
|
||||
"""
|
||||
if exclude.is_empty():
|
||||
return True
|
||||
|
||||
if sensory_tags_raw is None:
|
||||
return True
|
||||
if isinstance(sensory_tags_raw, str):
|
||||
try:
|
||||
tags: dict = json.loads(sensory_tags_raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return True
|
||||
else:
|
||||
tags = sensory_tags_raw
|
||||
|
||||
if not tags:
|
||||
return True
|
||||
|
||||
if exclude.textures:
|
||||
recipe_textures: list[str] = tags.get("textures") or []
|
||||
for t in recipe_textures:
|
||||
if t in exclude.textures:
|
||||
return False
|
||||
|
||||
if exclude.smell_above is not None:
|
||||
recipe_smell: str | None = tags.get("smell")
|
||||
if recipe_smell and recipe_smell in _SMELL_LEVELS:
|
||||
max_idx = _SMELL_LEVELS.index(exclude.smell_above)
|
||||
recipe_idx = _SMELL_LEVELS.index(recipe_smell)
|
||||
if recipe_idx > max_idx:
|
||||
return False
|
||||
|
||||
if exclude.noise_above is not None:
|
||||
recipe_noise: str | None = tags.get("noise")
|
||||
if recipe_noise and recipe_noise in _NOISE_LEVELS:
|
||||
max_idx = _NOISE_LEVELS.index(exclude.noise_above)
|
||||
recipe_idx = _NOISE_LEVELS.index(recipe_noise)
|
||||
if recipe_idx > max_idx:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
|
@ -68,6 +68,15 @@ _CUISINE_SIGNALS: list[tuple[str, list[str]]] = [
|
|||
("cuisine:Cajun", ["cajun", "creole", "gumbo", "jambalaya", "andouille", "etouffee"]),
|
||||
("cuisine:African", ["injera", "berbere", "jollof", "suya", "egusi", "fufu", "tagine"]),
|
||||
("cuisine:Caribbean", ["jerk", "scotch bonnet", "callaloo", "ackee"]),
|
||||
# BBQ detection: match on title terms and key ingredients; these rarely appear
|
||||
# in food.com's own keyword/category taxonomy so we derive the tag from content.
|
||||
("cuisine:BBQ", ["brisket", "pulled pork", "spare ribs", "baby back ribs",
|
||||
"baby back", "burnt ends", "pit smoked", "smoke ring",
|
||||
"low and slow", "hickory", "mesquite", "liquid smoke",
|
||||
"bbq brisket", "smoked brisket", "barbecue brisket",
|
||||
"carolina bbq", "texas bbq", "kansas city bbq",
|
||||
"memphis bbq", "smoked ribs", "smoked pulled pork",
|
||||
"dry rub ribs", "wet rub ribs", "beer can chicken smoked"]),
|
||||
]
|
||||
|
||||
_DIETARY_SIGNALS: list[tuple[str, list[str]]] = [
|
||||
|
|
|
|||
197
app/services/recipe/time_effort.py
Normal file
197
app/services/recipe/time_effort.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""
|
||||
Runtime parser for active/passive time split and equipment detection.
|
||||
|
||||
Operates over a list of direction strings. No I/O — pure Python functions.
|
||||
Sub-millisecond for up to 20 recipes (20 × ~10 steps each = 200 regex calls).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
# ── Passive step keywords (whole-word, case-insensitive) ──────────────────
|
||||
|
||||
_PASSIVE_PATTERNS: Final[list[str]] = [
|
||||
"simmer", "bake", "roast", "broil", "refrigerate", "marinate",
|
||||
"chill", "cool", "freeze", "rest", "stand", "set", "soak",
|
||||
"steep", "proof", "rise", "let", "wait", "overnight", "braise",
|
||||
r"slow\s+cook", r"pressure\s+cook",
|
||||
]
|
||||
|
||||
# Pre-compiled as a single alternation — avoids re-compiling on every call.
|
||||
_PASSIVE_RE: re.Pattern[str] = re.compile(
|
||||
r"\b(?:" + "|".join(_PASSIVE_PATTERNS) + r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# ── Time extraction regex ─────────────────────────────────────────────────
|
||||
|
||||
# Two-branch pattern:
|
||||
# Branch A (groups 1-3): range "15-20 minutes", "15–20 min"
|
||||
# Branch B (groups 4-5): single "10 minutes", "2 hours", "30 sec"
|
||||
#
|
||||
# Separator characters: plain hyphen (-), en-dash (–), or literal "-to-"
|
||||
_TIME_RE: re.Pattern[str] = re.compile(
|
||||
r"(\d+)\s*(?:[-\u2013]|-to-)\s*(\d+)\s*(hour|hr|minute|min|second|sec)s?"
|
||||
r"|"
|
||||
r"(\d+)\s*(hour|hr|minute|min|second|sec)s?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_MAX_MINUTES_PER_STEP: Final[int] = 480 # 8 hours sanity cap
|
||||
|
||||
# ── Equipment detection (keyword → label, in detection priority order) ────
|
||||
|
||||
_EQUIPMENT_RULES: Final[list[tuple[re.Pattern[str], str]]] = [
|
||||
(re.compile(r"\b(?:chop|dice|mince|slice|julienne)\b", re.IGNORECASE), "Knife"),
|
||||
(re.compile(r"\b(?:skillet|sauté|saute|fry|sear|pan-fry|pan fry)\b", re.IGNORECASE), "Skillet"),
|
||||
(re.compile(r"\b(?:wooden spoon|spatula|stir|fold)\b", re.IGNORECASE), "Spoon"),
|
||||
(re.compile(r"\b(?:pot|boil|simmer|blanch|stock)\b", re.IGNORECASE), "Pot"),
|
||||
(re.compile(r"\b(?:oven|bake|roast|preheat|broil)\b", re.IGNORECASE), "Oven"),
|
||||
(re.compile(r"\b(?:blender|blend|purée|puree|food processor)\b", re.IGNORECASE), "Blender"),
|
||||
(re.compile(r"\b(?:stand mixer|hand mixer|whip|beat)\b", re.IGNORECASE), "Mixer"),
|
||||
(re.compile(r"\b(?:grill|barbecue|char|griddle)\b", re.IGNORECASE), "Grill"),
|
||||
(re.compile(r"\b(?:slow cooker|crockpot|low and slow)\b", re.IGNORECASE), "Slow cooker"),
|
||||
(re.compile(r"\b(?:pressure cooker|instant pot)\b", re.IGNORECASE), "Pressure cooker"),
|
||||
(re.compile(r"\b(?:drain|strain|colander|rinse pasta)\b", re.IGNORECASE), "Colander"),
|
||||
]
|
||||
|
||||
# ── Dataclasses ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StepAnalysis:
|
||||
"""Analysis result for a single direction step."""
|
||||
is_passive: bool
|
||||
detected_minutes: int | None # None when no time mention found in text
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TimeEffortProfile:
|
||||
"""Aggregated time and effort profile for a full recipe."""
|
||||
active_min: int # total minutes requiring active attention
|
||||
passive_min: int # total minutes the cook can step away
|
||||
total_min: int # active_min + passive_min
|
||||
step_analyses: list[StepAnalysis] # one entry per direction step
|
||||
equipment: list[str] # ordered, deduplicated equipment labels
|
||||
effort_label: str # "quick" | "moderate" | "involved"
|
||||
|
||||
|
||||
# ── Core parsing logic ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _extract_minutes(text: str) -> int | None:
|
||||
"""Return the number of minutes mentioned in text, or None.
|
||||
|
||||
Range values (e.g. "15-20 minutes") return the integer midpoint.
|
||||
Hours are converted to minutes. Seconds are rounded up to 1 minute minimum.
|
||||
Result is capped at _MAX_MINUTES_PER_STEP.
|
||||
"""
|
||||
m = _TIME_RE.search(text)
|
||||
if m is None:
|
||||
return None
|
||||
|
||||
if m.group(1) is not None:
|
||||
# Branch A: range match (e.g. "15-20 minutes")
|
||||
low = int(m.group(1))
|
||||
high = int(m.group(2))
|
||||
unit = m.group(3).lower()
|
||||
raw_value: float = (low + high) / 2
|
||||
else:
|
||||
# Branch B: single value match (e.g. "10 minutes")
|
||||
low = int(m.group(4))
|
||||
unit = m.group(5).lower()
|
||||
raw_value = float(low)
|
||||
|
||||
if unit in ("hour", "hr"):
|
||||
minutes: float = raw_value * 60
|
||||
elif unit in ("second", "sec"):
|
||||
minutes = max(1.0, math.ceil(raw_value / 60))
|
||||
else:
|
||||
minutes = raw_value
|
||||
|
||||
return min(int(minutes), _MAX_MINUTES_PER_STEP)
|
||||
|
||||
|
||||
def _classify_passive(text: str) -> bool:
|
||||
"""Return True if the step text matches any passive keyword (whole-word)."""
|
||||
return _PASSIVE_RE.search(text) is not None
|
||||
|
||||
|
||||
def _detect_equipment(all_text: str, has_passive: bool) -> list[str]:
|
||||
"""Return ordered, deduplicated list of equipment labels detected in text.
|
||||
|
||||
all_text should be all direction steps joined with spaces.
|
||||
has_passive controls whether 'Timer' is appended at the end.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
for pattern, label in _EQUIPMENT_RULES:
|
||||
if label not in seen and pattern.search(all_text):
|
||||
seen.add(label)
|
||||
result.append(label)
|
||||
if has_passive and "Timer" not in seen:
|
||||
result.append("Timer")
|
||||
return result
|
||||
|
||||
|
||||
def _effort_label(step_count: int) -> str:
|
||||
"""Derive effort label from step count."""
|
||||
if step_count <= 3:
|
||||
return "quick"
|
||||
if step_count <= 7:
|
||||
return "moderate"
|
||||
return "involved"
|
||||
|
||||
|
||||
def parse_time_effort(directions: list[str]) -> TimeEffortProfile:
|
||||
"""Parse a list of direction strings into a TimeEffortProfile.
|
||||
|
||||
Returns a zero-value profile with empty lists when directions is empty.
|
||||
Never raises — all failures silently produce sensible defaults.
|
||||
"""
|
||||
if not directions:
|
||||
return TimeEffortProfile(
|
||||
active_min=0,
|
||||
passive_min=0,
|
||||
total_min=0,
|
||||
step_analyses=[],
|
||||
equipment=[],
|
||||
effort_label="quick",
|
||||
)
|
||||
|
||||
step_analyses: list[StepAnalysis] = []
|
||||
active_min = 0
|
||||
passive_min = 0
|
||||
has_any_passive = False
|
||||
|
||||
for step in directions:
|
||||
is_passive = _classify_passive(step)
|
||||
detected = _extract_minutes(step)
|
||||
|
||||
if is_passive:
|
||||
has_any_passive = True
|
||||
if detected is not None:
|
||||
passive_min += detected
|
||||
else:
|
||||
if detected is not None:
|
||||
active_min += detected
|
||||
|
||||
step_analyses.append(StepAnalysis(
|
||||
is_passive=is_passive,
|
||||
detected_minutes=detected,
|
||||
))
|
||||
|
||||
combined_text = " ".join(directions)
|
||||
equipment = _detect_equipment(combined_text, has_any_passive)
|
||||
|
||||
return TimeEffortProfile(
|
||||
active_min=active_min,
|
||||
passive_min=passive_min,
|
||||
total_min=active_min + passive_min,
|
||||
step_analyses=step_analyses,
|
||||
equipment=equipment,
|
||||
effort_label=_effort_label(len(directions)),
|
||||
)
|
||||
|
|
@ -44,6 +44,7 @@ KIWI_FEATURES: dict[str, str] = {
|
|||
|
||||
# Paid tier
|
||||
"receipt_ocr": "paid", # BYOK-unlockable
|
||||
"visual_label_capture": "paid", # Camera capture for unenriched barcodes (kiwi#79)
|
||||
"recipe_suggestions": "paid", # BYOK-unlockable
|
||||
"expiry_llm_matching": "paid", # BYOK-unlockable
|
||||
"meal_planning": "free",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ services:
|
|||
CLOUD_AUTH_BYPASS_IPS: ${CLOUD_AUTH_BYPASS_IPS:-}
|
||||
# cf-orch: route LLM calls through the coordinator for managed GPU inference
|
||||
CF_ORCH_URL: http://host.docker.internal:7700
|
||||
# Product identifier for coordinator analytics — per-product VRAM/request breakdown
|
||||
CF_APP_NAME: kiwi
|
||||
# cf-orch streaming proxy — coordinator URL + product key for /proxy/authorize
|
||||
# COORDINATOR_KIWI_KEY must be set in .env (never commit the value)
|
||||
COORDINATOR_URL: http://10.1.10.71:7700
|
||||
COORDINATOR_KIWI_KEY: ${COORDINATOR_KIWI_KEY:-}
|
||||
# Community PostgreSQL — shared across CF products; unset = community features unavailable (fail soft)
|
||||
COMMUNITY_DB_URL: ${COMMUNITY_DB_URL:-}
|
||||
COMMUNITY_PSEUDONYM_SALT: ${COMMUNITY_PSEUDONYM_SALT:-}
|
||||
|
|
|
|||
|
|
@ -8,23 +8,6 @@ services:
|
|||
# Docker can follow the symlink inside the container.
|
||||
- /Library/Assets/kiwi:/Library/Assets/kiwi:rw
|
||||
|
||||
# cf-orch agent sidecar: registers this machine as GPU node "sif" with the coordinator.
|
||||
# The API scheduler uses COORDINATOR_URL to lease VRAM cooperatively; this
|
||||
# agent makes the local VRAM usage visible on the orchestrator dashboard.
|
||||
cf-orch-agent:
|
||||
image: kiwi-api # reuse local api image — cf-core already installed there
|
||||
network_mode: host
|
||||
env_file: .env
|
||||
environment:
|
||||
# Override coordinator URL here or via .env
|
||||
COORDINATOR_URL: ${COORDINATOR_URL:-http://10.1.10.71:7700}
|
||||
command: >
|
||||
conda run -n kiwi cf-orch agent
|
||||
--coordinator ${COORDINATOR_URL:-http://10.1.10.71:7700}
|
||||
--node-id sif
|
||||
--host 0.0.0.0
|
||||
--port 7702
|
||||
--advertise-host ${CF_ORCH_ADVERTISE_HOST:-10.1.10.71}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- api
|
||||
# cf-orch agent sidecar removed 2026-04-24: Sif is now a dedicated compute node
|
||||
# with its own systemd cf-orch-agent service (port 7703, advertise-host 10.1.10.158).
|
||||
# This sidecar was only valid when Kiwi ran on Sif directly.
|
||||
|
|
|
|||
|
|
@ -138,6 +138,103 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Label Capture Panel (paid tier — appears after gap detection) -->
|
||||
<div v-if="capturePhase !== null" class="label-capture-panel">
|
||||
|
||||
<!-- Offer phase -->
|
||||
<div v-if="capturePhase === 'offer'" class="capture-offer">
|
||||
<p class="capture-offer-text">We couldn't find this product. Photograph the nutrition label to add it.</p>
|
||||
<div class="capture-offer-actions">
|
||||
<button class="btn btn-primary" type="button" @click="triggerCaptureLabelInput">
|
||||
Capture label
|
||||
</button>
|
||||
<button class="btn btn-ghost" type="button" @click="dismissCapture">
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref="captureFileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
style="display: none"
|
||||
@change="handleLabelPhotoSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Uploading / processing phase -->
|
||||
<div v-else-if="capturePhase === 'uploading'" class="capture-processing">
|
||||
<div class="loading-inline">
|
||||
<div class="spinner spinner-sm"></div>
|
||||
<span>Reading the label…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Review phase -->
|
||||
<div v-else-if="capturePhase === 'reviewing' && captureExtraction" class="capture-review">
|
||||
<p class="capture-review-note">
|
||||
Check the details below.
|
||||
<span v-if="captureExtraction.needs_review" class="capture-review-low-conf">
|
||||
Fields highlighted in amber weren't fully legible — please verify them.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Product name</label>
|
||||
<input v-model="captureReview.product_name" type="text" class="form-input" placeholder="Product name" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Brand</label>
|
||||
<input v-model="captureReview.brand" type="text" class="form-input" placeholder="Brand (optional)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="form-section-label">Nutrition per serving</p>
|
||||
<div class="capture-nutrition-grid">
|
||||
<div
|
||||
v-for="field in captureNutritionFields"
|
||||
:key="field.key"
|
||||
class="form-group"
|
||||
>
|
||||
<label
|
||||
:class="['form-label', { 'capture-field-amber': captureExtraction.needs_review && captureExtraction[field.src as keyof typeof captureExtraction] == null }]"
|
||||
>{{ field.label }}</label>
|
||||
<input
|
||||
v-model="captureReview[field.key as keyof typeof captureReview]"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.1"
|
||||
class="form-input"
|
||||
:placeholder="field.unit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: var(--spacing-sm)">
|
||||
<label class="form-label">Ingredients (comma-separated)</label>
|
||||
<input v-model="captureReview.ingredients" type="text" class="form-input" placeholder="flour, water, salt…" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Allergens (comma-separated)</label>
|
||||
<input v-model="captureReview.allergens" type="text" class="form-input" placeholder="wheat, milk…" />
|
||||
</div>
|
||||
|
||||
<div class="capture-review-actions">
|
||||
<button class="btn btn-primary" type="button" :disabled="captureLoading" @click="confirmCapture">
|
||||
<span v-if="captureLoading"><div class="spinner spinner-sm"></div></span>
|
||||
<span v-else>Looks good — save</span>
|
||||
</button>
|
||||
<button class="btn btn-ghost" type="button" @click="capturePhase = 'offer'">
|
||||
Retake photo
|
||||
</button>
|
||||
<button class="btn btn-ghost" type="button" @click="dismissCapture">
|
||||
Discard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Camera Scan Panel -->
|
||||
<div v-if="scanMode === 'camera'" class="scan-panel">
|
||||
<div class="upload-area" @click="triggerBarcodeInput">
|
||||
|
|
@ -622,7 +719,7 @@ import { storeToRefs } from 'pinia'
|
|||
import { useInventoryStore } from '../stores/inventory'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { inventoryAPI } from '../services/api'
|
||||
import type { InventoryItem } from '../services/api'
|
||||
import type { InventoryItem, LabelCaptureResult } from '../services/api'
|
||||
import { formatQuantity } from '../utils/units'
|
||||
import EditItemModal from './EditItemModal.vue'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
|
|
@ -684,6 +781,16 @@ function daysLabel(dateStr: string): string {
|
|||
const scanMode = ref<'gun' | 'camera' | 'manual'>('gun')
|
||||
|
||||
// Options for button groups
|
||||
// Label capture nutrition field descriptors used in the review form
|
||||
const captureNutritionFields = [
|
||||
{ key: 'calories', src: 'calories', label: 'Calories', unit: 'kcal' },
|
||||
{ key: 'fat_g', src: 'fat_g', label: 'Total fat', unit: 'g' },
|
||||
{ key: 'saturated_fat_g', src: 'saturated_fat_g', label: 'Saturated fat', unit: 'g' },
|
||||
{ key: 'carbs_g', src: 'carbs_g', label: 'Carbs', unit: 'g' },
|
||||
{ key: 'protein_g', src: 'protein_g', label: 'Protein', unit: 'g' },
|
||||
{ key: 'sodium_mg', src: 'sodium_mg', label: 'Sodium', unit: 'mg' },
|
||||
]
|
||||
|
||||
const locations = [
|
||||
{ value: 'fridge', label: 'Fridge', icon: '🧊' },
|
||||
{ value: 'freezer', label: 'Freezer', icon: '❄️' },
|
||||
|
|
@ -780,6 +887,29 @@ const barcodeQuantity = ref(1)
|
|||
const barcodeLoading = ref(false)
|
||||
const barcodeResults = ref<Array<{ type: string; message: string }>>([])
|
||||
|
||||
// Label Capture Flow (kiwi#79)
|
||||
type CapturePhase = 'offer' | 'uploading' | 'reviewing' | null
|
||||
const capturePhase = ref<CapturePhase>(null)
|
||||
const captureBarcode = ref('')
|
||||
const captureLocation = ref('pantry')
|
||||
const captureQuantity = ref(1)
|
||||
const captureLoading = ref(false)
|
||||
const captureFileInput = ref<HTMLInputElement | null>(null)
|
||||
const captureExtraction = ref<LabelCaptureResult | null>(null)
|
||||
// Editable review form — populated from extraction, user may correct fields
|
||||
const captureReview = ref({
|
||||
product_name: '',
|
||||
brand: '',
|
||||
calories: '' as string,
|
||||
fat_g: '' as string,
|
||||
saturated_fat_g: '' as string,
|
||||
carbs_g: '' as string,
|
||||
protein_g: '' as string,
|
||||
sodium_mg: '' as string,
|
||||
ingredients: '',
|
||||
allergens: '',
|
||||
})
|
||||
|
||||
// Manual Form
|
||||
const manualForm = ref({
|
||||
name: '',
|
||||
|
|
@ -935,6 +1065,15 @@ async function handleScannerGunInput() {
|
|||
message: `Added: ${productName}${productBrand} to ${scannerLocation.value}`,
|
||||
})
|
||||
await refreshItems()
|
||||
} else if (item?.needs_visual_capture) {
|
||||
captureBarcode.value = barcode
|
||||
captureLocation.value = scannerLocation.value
|
||||
captureQuantity.value = scannerQuantity.value
|
||||
capturePhase.value = 'offer'
|
||||
scannerResults.value.push({
|
||||
type: 'info',
|
||||
message: item.message,
|
||||
})
|
||||
} else if (item?.needs_manual_entry) {
|
||||
// Barcode not found in any database — guide user to manual entry
|
||||
scannerResults.value.push({
|
||||
|
|
@ -1007,6 +1146,88 @@ async function handleBarcodeImageSelect(e: Event) {
|
|||
}
|
||||
}
|
||||
|
||||
// Label Capture Functions
|
||||
|
||||
function triggerCaptureLabelInput() {
|
||||
captureFileInput.value?.click()
|
||||
}
|
||||
|
||||
function dismissCapture() {
|
||||
capturePhase.value = null
|
||||
captureBarcode.value = ''
|
||||
captureExtraction.value = null
|
||||
}
|
||||
|
||||
async function handleLabelPhotoSelect(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
captureLoading.value = true
|
||||
capturePhase.value = 'uploading'
|
||||
|
||||
try {
|
||||
const result = await inventoryAPI.captureLabelPhoto(file, captureBarcode.value)
|
||||
captureExtraction.value = result
|
||||
// Pre-populate the review form with extracted values
|
||||
captureReview.value = {
|
||||
product_name: result.product_name || '',
|
||||
brand: result.brand || '',
|
||||
calories: result.calories != null ? String(result.calories) : '',
|
||||
fat_g: result.fat_g != null ? String(result.fat_g) : '',
|
||||
saturated_fat_g: result.saturated_fat_g != null ? String(result.saturated_fat_g) : '',
|
||||
carbs_g: result.carbs_g != null ? String(result.carbs_g) : '',
|
||||
protein_g: result.protein_g != null ? String(result.protein_g) : '',
|
||||
sodium_mg: result.sodium_mg != null ? String(result.sodium_mg) : '',
|
||||
ingredients: (result.ingredient_names || []).join(', '),
|
||||
allergens: (result.allergens || []).join(', '),
|
||||
}
|
||||
capturePhase.value = 'reviewing'
|
||||
} catch {
|
||||
showToast('Could not read the label. Please try again or add manually.', 'error')
|
||||
capturePhase.value = 'offer'
|
||||
} finally {
|
||||
captureLoading.value = false
|
||||
if (target) target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmCapture() {
|
||||
if (!captureBarcode.value) return
|
||||
|
||||
captureLoading.value = true
|
||||
try {
|
||||
const toNum = (s: string) => s ? parseFloat(s) || null : null
|
||||
const toList = (s: string) => s.split(',').map(x => x.trim()).filter(Boolean)
|
||||
|
||||
await inventoryAPI.confirmLabelCapture({
|
||||
barcode: captureBarcode.value,
|
||||
product_name: captureReview.value.product_name || null,
|
||||
brand: captureReview.value.brand || null,
|
||||
calories: toNum(captureReview.value.calories),
|
||||
fat_g: toNum(captureReview.value.fat_g),
|
||||
saturated_fat_g: toNum(captureReview.value.saturated_fat_g),
|
||||
carbs_g: toNum(captureReview.value.carbs_g),
|
||||
protein_g: toNum(captureReview.value.protein_g),
|
||||
sodium_mg: toNum(captureReview.value.sodium_mg),
|
||||
ingredient_names: toList(captureReview.value.ingredients),
|
||||
allergens: toList(captureReview.value.allergens),
|
||||
confidence: captureExtraction.value?.confidence ?? 0,
|
||||
location: captureLocation.value,
|
||||
quantity: captureQuantity.value,
|
||||
auto_add: true,
|
||||
})
|
||||
const name = captureReview.value.product_name || 'item'
|
||||
showToast(`${name} saved and added to ${captureLocation.value}`, 'success')
|
||||
await refreshItems()
|
||||
dismissCapture()
|
||||
} catch {
|
||||
showToast('Could not save. Please try again.', 'error')
|
||||
} finally {
|
||||
captureLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Manual Add Functions
|
||||
async function addManualItem() {
|
||||
const { name, brand, quantity, unit, location, expirationDate } = manualForm.value
|
||||
|
|
@ -1614,6 +1835,79 @@ function getItemClass(item: InventoryItem): string {
|
|||
border: 1px solid var(--color-warning-border, #fcd34d);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
LABEL CAPTURE FLOW (kiwi#79)
|
||||
============================================ */
|
||||
.label-capture-panel {
|
||||
margin: var(--spacing-md) 0;
|
||||
padding: var(--spacing-md);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.capture-offer-text {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 var(--spacing-md);
|
||||
}
|
||||
|
||||
.capture-offer-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.capture-processing {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-md) 0;
|
||||
}
|
||||
|
||||
.capture-review-note {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 var(--spacing-md);
|
||||
}
|
||||
|
||||
.capture-review-low-conf {
|
||||
color: var(--color-amber, #d97706);
|
||||
font-size: var(--font-size-xs);
|
||||
display: block;
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.form-section-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: var(--spacing-md) 0 var(--spacing-sm);
|
||||
}
|
||||
|
||||
.capture-nutrition-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* Amber highlight for unread/low-confidence label fields */
|
||||
.capture-field-amber {
|
||||
color: var(--color-amber, #d97706);
|
||||
}
|
||||
|
||||
.capture-field-amber + input {
|
||||
border-color: var(--color-amber, #d97706);
|
||||
}
|
||||
|
||||
.capture-review-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
EXPORT CARD
|
||||
============================================ */
|
||||
|
|
|
|||
|
|
@ -69,6 +69,12 @@
|
|||
>
|
||||
{{ sub.subcategory }}
|
||||
<span class="cat-count">{{ sub.recipe_count }}</span>
|
||||
<span
|
||||
v-if="sub.recipe_count === 0"
|
||||
class="tag-cta"
|
||||
title="Know a recipe in this category? Tag it!"
|
||||
@click.stop="openTagModal(sub.subcategory)"
|
||||
>+</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
|
@ -103,6 +109,12 @@
|
|||
@click="setSort('alpha_desc')"
|
||||
title="Alphabetical Z→A"
|
||||
>Z→A</button>
|
||||
<button
|
||||
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'match' }]"
|
||||
:disabled="pantryCount === 0"
|
||||
@click="setSort('match')"
|
||||
:title="pantryCount > 0 ? 'Sort by pantry match %' : 'Add items to pantry to sort by match'"
|
||||
>Best match</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -151,6 +163,19 @@
|
|||
{{ Math.round(recipe.match_pct * 100) }}%
|
||||
</span>
|
||||
|
||||
<!-- Time & effort split pill -->
|
||||
<span
|
||||
v-if="recipe.active_min !== null"
|
||||
class="time-split-pill"
|
||||
:title="`~${formatMin(recipe.active_min)} active · ~${formatMin(recipe.passive_min ?? 0)} passive`"
|
||||
>
|
||||
<span class="pill-active">🧑🍳 ~{{ formatMin(recipe.active_min) }}</span>
|
||||
<span
|
||||
v-if="recipe.passive_min !== null && recipe.passive_min > 0"
|
||||
class="pill-passive"
|
||||
>💤 ~{{ formatMin(recipe.passive_min) }}</span>
|
||||
</span>
|
||||
|
||||
<!-- Save toggle -->
|
||||
<button
|
||||
class="btn btn-secondary btn-xs"
|
||||
|
|
@ -180,11 +205,84 @@
|
|||
@saved="savingRecipe = null"
|
||||
@unsave="savingRecipe && doUnsave(savingRecipe.id)"
|
||||
/>
|
||||
|
||||
<!-- Community tag modal — opened from zero-count subcategory CTA -->
|
||||
<div v-if="tagModal.open" class="modal-backdrop" @click.self="tagModal.open = false">
|
||||
<div class="modal-box" role="dialog" aria-modal="true" aria-label="Tag a recipe">
|
||||
<h3 class="text-md font-semibold mb-sm">Tag a recipe as {{ tagModal.subcategory }}</h3>
|
||||
<p class="text-sm text-secondary mb-sm">
|
||||
Search for a recipe you know belongs here. Your tag helps other users discover it.
|
||||
</p>
|
||||
|
||||
<!-- Recipe search -->
|
||||
<input
|
||||
class="form-input mb-xs"
|
||||
v-model="tagModal.searchQuery"
|
||||
placeholder="Search recipe title…"
|
||||
@input="onTagSearchInput"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div v-if="tagModal.searching" class="text-sm text-secondary mb-xs">Searching…</div>
|
||||
<ul v-else-if="tagModal.results.length > 0" class="tag-search-results mb-sm">
|
||||
<li
|
||||
v-for="r in tagModal.results"
|
||||
:key="r.id"
|
||||
:class="['tag-result-row', { selected: tagModal.selectedRecipe?.id === r.id }]"
|
||||
@click="tagModal.selectedRecipe = r"
|
||||
>
|
||||
<span class="tag-result-title">{{ r.title }}</span>
|
||||
<span class="tag-result-check" v-if="tagModal.selectedRecipe?.id === r.id">✓</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="tagModal.searchQuery.length > 2" class="text-sm text-secondary mb-sm">
|
||||
No results — try a different title.
|
||||
</p>
|
||||
|
||||
<!-- Location correction (pre-filled from active browse context) -->
|
||||
<div class="form-group mb-xs">
|
||||
<label class="form-label text-xs">Domain</label>
|
||||
<select class="form-input" v-model="tagModal.domain">
|
||||
<option v-for="d in domains" :key="d.id" :value="d.id">{{ d.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group mb-xs">
|
||||
<label class="form-label text-xs">Category</label>
|
||||
<select class="form-input" v-model="tagModal.category">
|
||||
<option v-for="c in categories" :key="c.category" :value="c.category">
|
||||
{{ c.category }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group mb-sm">
|
||||
<label class="form-label text-xs">Subcategory (optional)</label>
|
||||
<select class="form-input" v-model="tagModal.subcategoryEdit">
|
||||
<option value="">— none (category level) —</option>
|
||||
<option v-for="s in subcategories" :key="s.subcategory" :value="s.subcategory">
|
||||
{{ s.subcategory }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-sm">
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="!tagModal.selectedRecipe || tagModal.submitting"
|
||||
@click="submitTag"
|
||||
>
|
||||
<span v-if="tagModal.submitting">Submitting…</span>
|
||||
<span v-else>Tag this recipe</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="tagModal.open = false">Cancel</button>
|
||||
</div>
|
||||
<p v-if="tagModal.error" class="text-sm status-badge status-error mt-xs">{{ tagModal.error }}</p>
|
||||
<p v-if="tagModal.success" class="text-sm status-badge status-ok mt-xs">{{ tagModal.success }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { browserAPI, type BrowserDomain, type BrowserCategory, type BrowserSubcategory, type BrowserRecipe } from '../services/api'
|
||||
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||
import { useInventoryStore } from '../stores/inventory'
|
||||
|
|
@ -212,8 +310,26 @@ const loadingDomains = ref(false)
|
|||
const loadingRecipes = ref(false)
|
||||
const savingRecipe = ref<BrowserRecipe | null>(null)
|
||||
const searchQuery = ref('')
|
||||
const sortOrder = ref<'default' | 'alpha' | 'alpha_desc'>('default')
|
||||
const sortOrder = ref<'default' | 'alpha' | 'alpha_desc' | 'match'>('default')
|
||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
let tagSearchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// ── Tag modal state ────────────────────────────────────────────────────────
|
||||
const tagModal = ref({
|
||||
open: false,
|
||||
subcategory: '', // display label (pre-filled from CTA)
|
||||
domain: '', // editable, pre-filled
|
||||
category: '', // editable, pre-filled
|
||||
subcategoryEdit: '', // editable, pre-filled
|
||||
searchQuery: '',
|
||||
searching: false,
|
||||
results: [] as Array<{ id: number; title: string }>,
|
||||
selectedRecipe: null as { id: number; title: string } | null,
|
||||
submitting: false,
|
||||
error: '',
|
||||
success: '',
|
||||
})
|
||||
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||||
const allCountsZero = computed(() =>
|
||||
|
|
@ -237,6 +353,18 @@ function matchBadgeClass(pct: number): string {
|
|||
return 'status-secondary'
|
||||
}
|
||||
|
||||
/**
|
||||
* Format minutes as a compact display string.
|
||||
* < 60 → "15m"
|
||||
* >= 60 → "1h 30m" (omits minutes when zero: "2h")
|
||||
*/
|
||||
function formatMin(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes}m`
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = minutes % 60
|
||||
return m === 0 ? `${h}h` : `${h}h ${m}m`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadingDomains.value = true
|
||||
try {
|
||||
|
|
@ -258,13 +386,23 @@ function onSearchInput() {
|
|||
}, 350)
|
||||
}
|
||||
|
||||
function setSort(s: 'default' | 'alpha' | 'alpha_desc') {
|
||||
function setSort(s: 'default' | 'alpha' | 'alpha_desc' | 'match') {
|
||||
if (sortOrder.value === s) return
|
||||
sortOrder.value = s
|
||||
page.value = 1
|
||||
loadRecipes()
|
||||
}
|
||||
|
||||
// When pantry items first become available while browsing, auto-engage match sort.
|
||||
// When pantry empties out mid-session, drop back to default so the button disables cleanly.
|
||||
watch(pantryCount, (newCount, oldCount) => {
|
||||
if (newCount > 0 && oldCount === 0 && activeCategory.value) {
|
||||
setSort('match')
|
||||
} else if (newCount === 0 && sortOrder.value === 'match') {
|
||||
setSort('default')
|
||||
}
|
||||
})
|
||||
|
||||
async function selectDomain(domainId: string) {
|
||||
activeDomain.value = domainId
|
||||
activeCategory.value = null
|
||||
|
|
@ -359,6 +497,73 @@ async function doUnsave(recipeId: number) {
|
|||
savingRecipe.value = null
|
||||
await savedStore.unsave(recipeId)
|
||||
}
|
||||
|
||||
// ── Tag modal ──────────────────────────────────────────────────────────────
|
||||
|
||||
function openTagModal(subcategoryName: string) {
|
||||
Object.assign(tagModal.value, {
|
||||
open: true,
|
||||
subcategory: subcategoryName,
|
||||
domain: activeDomain.value ?? '',
|
||||
category: activeCategory.value ?? '',
|
||||
subcategoryEdit: subcategoryName,
|
||||
searchQuery: '',
|
||||
searching: false,
|
||||
results: [],
|
||||
selectedRecipe: null,
|
||||
submitting: false,
|
||||
error: '',
|
||||
success: '',
|
||||
})
|
||||
}
|
||||
|
||||
function onTagSearchInput() {
|
||||
if (tagSearchDebounce) clearTimeout(tagSearchDebounce)
|
||||
const q = tagModal.value.searchQuery.trim()
|
||||
if (q.length < 3) {
|
||||
tagModal.value.results = []
|
||||
return
|
||||
}
|
||||
tagSearchDebounce = setTimeout(async () => {
|
||||
tagModal.value.searching = true
|
||||
try {
|
||||
// Re-use the browser API: browse all recipes filtered by title substring
|
||||
const res = await browserAPI.browse('_all', '_all', { page: 1, q })
|
||||
tagModal.value.results = (res.recipes ?? []).slice(0, 8).map(
|
||||
(r: { id: number; title: string }) => ({ id: r.id, title: r.title })
|
||||
)
|
||||
} catch {
|
||||
tagModal.value.results = []
|
||||
} finally {
|
||||
tagModal.value.searching = false
|
||||
}
|
||||
}, 350)
|
||||
}
|
||||
|
||||
async function submitTag() {
|
||||
const m = tagModal.value
|
||||
if (!m.selectedRecipe) return
|
||||
m.submitting = true
|
||||
m.error = ''
|
||||
m.success = ''
|
||||
try {
|
||||
await browserAPI.submitRecipeTag({
|
||||
recipe_id: m.selectedRecipe.id,
|
||||
domain: m.domain,
|
||||
category: m.category,
|
||||
subcategory: m.subcategoryEdit || null,
|
||||
pseudonym: 'anon', // TODO: wire real pseudonym from community store
|
||||
})
|
||||
m.success = `Tagged! It will appear here once a second user confirms.`
|
||||
setTimeout(() => { m.open = false }, 2500)
|
||||
} catch (err: any) {
|
||||
m.error = err?.message === '409'
|
||||
? 'You have already tagged this recipe here.'
|
||||
: 'Failed to submit — please try again.'
|
||||
} finally {
|
||||
m.submitting = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -519,4 +724,106 @@ async function doUnsave(recipeId: number) {
|
|||
.flex-shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Time & effort split pill ──────────────────────────────────────────── */
|
||||
.time-split-pill {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
border-radius: var(--radius-pill, 999px);
|
||||
overflow: hidden;
|
||||
font-size: var(--font-size-xs, 0.72rem);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.pill-active {
|
||||
padding: 2px 6px;
|
||||
background: rgba(232, 168, 32, 0.18);
|
||||
color: #f0bc48;
|
||||
border-radius: var(--radius-pill, 999px) 0 0 var(--radius-pill, 999px);
|
||||
}
|
||||
|
||||
/* When there is no passive segment, active gets full pill rounding */
|
||||
.time-split-pill:not(:has(.pill-passive)) .pill-active {
|
||||
border-radius: var(--radius-pill, 999px);
|
||||
}
|
||||
|
||||
.pill-passive {
|
||||
padding: 2px 6px;
|
||||
background: rgba(41, 128, 185, 0.15);
|
||||
color: #5dade2;
|
||||
border-radius: 0 var(--radius-pill, 999px) var(--radius-pill, 999px) 0;
|
||||
}
|
||||
|
||||
/* ── Community tag CTA ──────────────────────────────────────────────────── */
|
||||
.tag-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 0.25rem;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
border-radius: 50%;
|
||||
font-size: 0.75rem;
|
||||
background: var(--color-accent, #7c6fcd);
|
||||
color: #fff;
|
||||
opacity: 0.75;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.tag-cta:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Tag modal ──────────────────────────────────────────────────────────── */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
.modal-box {
|
||||
background: var(--color-surface, #fff);
|
||||
border-radius: var(--radius-md, 0.5rem);
|
||||
padding: 1.5rem;
|
||||
max-width: 28rem;
|
||||
width: 90vw;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
|
||||
}
|
||||
.tag-search-results {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 1px solid var(--color-border, #e0e0e0);
|
||||
border-radius: var(--radius-sm, 0.25rem);
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.tag-result-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.tag-result-row:hover,
|
||||
.tag-result-row.selected {
|
||||
background: var(--color-hover, #f0eeff);
|
||||
}
|
||||
.tag-result-title {
|
||||
font-size: 0.875rem;
|
||||
flex: 1;
|
||||
}
|
||||
.tag-result-check {
|
||||
color: var(--color-accent, #7c6fcd);
|
||||
font-size: 0.875rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,17 @@
|
|||
@click="showSaveModal = true"
|
||||
:aria-label="isSaved ? 'Edit saved recipe' : 'Save recipe'"
|
||||
>{{ isSaved ? '★ Saved' : '☆ Save' }}</button>
|
||||
<button class="btn-close" @click="$emit('close')" aria-label="Close panel">✕</button>
|
||||
<!-- Cook mode toggle -->
|
||||
<button
|
||||
v-if="recipe.directions.length > 0"
|
||||
class="btn btn-cook"
|
||||
:class="{ 'btn-cook--active': cookModeActive }"
|
||||
@click="cookModeActive ? exitCookMode() : enterCookMode()"
|
||||
:aria-label="cookModeActive ? 'Exit cook mode' : 'Enter cook mode'"
|
||||
:aria-pressed="cookModeActive"
|
||||
>{{ cookModeActive ? '✕ Exit' : 'Cook' }}</button>
|
||||
|
||||
<button class="btn-close" @click="$emit('close')" aria-label="Close panel">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="recipe.notes" class="detail-notes">{{ recipe.notes }}</p>
|
||||
|
|
@ -33,8 +43,19 @@
|
|||
>View original ↗</a>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable body -->
|
||||
<div class="detail-body">
|
||||
<!-- Cook mode bar: progress + step counter -->
|
||||
<div v-if="cookModeActive" class="cook-mode-bar" role="status" :aria-label="`Step ${cookStep + 1} of ${cookStepCount}`">
|
||||
<div class="cook-progress-track">
|
||||
<div
|
||||
class="cook-progress-fill"
|
||||
:style="{ width: `${cookProgress * 100}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="cook-step-counter">Step {{ cookStep + 1 }} of {{ cookStepCount }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Normal scrollable body -->
|
||||
<div v-if="!cookModeActive" class="detail-body">
|
||||
|
||||
<!-- Serving multiplier -->
|
||||
<div class="serving-scale-row">
|
||||
|
|
@ -51,7 +72,15 @@
|
|||
</div>
|
||||
|
||||
<!-- Ingredients: have vs. need in a two-column layout -->
|
||||
<div class="ingredients-grid">
|
||||
<details open class="ingredients-collapsible">
|
||||
<summary class="ingredients-collapsible-summary">
|
||||
Ingredients
|
||||
<span class="ingr-summary-counts">
|
||||
<span v-if="recipe.matched_ingredients?.length" class="ingr-count ingr-count-have">{{ recipe.matched_ingredients.length }} ✓</span>
|
||||
<span v-if="recipe.missing_ingredients?.length" class="ingr-count ingr-count-need">{{ recipe.missing_ingredients.length }} needed</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="ingredients-grid">
|
||||
<div v-if="recipe.matched_ingredients?.length > 0" class="ingredient-col ingredient-col-have">
|
||||
<h3 class="col-label col-label-have">From your pantry</h3>
|
||||
<ul class="ingredient-list">
|
||||
|
|
@ -97,6 +126,35 @@
|
|||
@click="toggleSelectAll"
|
||||
>{{ checkedIngredients.size === recipe.missing_ingredients.length ? 'Deselect all' : 'Select all' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Time & effort summary cards -->
|
||||
<div v-if="recipe.time_effort" class="effort-summary">
|
||||
<div class="effort-card effort-card-active">
|
||||
<span class="effort-label">Active</span>
|
||||
<span class="effort-value">{{ formatDetailMin(recipe.time_effort.active_min) }}</span>
|
||||
</div>
|
||||
<div v-if="recipe.time_effort.passive_min > 0" class="effort-card effort-card-passive">
|
||||
<span class="effort-label">Hands-off</span>
|
||||
<span class="effort-value">{{ formatDetailMin(recipe.time_effort.passive_min) }}</span>
|
||||
</div>
|
||||
<div class="effort-card effort-card-total">
|
||||
<span class="effort-label">Total</span>
|
||||
<span class="effort-value">{{ formatDetailMin(recipe.time_effort.total_min) }}</span>
|
||||
</div>
|
||||
<div class="effort-level-badge" :class="'effort-' + recipe.time_effort.effort_label">
|
||||
{{ recipe.time_effort.effort_label }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equipment chips -->
|
||||
<div v-if="recipe.time_effort?.equipment?.length" class="equipment-chips">
|
||||
<span
|
||||
v-for="eq in recipe.time_effort.equipment"
|
||||
:key="eq"
|
||||
class="equipment-chip"
|
||||
>{{ EQUIPMENT_ICONS[eq] ?? '🍴' }} {{ eq }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Swap candidates -->
|
||||
|
|
@ -145,18 +203,73 @@
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Directions -->
|
||||
<div v-if="recipe.directions.length > 0" class="detail-section">
|
||||
<h3 class="section-label">Steps</h3>
|
||||
<ol class="directions-list">
|
||||
<li v-for="(step, i) in recipe.directions" :key="i" class="text-sm direction-step">{{ step }}</li>
|
||||
<!-- Directions (annotated) -->
|
||||
<details open v-if="recipe.directions.length > 0" class="steps-collapsible">
|
||||
<summary class="steps-collapsible-summary">
|
||||
Steps <span class="steps-count">({{ recipe.directions.length }})</span>
|
||||
</summary>
|
||||
<ol class="directions-list directions-list-annotated">
|
||||
<li
|
||||
v-for="(step, i) in recipe.directions"
|
||||
:key="i"
|
||||
class="text-sm direction-step direction-step-annotated"
|
||||
:class="{ 'step-passive': stepAnalysis(i)?.is_passive }"
|
||||
>
|
||||
<div class="step-badge-row">
|
||||
<span v-if="stepAnalysis(i)?.is_passive" class="step-type-badge step-type-wait">Wait</span>
|
||||
<span v-else-if="stepAnalysis(i)" class="step-type-badge step-type-active">Active</span>
|
||||
</div>
|
||||
<p class="step-text">{{ step }}</p>
|
||||
<p v-if="passiveHint(stepAnalysis(i))" class="step-passive-hint">{{ passiveHint(stepAnalysis(i)) }}</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Bottom padding so last step isn't hidden behind sticky footer -->
|
||||
<div style="height: var(--spacing-xl)" />
|
||||
</div>
|
||||
|
||||
<!-- Cook mode: single-step view -->
|
||||
<div
|
||||
v-else
|
||||
class="detail-body cook-step-view"
|
||||
@touchstart.passive="onTouchStart"
|
||||
@touchend.passive="onTouchEnd"
|
||||
>
|
||||
<div class="cook-step-label">STEP {{ cookStep + 1 }}</div>
|
||||
|
||||
<div v-if="currentStepAnalysis" class="cook-step-badge-row">
|
||||
<span
|
||||
class="cook-step-badge"
|
||||
:class="currentStepAnalysis.is_passive ? 'cook-badge--wait' : 'cook-badge--active'"
|
||||
>{{ currentStepAnalysis.is_passive ? 'Wait' : 'Active' }}</span>
|
||||
</div>
|
||||
|
||||
<p class="cook-step-text">{{ recipe.directions[cookStep] }}</p>
|
||||
|
||||
<p
|
||||
v-if="currentStepAnalysis?.detected_minutes != null"
|
||||
class="cook-step-hint"
|
||||
>~{{ currentStepAnalysis.detected_minutes }} min hands-off</p>
|
||||
|
||||
<div class="cook-nav">
|
||||
<button
|
||||
class="btn cook-nav-prev"
|
||||
:class="{ 'cook-nav--disabled': cookStep === 0 }"
|
||||
:disabled="cookStep === 0"
|
||||
:aria-label="cookStep === 0 ? 'No previous step' : 'Previous step'"
|
||||
@click="prevStep"
|
||||
>← Prev</button>
|
||||
|
||||
<button
|
||||
class="btn cook-nav-next"
|
||||
:class="{ 'cook-nav--done': isLastStep }"
|
||||
:aria-label="isLastStep ? 'Done cooking' : 'Next step'"
|
||||
@click="nextStep"
|
||||
>{{ isLastStep ? 'Done ✓' : 'Next →' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky footer -->
|
||||
<div class="detail-footer">
|
||||
<div v-if="cookDone" class="cook-success">
|
||||
|
|
@ -217,14 +330,26 @@ import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
|||
import { useRecipesStore } from '../stores/recipes'
|
||||
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||
import { inventoryAPI } from '../services/api'
|
||||
import type { RecipeSuggestion, GroceryLink } from '../services/api'
|
||||
import type { RecipeSuggestion, GroceryLink, StepAnalysis } from '../services/api'
|
||||
import SaveRecipeModal from './SaveRecipeModal.vue'
|
||||
|
||||
const dialogRef = ref<HTMLElement | null>(null)
|
||||
let previousFocus: HTMLElement | null = null
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') emit('close')
|
||||
if (e.key === 'Escape') {
|
||||
emit('close')
|
||||
return
|
||||
}
|
||||
if (cookModeActive.value) {
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
nextStep()
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
prevStep()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
|
@ -260,6 +385,67 @@ const showSaveModal = ref(false)
|
|||
const isSaved = computed(() => savedStore.isSaved(props.recipe.id))
|
||||
|
||||
const cookDone = ref(false)
|
||||
|
||||
// ── Cook mode ─────────────────────────────────────────────
|
||||
const cookModeActive = ref(false)
|
||||
const cookStep = ref(0) // 0-indexed
|
||||
|
||||
function enterCookMode() {
|
||||
cookModeActive.value = true
|
||||
cookStep.value = 0
|
||||
}
|
||||
|
||||
function exitCookMode() {
|
||||
cookModeActive.value = false
|
||||
cookStep.value = 0
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
const lastIdx = props.recipe.directions.length - 1
|
||||
if (cookStep.value < lastIdx) {
|
||||
cookStep.value++
|
||||
} else {
|
||||
handleCook()
|
||||
exitCookMode()
|
||||
}
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
if (cookStep.value > 0) cookStep.value--
|
||||
}
|
||||
|
||||
// Reads step_analyses from kiwi#50 time_effort — null-safe
|
||||
const currentStepAnalysis = computed(() => {
|
||||
return props.recipe.time_effort?.step_analyses?.[cookStep.value] ?? null
|
||||
})
|
||||
|
||||
const cookStepCount = computed(() => props.recipe.directions.length)
|
||||
const isLastStep = computed(() => cookStep.value === cookStepCount.value - 1)
|
||||
const cookProgress = computed(() =>
|
||||
cookStepCount.value > 1 ? cookStep.value / (cookStepCount.value - 1) : 1
|
||||
)
|
||||
|
||||
// Touch state for swipe navigation
|
||||
const touchStartX = ref(0)
|
||||
const touchStartY = ref(0)
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
touchStartX.value = e.changedTouches[0]!.clientX
|
||||
touchStartY.value = e.changedTouches[0]!.clientY
|
||||
}
|
||||
|
||||
function onTouchEnd(e: TouchEvent) {
|
||||
const dx = e.changedTouches[0]!.clientX - touchStartX.value
|
||||
const dy = e.changedTouches[0]!.clientY - touchStartY.value
|
||||
// Require predominantly horizontal gesture
|
||||
if (Math.abs(dx) >= 40 && Math.abs(dy) < 80) {
|
||||
if (dx < 0) {
|
||||
nextStep() // swipe left → next
|
||||
} else {
|
||||
prevStep() // swipe right → prev
|
||||
}
|
||||
}
|
||||
}
|
||||
const shareCopied = ref(false)
|
||||
|
||||
// Serving scale multiplier: 1×, 2×, 3×, 4×
|
||||
|
|
@ -325,6 +511,39 @@ function scaleIngredient(ing: string, scale: number): string {
|
|||
return scaled + ing.slice(m[0].length)
|
||||
}
|
||||
|
||||
// Time & effort helpers
|
||||
function formatDetailMin(minutes: number): string {
|
||||
if (minutes < 60) return `${minutes} min`
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = minutes % 60
|
||||
return m === 0 ? `${h} hr` : `${h} hr ${m} min`
|
||||
}
|
||||
|
||||
const EQUIPMENT_ICONS: Record<string, string> = {
|
||||
oven: '♨',
|
||||
stovetop: '🔥',
|
||||
blender: '⚡',
|
||||
'food processor': '⚡',
|
||||
microwave: '📡',
|
||||
grill: '🔥',
|
||||
'slow cooker': '⏲',
|
||||
'instant pot': '⏲',
|
||||
mixer: '🌀',
|
||||
skillet: '🍳',
|
||||
'cast iron': '🍳',
|
||||
wok: '🍳',
|
||||
}
|
||||
|
||||
function stepAnalysis(i: number): StepAnalysis | null {
|
||||
return props.recipe.time_effort?.step_analyses?.[i] ?? null
|
||||
}
|
||||
|
||||
function passiveHint(analysis: StepAnalysis | null): string {
|
||||
if (!analysis?.is_passive) return ''
|
||||
if (analysis.detected_minutes) return `~${analysis.detected_minutes} min hands-off`
|
||||
return 'Hands-off time'
|
||||
}
|
||||
|
||||
// Shopping: add purchased ingredients to pantry
|
||||
const checkedIngredients = ref<Set<string>>(new Set())
|
||||
const addingToPantry = ref(false)
|
||||
|
|
@ -490,6 +709,36 @@ function handleCook() {
|
|||
border-color: var(--color-warning);
|
||||
}
|
||||
|
||||
/* ── Cook mode button ───────────────────────────────────── */
|
||||
.btn-cook {
|
||||
font-size: var(--font-size-sm);
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background: rgba(232, 168, 32, 0.15);
|
||||
border: 1px solid rgba(232, 168, 32, 0.3);
|
||||
color: #f0bc48;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.btn-cook:hover {
|
||||
background: rgba(232, 168, 32, 0.25);
|
||||
border-color: rgba(232, 168, 32, 0.5);
|
||||
}
|
||||
|
||||
.btn-cook--active {
|
||||
background: rgba(232, 168, 32, 0.22);
|
||||
border-color: rgba(232, 168, 32, 0.5);
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.btn-cook {
|
||||
padding: 2px 8px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
|
@ -871,6 +1120,377 @@ function handleCook() {
|
|||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Ingredients collapsible ────────────────────────────── */
|
||||
.ingredients-collapsible {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.ingredients-collapsible-summary {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-xs) 0;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.ingredients-collapsible-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ingredients-collapsible-summary::before {
|
||||
content: '\25B6';
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
transition: transform 0.15s;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
details[open].ingredients-collapsible .ingredients-collapsible-summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.ingr-summary-counts {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.ingr-count {
|
||||
font-size: var(--font-size-xs);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.ingr-count-have {
|
||||
background: var(--color-success-bg, #dcfce7);
|
||||
color: var(--color-success, #16a34a);
|
||||
}
|
||||
|
||||
.ingr-count-need {
|
||||
background: var(--color-warning-bg, #fef9c3);
|
||||
color: var(--color-warning, #ca8a04);
|
||||
}
|
||||
|
||||
/* ── Effort summary cards ───────────────────────────────── */
|
||||
.effort-summary {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.effort-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.effort-card-active {
|
||||
background: var(--color-success-bg, #dcfce7);
|
||||
}
|
||||
|
||||
.effort-card-passive {
|
||||
background: var(--color-info-bg, #dbeafe);
|
||||
}
|
||||
|
||||
.effort-card-total {
|
||||
background: var(--color-bg-secondary, #f5f5f5);
|
||||
}
|
||||
|
||||
.effort-label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.effort-value {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.effort-level-badge {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.effort-quick {
|
||||
background: var(--color-success-bg, #dcfce7);
|
||||
color: var(--color-success, #16a34a);
|
||||
}
|
||||
|
||||
.effort-moderate {
|
||||
background: var(--color-info-bg, #dbeafe);
|
||||
color: var(--color-info-light, #2563eb);
|
||||
}
|
||||
|
||||
.effort-involved {
|
||||
background: var(--color-warning-bg, #fef9c3);
|
||||
color: var(--color-warning, #ca8a04);
|
||||
}
|
||||
|
||||
/* ── Equipment chips ────────────────────────────────────── */
|
||||
.equipment-chips {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.equipment-chip {
|
||||
font-size: var(--font-size-xs);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-bg-secondary, #f5f5f5);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ── Steps collapsible ──────────────────────────────────── */
|
||||
.steps-collapsible {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.steps-collapsible-summary {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
padding: var(--spacing-xs) 0;
|
||||
color: var(--color-text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.steps-collapsible-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.steps-collapsible-summary::before {
|
||||
content: '\25B6';
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
transition: transform 0.15s;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
details[open].steps-collapsible .steps-collapsible-summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.steps-count {
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.directions-list-annotated {
|
||||
padding-left: var(--spacing-md);
|
||||
}
|
||||
|
||||
.direction-step-annotated {
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
border-left: 3px solid var(--color-border);
|
||||
}
|
||||
|
||||
.step-passive {
|
||||
border-left-color: var(--color-info-light, #60a5fa);
|
||||
background: var(--color-info-bg, #dbeafe);
|
||||
}
|
||||
|
||||
.step-badge-row {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.step-type-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.step-type-active {
|
||||
background: var(--color-success-bg, #dcfce7);
|
||||
color: var(--color-success, #16a34a);
|
||||
}
|
||||
|
||||
.step-type-wait {
|
||||
background: var(--color-info-bg, #dbeafe);
|
||||
color: var(--color-info-light, #2563eb);
|
||||
}
|
||||
|
||||
.step-text {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.step-passive-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-info-light, #2563eb);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Cook mode bar ──────────────────────────────────────── */
|
||||
.cook-mode-bar {
|
||||
flex-shrink: 0;
|
||||
padding: var(--spacing-sm) var(--spacing-md) var(--spacing-xs);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.cook-progress-track {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cook-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: #f0bc48;
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
.cook-step-counter {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 248, 235, 0.38);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ── Cook mode step view ────────────────────────────────── */
|
||||
.cook-step-view {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--spacing-lg) var(--spacing-md) var(--spacing-md);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.cook-step-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: rgba(255, 248, 235, 0.35);
|
||||
}
|
||||
|
||||
.cook-step-badge-row {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.cook-step-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.cook-badge--active {
|
||||
background: rgba(232, 168, 32, 0.18);
|
||||
color: #f0bc48;
|
||||
border: 1px solid rgba(232, 168, 32, 0.35);
|
||||
}
|
||||
|
||||
.cook-badge--wait {
|
||||
background: rgba(96, 165, 250, 0.15);
|
||||
color: #93c5fd;
|
||||
border: 1px solid rgba(96, 165, 250, 0.3);
|
||||
}
|
||||
|
||||
.cook-step-text {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 248, 235, 0.92);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cook-step-hint {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 248, 235, 0.38);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Cook mode navigation ───────────────────────────────── */
|
||||
.cook-nav {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: auto;
|
||||
padding-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
.cook-nav-prev {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.cook-nav--disabled {
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.cook-nav-next {
|
||||
flex: 2;
|
||||
background: rgba(232, 168, 32, 0.18);
|
||||
border: 1px solid rgba(232, 168, 32, 0.4);
|
||||
color: #f0bc48;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.cook-nav-next:hover {
|
||||
background: rgba(232, 168, 32, 0.28);
|
||||
}
|
||||
|
||||
.cook-nav--done {
|
||||
background: rgba(127, 192, 115, 0.18);
|
||||
border-color: rgba(127, 192, 115, 0.4);
|
||||
color: #7fc073;
|
||||
}
|
||||
|
||||
.cook-nav--done:hover {
|
||||
background: rgba(127, 192, 115, 0.28);
|
||||
}
|
||||
|
||||
/* ── Sticky footer ──────────────────────────────────────── */
|
||||
.detail-footer {
|
||||
padding: var(--spacing-md);
|
||||
|
|
|
|||
|
|
@ -102,6 +102,28 @@
|
|||
Tap "Find recipes" again to apply.
|
||||
</p>
|
||||
|
||||
<!-- Time Budget selector (kiwi#52) -->
|
||||
<!-- Shows when time_first_layout != 'normal' (auto or time_first) -->
|
||||
<div v-if="settingsStore.timeFirstLayout !== 'normal'" class="form-group time-bucket-group">
|
||||
<label class="form-label">How much time do you have?</label>
|
||||
<div class="flex flex-wrap gap-sm">
|
||||
<button
|
||||
v-for="bucket in timeBuckets"
|
||||
:key="bucket.label"
|
||||
:class="['btn', 'btn-sm', 'time-bucket-btn',
|
||||
recipesStore.maxTotalMin === bucket.value ? 'time-bucket-active' : 'btn-secondary']"
|
||||
@click="recipesStore.maxTotalMin = recipesStore.maxTotalMin === bucket.value ? null : bucket.value"
|
||||
:aria-pressed="recipesStore.maxTotalMin === bucket.value"
|
||||
>
|
||||
{{ bucket.label }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="form-hint">
|
||||
Filters by time found in recipe steps.
|
||||
<span v-if="!recipesStore.maxTotalMin">No time limit set.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Dietary Preferences (collapsible) -->
|
||||
<details class="collapsible form-group" @toggle="(e: Event) => dietaryOpen = (e.target as HTMLDetailsElement).open">
|
||||
<summary class="collapsible-summary filter-summary" :aria-expanded="dietaryOpen">
|
||||
|
|
@ -169,6 +191,31 @@
|
|||
<span id="allergy-hint" class="form-hint">No recipes containing these ingredients will appear.</span>
|
||||
</div>
|
||||
|
||||
<!-- Not Today — temporary per-session ingredient exclusions -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">Not today <span class="text-muted text-xs">(skip these ingredients this session)</span></label>
|
||||
<div v-if="recipesStore.excludeIngredients.length > 0" class="tags-wrap flex flex-wrap gap-xs mb-xs">
|
||||
<span
|
||||
v-for="tag in recipesStore.excludeIngredients"
|
||||
:key="tag"
|
||||
class="tag-chip status-badge status-warning"
|
||||
>
|
||||
{{ tag }}
|
||||
<button class="chip-remove" @click="removeExcludeIngredient(tag)" :aria-label="'Stop excluding: ' + tag">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
class="form-input"
|
||||
v-model="excludeIngredientInput"
|
||||
placeholder="e.g. eggs, chicken, broccoli"
|
||||
aria-describedby="exclude-hint"
|
||||
@keydown="onExcludeIngredientKey"
|
||||
@blur="commitExcludeIngredientInput"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<span id="exclude-hint" class="form-hint">Recipes containing these won't appear. Press Enter or comma to add.</span>
|
||||
</div>
|
||||
|
||||
<!-- Can Make Now toggle -->
|
||||
<div class="form-group">
|
||||
<label class="flex-start gap-sm shopping-toggle">
|
||||
|
|
@ -294,6 +341,15 @@
|
|||
</span>
|
||||
<span v-else>Suggest Recipes</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="recipesStore.level === 3 || recipesStore.level === 4"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="isStreaming || recipesStore.loading || pantryItems.length === 0"
|
||||
@click="streamRecipe(recipesStore.level as 3 | 4, recipesStore.wildcardConfirmed)"
|
||||
title="Stream recipe generation token-by-token via cf-orch"
|
||||
>
|
||||
{{ isStreaming ? 'Streaming…' : 'Stream (L' + recipesStore.level + ')' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="recipesStore.dismissedCount > 0"
|
||||
class="btn btn-ghost btn-sm"
|
||||
|
|
@ -313,6 +369,16 @@
|
|||
{{ recipesStore.error }}
|
||||
</div>
|
||||
|
||||
<!-- Streaming recipe generation panel -->
|
||||
<div v-if="isStreaming || streamChunks || streamError" class="stream-panel">
|
||||
<div v-if="isStreaming" class="stream-status">
|
||||
<span class="stream-dot" aria-hidden="true"></span>
|
||||
Generating recipe…
|
||||
</div>
|
||||
<div v-if="streamError" class="stream-error text-sm">{{ streamError }}</div>
|
||||
<pre v-if="streamChunks" class="stream-output">{{ streamChunks }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- Screen reader announcement for loading + results -->
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">
|
||||
<span v-if="recipesStore.loading && recipesStore.jobStatus === 'queued'">Recipe request queued, waiting for model…</span>
|
||||
|
|
@ -671,6 +737,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { useRecipesStore } from '../stores/recipes'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { useInventoryStore } from '../stores/inventory'
|
||||
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||
import RecipeDetailPanel from './RecipeDetailPanel.vue'
|
||||
|
|
@ -680,11 +747,17 @@ import CommunityFeedPanel from './CommunityFeedPanel.vue'
|
|||
import BuildYourOwnTab from './BuildYourOwnTab.vue'
|
||||
import OrchUsagePill from './OrchUsagePill.vue'
|
||||
import type { ForkResult } from '../stores/community'
|
||||
import type { RecipeSuggestion, GroceryLink } from '../services/api'
|
||||
import type { RecipeSuggestion, GroceryLink, StreamTokenResponse } from '../services/api'
|
||||
import { recipesAPI } from '../services/api'
|
||||
|
||||
// Streaming state
|
||||
const isStreaming = ref(false)
|
||||
const streamChunks = ref('')
|
||||
const streamError = ref<string | null>(null)
|
||||
|
||||
const recipesStore = useRecipesStore()
|
||||
const inventoryStore = useInventoryStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
// Tab state
|
||||
type TabId = 'find' | 'browse' | 'saved' | 'community' | 'build'
|
||||
|
|
@ -771,6 +844,7 @@ const levelLabels: Record<number, string> = {
|
|||
// Local input state for tags
|
||||
const constraintInput = ref('')
|
||||
const allergyInput = ref('')
|
||||
const excludeIngredientInput = ref('')
|
||||
const categoryInput = ref('')
|
||||
const isLoadingMore = ref(false)
|
||||
|
||||
|
|
@ -918,6 +992,7 @@ function toggleAllergy(value: string) {
|
|||
const dietaryActive = computed(() =>
|
||||
recipesStore.constraints.length > 0 ||
|
||||
recipesStore.allergies.length > 0 ||
|
||||
recipesStore.excludeIngredients.length > 0 ||
|
||||
recipesStore.shoppingMode
|
||||
)
|
||||
|
||||
|
|
@ -935,6 +1010,15 @@ const activeNutritionFilterCount = computed(() =>
|
|||
|
||||
const activeLevel = computed(() => levels.find(l => l.value === recipesStore.level))
|
||||
|
||||
// Time budget buckets for the time-first entry selector (kiwi#52)
|
||||
const timeBuckets = [
|
||||
{ label: '15 min', value: 15 },
|
||||
{ label: '30 min', value: 30 },
|
||||
{ label: '45 min', value: 45 },
|
||||
{ label: '1 hour', value: 60 },
|
||||
{ label: '90 min', value: 90 },
|
||||
]
|
||||
|
||||
const cuisineStyles = [
|
||||
{ id: 'italian', label: 'Italian' },
|
||||
{ id: 'mediterranean', label: 'Mediterranean' },
|
||||
|
|
@ -1025,6 +1109,31 @@ function commitAllergyInput() {
|
|||
}
|
||||
}
|
||||
|
||||
function addExcludeIngredient(value: string) {
|
||||
const tag = value.trim().toLowerCase()
|
||||
if (tag && !recipesStore.excludeIngredients.includes(tag)) {
|
||||
recipesStore.excludeIngredients = [...recipesStore.excludeIngredients, tag]
|
||||
}
|
||||
excludeIngredientInput.value = ''
|
||||
}
|
||||
|
||||
function removeExcludeIngredient(tag: string) {
|
||||
recipesStore.excludeIngredients = recipesStore.excludeIngredients.filter((i) => i !== tag)
|
||||
}
|
||||
|
||||
function onExcludeIngredientKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault()
|
||||
addExcludeIngredient(excludeIngredientInput.value)
|
||||
}
|
||||
}
|
||||
|
||||
function commitExcludeIngredientInput() {
|
||||
if (excludeIngredientInput.value.trim()) {
|
||||
addExcludeIngredient(excludeIngredientInput.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Max missing number input
|
||||
function onMaxMissingInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
|
|
@ -1040,6 +1149,49 @@ function onNutritionInput(key: NutritionKey, e: Event) {
|
|||
recipesStore.nutritionFilters[key] = isNaN(val) ? null : val
|
||||
}
|
||||
|
||||
// Streaming recipe generation
|
||||
async function streamRecipe(level: 3 | 4, wildcardConfirmed = false) {
|
||||
isStreaming.value = true
|
||||
streamChunks.value = ''
|
||||
streamError.value = null
|
||||
|
||||
let tokenData: StreamTokenResponse
|
||||
try {
|
||||
tokenData = await recipesAPI.getRecipeStreamToken({ level, wildcard_confirmed: wildcardConfirmed })
|
||||
} catch (err: unknown) {
|
||||
isStreaming.value = false
|
||||
streamError.value = err instanceof Error ? err.message : 'Failed to start stream'
|
||||
return
|
||||
}
|
||||
|
||||
const url = `${tokenData.stream_url}?token=${encodeURIComponent(tokenData.token)}`
|
||||
const es = new EventSource(url)
|
||||
|
||||
es.onmessage = (e: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data)
|
||||
if (data.done) {
|
||||
es.close()
|
||||
isStreaming.value = false
|
||||
} else if (data.error) {
|
||||
es.close()
|
||||
isStreaming.value = false
|
||||
streamError.value = data.error
|
||||
} else if (data.chunk) {
|
||||
streamChunks.value += data.chunk
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed events
|
||||
}
|
||||
}
|
||||
|
||||
es.onerror = () => {
|
||||
es.close()
|
||||
isStreaming.value = false
|
||||
streamError.value = 'Stream connection lost'
|
||||
}
|
||||
}
|
||||
|
||||
// Suggest handler
|
||||
async function handleSuggest() {
|
||||
isLoadingMore.value = false
|
||||
|
|
@ -1425,6 +1577,23 @@ details[open] .collapsible-summary::before {
|
|||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Time bucket selector (kiwi#52) */
|
||||
.time-bucket-group {
|
||||
margin-top: var(--spacing-sm, 0.5rem);
|
||||
}
|
||||
|
||||
.time-bucket-btn {
|
||||
min-width: 4.5rem;
|
||||
border-radius: var(--radius-full, 9999px);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.time-bucket-active {
|
||||
background: var(--color-primary, #1a6b4a);
|
||||
color: white;
|
||||
border-color: var(--color-primary, #1a6b4a);
|
||||
}
|
||||
|
||||
/* Preset grid — auto-fill 2+ columns */
|
||||
.preset-grid {
|
||||
display: grid;
|
||||
|
|
@ -1636,4 +1805,48 @@ details[open] .collapsible-summary::before {
|
|||
min-height: 24px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.stream-panel {
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.stream-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.stream-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-warning);
|
||||
animation: stream-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes stream-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.stream-error {
|
||||
color: var(--color-danger, #e05c5c);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stream-output {
|
||||
font-family: inherit;
|
||||
white-space: pre-wrap;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -64,6 +64,89 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sensory Preferences -->
|
||||
<section class="mt-md">
|
||||
<h3 class="text-lg font-semibold mb-xs">Sensory Preferences</h3>
|
||||
<p class="text-sm text-secondary mb-md">
|
||||
Tell Kiwi what your senses prefer. Recipes that don't match will be
|
||||
filtered out quietly in Browse and Find. Leave everything unset and nothing is filtered.
|
||||
</p>
|
||||
|
||||
<!-- Texture avoid pills -->
|
||||
<div class="form-group">
|
||||
<label class="form-label">
|
||||
<span class="mr-xs">Texture — avoid</span>
|
||||
<span class="text-xs text-muted">(select any textures you'd rather skip)</span>
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-xs mt-xs" role="group" aria-label="Texture avoidance">
|
||||
<button
|
||||
v-for="tex in TEXTURE_OPTIONS"
|
||||
:key="tex.tag"
|
||||
:class="[
|
||||
'sensory-pill',
|
||||
settingsStore.sensoryPreferences.avoid_textures.includes(tex.tag)
|
||||
? 'sensory-pill--avoided'
|
||||
: 'sensory-pill--neutral',
|
||||
]"
|
||||
:aria-pressed="settingsStore.sensoryPreferences.avoid_textures.includes(tex.tag)"
|
||||
@click="toggleTexture(tex.tag)"
|
||||
>{{ tex.emoji }} {{ tex.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Smell tolerance -->
|
||||
<div class="form-group mt-sm">
|
||||
<label class="form-label">
|
||||
<span class="mr-xs">Smell — max I'm ok with</span>
|
||||
<span class="text-xs text-muted">(tap to set your limit; tap again to clear)</span>
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-xs mt-xs" role="group" aria-label="Smell tolerance">
|
||||
<button
|
||||
v-for="(level, idx) in SMELL_LEVELS"
|
||||
:key="String(level.value)"
|
||||
:class="['sensory-pill', getSmellClass(level.value, idx)]"
|
||||
:aria-pressed="settingsStore.sensoryPreferences.max_smell === level.value"
|
||||
@click="toggleSmell(level.value)"
|
||||
>{{ level.emoji }} {{ level.label }}</button>
|
||||
</div>
|
||||
<p v-if="settingsStore.sensoryPreferences.max_smell" class="text-xs text-muted mt-xs">
|
||||
Recipes stronger than <strong>{{ smellLabel(settingsStore.sensoryPreferences.max_smell) }}</strong> will be hidden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Noise tolerance -->
|
||||
<div class="form-group mt-sm">
|
||||
<label class="form-label">
|
||||
<span class="mr-xs">Noise — max I'm ok with</span>
|
||||
<span class="text-xs text-muted">(tap to set your limit; tap again to clear)</span>
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-xs mt-xs" role="group" aria-label="Noise tolerance">
|
||||
<button
|
||||
v-for="(level, idx) in NOISE_LEVELS"
|
||||
:key="String(level.value)"
|
||||
:class="['sensory-pill', getNoiseClass(level.value, idx)]"
|
||||
:aria-pressed="settingsStore.sensoryPreferences.max_noise === level.value"
|
||||
@click="toggleNoise(level.value)"
|
||||
>{{ level.emoji }} {{ level.label }}</button>
|
||||
</div>
|
||||
<p v-if="settingsStore.sensoryPreferences.max_noise" class="text-xs text-muted mt-xs">
|
||||
Recipes louder than <strong>{{ noiseLabel(settingsStore.sensoryPreferences.max_noise) }}</strong> will be hidden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-start gap-sm mt-sm">
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="settingsStore.loading"
|
||||
@click="settingsStore.saveSensory()"
|
||||
>
|
||||
<span v-if="settingsStore.loading">Saving…</span>
|
||||
<span v-else-if="settingsStore.saved">Saved!</span>
|
||||
<span v-else>Save sensory preferences</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Units -->
|
||||
<section class="mt-md">
|
||||
<h3 class="text-lg font-semibold mb-xs">Units</h3>
|
||||
|
|
@ -99,6 +182,95 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Shopping Locale -->
|
||||
<section class="mt-md">
|
||||
<h3 class="text-lg font-semibold mb-xs">Shopping Region</h3>
|
||||
<p class="text-sm text-secondary mb-sm">
|
||||
Sets your Amazon storefront and which retailers appear in shopping links.
|
||||
Instacart and Walmart are US/CA only — other regions get Amazon.
|
||||
</p>
|
||||
<select
|
||||
class="form-input"
|
||||
v-model="settingsStore.shoppingLocale"
|
||||
aria-label="Shopping region"
|
||||
style="max-width: 20rem;"
|
||||
>
|
||||
<optgroup label="North America">
|
||||
<option value="us">United States (USD $)</option>
|
||||
<option value="ca">Canada (CAD CA$)</option>
|
||||
<option value="mx">Mexico (MXN MX$)</option>
|
||||
</optgroup>
|
||||
<optgroup label="Europe">
|
||||
<option value="gb">United Kingdom (GBP £)</option>
|
||||
<option value="de">Germany (EUR €)</option>
|
||||
<option value="fr">France (EUR €)</option>
|
||||
<option value="it">Italy (EUR €)</option>
|
||||
<option value="es">Spain (EUR €)</option>
|
||||
<option value="nl">Netherlands (EUR €)</option>
|
||||
<option value="se">Sweden (SEK kr)</option>
|
||||
</optgroup>
|
||||
<optgroup label="Asia Pacific">
|
||||
<option value="au">Australia (AUD A$)</option>
|
||||
<option value="nz">New Zealand (NZD NZ$) — via Amazon AU</option>
|
||||
<option value="jp">Japan (JPY ¥)</option>
|
||||
<option value="in">India (INR ₹)</option>
|
||||
<option value="sg">Singapore (SGD S$)</option>
|
||||
</optgroup>
|
||||
<optgroup label="South America">
|
||||
<option value="br">Brazil (BRL R$)</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<div class="flex-start gap-sm mt-sm">
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="settingsStore.loading"
|
||||
@click="settingsStore.save()"
|
||||
>
|
||||
<span v-if="settingsStore.loading">Saving…</span>
|
||||
<span v-else-if="settingsStore.saved">✓ Saved!</span>
|
||||
<span v-else>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Time-First Layout -->
|
||||
<section class="mt-md">
|
||||
<h3 class="text-lg font-semibold mb-xs">Recipe Search Layout</h3>
|
||||
<p class="text-sm text-secondary mb-sm">
|
||||
Choose how the Find tab looks when you search for recipes.
|
||||
</p>
|
||||
<div class="flex flex-col gap-xs" role="radiogroup" aria-label="Recipe search layout">
|
||||
<label
|
||||
v-for="opt in timeFirstLayoutOptions"
|
||||
:key="opt.value"
|
||||
class="flex-start gap-sm time-layout-option"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="time_first_layout"
|
||||
:value="opt.value"
|
||||
:checked="settingsStore.timeFirstLayout === opt.value"
|
||||
@change="settingsStore.timeFirstLayout = opt.value"
|
||||
/>
|
||||
<span>
|
||||
<strong>{{ opt.label }}</strong>
|
||||
<span class="text-xs text-muted ml-xs">{{ opt.description }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-start gap-sm mt-sm">
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="settingsStore.loading"
|
||||
@click="settingsStore.save()"
|
||||
>
|
||||
<span v-if="settingsStore.loading">Saving…</span>
|
||||
<span v-else-if="settingsStore.saved">✓ Saved!</span>
|
||||
<span v-else>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Display Preferences -->
|
||||
<section class="mt-md">
|
||||
<h3 class="text-lg font-semibold mb-xs">Display</h3>
|
||||
|
|
@ -210,12 +382,20 @@ import { ref, computed, onMounted } from 'vue'
|
|||
import { useSettingsStore } from '../stores/settings'
|
||||
import { useRecipesStore } from '../stores/recipes'
|
||||
import { householdAPI, type HouseholdStatus } from '../services/api'
|
||||
import type { TextureTag, SmellLevel, NoiseLevel } from '../services/api'
|
||||
import type { TimeFirstLayout } from '../stores/settings'
|
||||
import { useOrchUsage } from '../composables/useOrchUsage'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const recipesStore = useRecipesStore()
|
||||
const { enabled: orchPillEnabled, setEnabled: setOrchPillEnabled } = useOrchUsage()
|
||||
|
||||
const timeFirstLayoutOptions: Array<{ value: TimeFirstLayout; label: string; description: string }> = [
|
||||
{ value: 'auto', label: 'Auto', description: 'Shows a time selector when recipes are available.' },
|
||||
{ value: 'time_first', label: 'Time First', description: 'Always show the time bucket selector at the top.' },
|
||||
{ value: 'normal', label: 'Normal', description: 'Standard layout — no time selector shown.' },
|
||||
]
|
||||
|
||||
const sortedCookLog = computed(() =>
|
||||
[...recipesStore.cookLog].sort((a, b) => b.cookedAt - a.cookedAt)
|
||||
)
|
||||
|
|
@ -360,6 +540,84 @@ onMounted(async () => {
|
|||
await settingsStore.load()
|
||||
await loadHouseholdStatus()
|
||||
})
|
||||
|
||||
// ── Sensory taxonomy ───────────────────────────────────────────────────────
|
||||
|
||||
const TEXTURE_OPTIONS: { tag: TextureTag; label: string; emoji: string }[] = [
|
||||
{ tag: 'mushy', label: 'Mushy', emoji: '🦫' },
|
||||
{ tag: 'slimy', label: 'Slimy', emoji: '🫙' },
|
||||
{ tag: 'crunchy', label: 'Crunchy', emoji: '🥜' },
|
||||
{ tag: 'chewy', label: 'Chewy', emoji: '🍖' },
|
||||
{ tag: 'creamy', label: 'Creamy', emoji: '🥣' },
|
||||
{ tag: 'chunky', label: 'Chunky', emoji: '🫕' },
|
||||
]
|
||||
|
||||
const SMELL_LEVELS: { value: SmellLevel; label: string; emoji: string }[] = [
|
||||
{ value: 'mild', label: 'Mild', emoji: '🌿' },
|
||||
{ value: 'aromatic', label: 'Aromatic', emoji: '🌸' },
|
||||
{ value: 'pungent', label: 'Pungent', emoji: '🧄' },
|
||||
{ value: 'fermented', label: 'Fermented', emoji: '🧀' },
|
||||
]
|
||||
|
||||
const NOISE_LEVELS: { value: NoiseLevel; label: string; emoji: string }[] = [
|
||||
{ value: 'quiet', label: 'Quiet', emoji: '🤫' },
|
||||
{ value: 'moderate', label: 'Moderate', emoji: '🍳' },
|
||||
{ value: 'loud', label: 'Loud', emoji: '🔥' },
|
||||
{ value: 'very_loud', label: 'Very loud', emoji: '💥' },
|
||||
]
|
||||
|
||||
function smellLabel(value: SmellLevel): string {
|
||||
return SMELL_LEVELS.find(l => l.value === value)?.label ?? ''
|
||||
}
|
||||
|
||||
function noiseLabel(value: NoiseLevel): string {
|
||||
return NOISE_LEVELS.find(l => l.value === value)?.label ?? ''
|
||||
}
|
||||
|
||||
function toggleTexture(tag: TextureTag) {
|
||||
const current = settingsStore.sensoryPreferences.avoid_textures
|
||||
const updated = current.includes(tag)
|
||||
? current.filter(t => t !== tag)
|
||||
: [...current, tag]
|
||||
settingsStore.sensoryPreferences = {
|
||||
...settingsStore.sensoryPreferences,
|
||||
avoid_textures: updated,
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSmell(value: SmellLevel) {
|
||||
const current = settingsStore.sensoryPreferences.max_smell
|
||||
settingsStore.sensoryPreferences = {
|
||||
...settingsStore.sensoryPreferences,
|
||||
max_smell: current === value ? null : value,
|
||||
}
|
||||
}
|
||||
|
||||
function toggleNoise(value: NoiseLevel) {
|
||||
const current = settingsStore.sensoryPreferences.max_noise
|
||||
settingsStore.sensoryPreferences = {
|
||||
...settingsStore.sensoryPreferences,
|
||||
max_noise: current === value ? null : value,
|
||||
}
|
||||
}
|
||||
|
||||
function getSmellClass(_value: SmellLevel, idx: number): string {
|
||||
const maxSmell = settingsStore.sensoryPreferences.max_smell
|
||||
if (!maxSmell) return 'sensory-pill--neutral'
|
||||
const maxIdx = SMELL_LEVELS.findIndex(l => l.value === maxSmell)
|
||||
if (idx === maxIdx) return 'sensory-pill--limit'
|
||||
if (idx < maxIdx) return 'sensory-pill--ok'
|
||||
return 'sensory-pill--neutral'
|
||||
}
|
||||
|
||||
function getNoiseClass(_value: NoiseLevel, idx: number): string {
|
||||
const maxNoise = settingsStore.sensoryPreferences.max_noise
|
||||
if (!maxNoise) return 'sensory-pill--neutral'
|
||||
const maxIdx = NOISE_LEVELS.findIndex(l => l.value === maxNoise)
|
||||
if (idx === maxIdx) return 'sensory-pill--limit'
|
||||
if (idx < maxIdx) return 'sensory-pill--ok'
|
||||
return 'sensory-pill--neutral'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -516,4 +774,63 @@ onMounted(async () => {
|
|||
height: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
/* ── Time-first layout option ────────────────────────────────────────────── */
|
||||
|
||||
.time-layout-option {
|
||||
cursor: pointer;
|
||||
padding: var(--spacing-xs, 0.25rem) 0;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.time-layout-option input[type="radio"] {
|
||||
accent-color: var(--color-primary);
|
||||
margin-top: 0.15rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Sensory pills ───────────────────────────────────────────────────────── */
|
||||
|
||||
.sensory-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
border: 1.5px solid var(--color-border, #e0e0e0);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary, #888);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sensory-pill:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.sensory-pill--avoided {
|
||||
background: rgba(220, 80, 60, 0.18);
|
||||
border-color: rgba(220, 80, 60, 0.40);
|
||||
color: #f08070;
|
||||
}
|
||||
|
||||
.sensory-pill--ok {
|
||||
background: rgba(74, 140, 64, 0.15);
|
||||
border-color: rgba(74, 140, 64, 0.35);
|
||||
color: #7fc073;
|
||||
}
|
||||
|
||||
.sensory-pill--limit {
|
||||
background: rgba(200, 140, 30, 0.18);
|
||||
border-color: rgba(200, 140, 30, 0.45);
|
||||
color: #c8a020;
|
||||
}
|
||||
|
||||
.sensory-pill--neutral {
|
||||
background: transparent;
|
||||
border-color: var(--color-border, #e0e0e0);
|
||||
color: var(--color-text-secondary, #888);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -111,9 +111,50 @@ export interface BarcodeScanResult {
|
|||
inventory_item: InventoryItem | null
|
||||
added_to_inventory: boolean
|
||||
needs_manual_entry: boolean
|
||||
needs_visual_capture: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface LabelCaptureResult {
|
||||
barcode: string
|
||||
product_name: string | null
|
||||
brand: string | null
|
||||
serving_size_g: number | null
|
||||
calories: number | null
|
||||
fat_g: number | null
|
||||
saturated_fat_g: number | null
|
||||
carbs_g: number | null
|
||||
sugar_g: number | null
|
||||
fiber_g: number | null
|
||||
protein_g: number | null
|
||||
sodium_mg: number | null
|
||||
ingredient_names: string[]
|
||||
allergens: string[]
|
||||
confidence: number
|
||||
needs_review: boolean
|
||||
}
|
||||
|
||||
export interface LabelConfirmRequest {
|
||||
barcode: string
|
||||
product_name?: string | null
|
||||
brand?: string | null
|
||||
serving_size_g?: number | null
|
||||
calories?: number | null
|
||||
fat_g?: number | null
|
||||
saturated_fat_g?: number | null
|
||||
carbs_g?: number | null
|
||||
sugar_g?: number | null
|
||||
fiber_g?: number | null
|
||||
protein_g?: number | null
|
||||
sodium_mg?: number | null
|
||||
ingredient_names?: string[]
|
||||
allergens?: string[]
|
||||
confidence?: number
|
||||
location?: string
|
||||
quantity?: number
|
||||
auto_add?: boolean
|
||||
}
|
||||
|
||||
export interface BarcodeScanResponse {
|
||||
success: boolean
|
||||
barcodes_found: number
|
||||
|
|
@ -344,6 +385,32 @@ export const inventoryAPI = {
|
|||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload a nutrition label photo for an unenriched barcode (paid tier).
|
||||
* Returns extracted fields + confidence score for user review.
|
||||
*/
|
||||
async captureLabelPhoto(
|
||||
file: File,
|
||||
barcode: string
|
||||
): Promise<LabelCaptureResult> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('barcode', barcode)
|
||||
const response = await api.post('/inventory/scan/label-capture', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 60000, // vision inference can take ~5–10s
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Confirm a user-reviewed label extraction and save to the local cache.
|
||||
*/
|
||||
async confirmLabelCapture(data: LabelConfirmRequest): Promise<{ ok: boolean; product_id?: number; inventory_item_id?: number; message: string }> {
|
||||
const response = await api.post('/inventory/scan/label-confirm', data)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
// ========== Receipts API ==========
|
||||
|
|
@ -500,6 +567,7 @@ export interface RecipeSuggestion {
|
|||
source_url: string | null
|
||||
complexity: 'easy' | 'moderate' | 'involved' | null
|
||||
estimated_time_min: number | null
|
||||
time_effort: TimeEffortProfile | null
|
||||
}
|
||||
|
||||
export interface NutritionFilters {
|
||||
|
|
@ -524,6 +592,12 @@ export interface RecipeResult {
|
|||
rate_limit_count: number
|
||||
}
|
||||
|
||||
export interface StreamTokenResponse {
|
||||
stream_url: string
|
||||
token: string
|
||||
expires_in_s: number
|
||||
}
|
||||
|
||||
export type RecipeJobStatusValue = 'queued' | 'running' | 'done' | 'failed'
|
||||
|
||||
export interface RecipeJobStatus {
|
||||
|
|
@ -547,10 +621,12 @@ export interface RecipeRequest {
|
|||
wildcard_confirmed: boolean
|
||||
nutrition_filters: NutritionFilters
|
||||
excluded_ids: number[]
|
||||
exclude_ingredients: string[]
|
||||
shopping_mode: boolean
|
||||
pantry_match_only: boolean
|
||||
complexity_filter: string | null
|
||||
max_time_min: number | null
|
||||
max_total_min: number | null
|
||||
}
|
||||
|
||||
export interface Staple {
|
||||
|
|
@ -644,6 +720,18 @@ export const recipesAPI = {
|
|||
const response = await api.post('/recipes/build', req)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/** Issue a one-time stream token for LLM recipe generation (Paid tier / BYOK only). */
|
||||
async getRecipeStreamToken(params: {
|
||||
level: 3 | 4
|
||||
wildcard_confirmed?: boolean
|
||||
}): Promise<StreamTokenResponse> {
|
||||
const response = await api.post('/recipes/stream-token', {
|
||||
level: params.level,
|
||||
wildcard_confirmed: params.wildcard_confirmed ?? false,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
// ========== Settings API ==========
|
||||
|
|
@ -910,11 +998,29 @@ export interface BrowserSubcategory {
|
|||
recipe_count: number
|
||||
}
|
||||
|
||||
// ── Time & Effort types ───────────────────────────────────────────────────
|
||||
|
||||
export interface StepAnalysis {
|
||||
is_passive: boolean
|
||||
detected_minutes: number | null
|
||||
}
|
||||
|
||||
export interface TimeEffortProfile {
|
||||
active_min: number
|
||||
passive_min: number
|
||||
total_min: number
|
||||
effort_label: 'quick' | 'moderate' | 'involved'
|
||||
equipment: string[]
|
||||
step_analyses: StepAnalysis[]
|
||||
}
|
||||
|
||||
export interface BrowserRecipe {
|
||||
id: number
|
||||
title: string
|
||||
category: string | null
|
||||
match_pct: number | null
|
||||
active_min: number | null
|
||||
passive_min: number | null
|
||||
}
|
||||
|
||||
export interface BrowserResult {
|
||||
|
|
@ -951,6 +1057,28 @@ export const browserAPI = {
|
|||
const response = await api.get(`/recipes/browse/${domain}/${encodeURIComponent(category)}`, { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async submitRecipeTag(body: {
|
||||
recipe_id: number
|
||||
domain: string
|
||||
category: string
|
||||
subcategory: string | null
|
||||
pseudonym: string
|
||||
}): Promise<void> {
|
||||
await api.post('/recipes/community-tags', body)
|
||||
},
|
||||
|
||||
async upvoteRecipeTag(tagId: number, pseudonym: string): Promise<void> {
|
||||
await api.post(`/recipes/community-tags/${tagId}/upvote`, null, { params: { pseudonym } })
|
||||
},
|
||||
|
||||
async listRecipeTags(recipeId: number): Promise<Array<{
|
||||
id: number; domain: string; category: string; subcategory: string | null;
|
||||
pseudonym: string; upvotes: number; accepted: boolean
|
||||
}>> {
|
||||
const response = await api.get(`/recipes/community-tags/${recipeId}`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
// ── Shopping List ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -1049,4 +1177,22 @@ export async function bootstrapSession(): Promise<SessionInfo | null> {
|
|||
}
|
||||
}
|
||||
|
||||
// ========== Sensory Preferences Types ==========
|
||||
|
||||
export type TextureTag = 'mushy' | 'slimy' | 'crunchy' | 'chewy' | 'creamy' | 'chunky'
|
||||
export type SmellLevel = 'mild' | 'aromatic' | 'pungent' | 'fermented' | null
|
||||
export type NoiseLevel = 'quiet' | 'moderate' | 'loud' | 'very_loud' | null
|
||||
|
||||
export interface SensoryPreferences {
|
||||
avoid_textures: TextureTag[]
|
||||
max_smell: SmellLevel
|
||||
max_noise: NoiseLevel
|
||||
}
|
||||
|
||||
export const DEFAULT_SENSORY_PREFERENCES: SensoryPreferences = {
|
||||
avoid_textures: [],
|
||||
max_smell: null,
|
||||
max_noise: null,
|
||||
}
|
||||
|
||||
export default api
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const FILTER_MODE_KEY = 'kiwi:builder_filter_mode'
|
|||
|
||||
const CONSTRAINTS_KEY = 'kiwi:constraints'
|
||||
const ALLERGIES_KEY = 'kiwi:allergies'
|
||||
const EXCLUDE_INGREDIENTS_KEY = 'kiwi:exclude_ingredients'
|
||||
|
||||
function loadConstraints(): string[] {
|
||||
try {
|
||||
|
|
@ -50,6 +51,19 @@ function saveAllergies(vals: string[]) {
|
|||
localStorage.setItem(ALLERGIES_KEY, JSON.stringify(vals))
|
||||
}
|
||||
|
||||
function loadExcludeIngredients(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(EXCLUDE_INGREDIENTS_KEY)
|
||||
return raw ? JSON.parse(raw) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function saveExcludeIngredients(vals: string[]) {
|
||||
localStorage.setItem(EXCLUDE_INGREDIENTS_KEY, JSON.stringify(vals))
|
||||
}
|
||||
|
||||
type MissingIngredientMode = 'hidden' | 'greyed' | 'add-to-cart'
|
||||
type BuilderFilterMode = 'text' | 'tags'
|
||||
|
||||
|
|
@ -127,6 +141,7 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
const level = ref(1)
|
||||
const constraints = ref<string[]>(loadConstraints())
|
||||
const allergies = ref<string[]>(loadAllergies())
|
||||
const excludeIngredients = ref<string[]>(loadExcludeIngredients())
|
||||
const hardDayMode = ref(false)
|
||||
const maxMissing = ref<number | null>(null)
|
||||
const styleId = ref<string | null>(null)
|
||||
|
|
@ -136,6 +151,7 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
const pantryMatchOnly = ref(false)
|
||||
const complexityFilter = ref<string | null>(null)
|
||||
const maxTimeMin = ref<number | null>(null)
|
||||
const maxTotalMin = ref<number | null>(null)
|
||||
const nutritionFilters = ref<NutritionFilters>({
|
||||
max_calories: null,
|
||||
max_sugar_g: null,
|
||||
|
|
@ -161,6 +177,7 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
watch(builderFilterMode, (val) => localStorage.setItem(FILTER_MODE_KEY, val))
|
||||
watch(constraints, (val) => saveConstraints(val), { deep: true })
|
||||
watch(allergies, (val) => saveAllergies(val), { deep: true })
|
||||
watch(excludeIngredients, (val) => saveExcludeIngredients(val), { deep: true })
|
||||
|
||||
const dismissedCount = computed(() => dismissedIds.value.size)
|
||||
|
||||
|
|
@ -184,10 +201,12 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
wildcard_confirmed: wildcardConfirmed.value,
|
||||
nutrition_filters: nutritionFilters.value,
|
||||
excluded_ids: [...excluded],
|
||||
exclude_ingredients: excludeIngredients.value,
|
||||
shopping_mode: shoppingMode.value,
|
||||
pantry_match_only: pantryMatchOnly.value,
|
||||
complexity_filter: complexityFilter.value,
|
||||
max_time_min: maxTimeMin.value,
|
||||
max_total_min: maxTotalMin.value,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -338,6 +357,11 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
localStorage.removeItem(ALLERGIES_KEY)
|
||||
}
|
||||
|
||||
function clearExcludeIngredients() {
|
||||
excludeIngredients.value = []
|
||||
localStorage.removeItem(EXCLUDE_INGREDIENTS_KEY)
|
||||
}
|
||||
|
||||
function clearResult() {
|
||||
result.value = null
|
||||
error.value = null
|
||||
|
|
@ -352,6 +376,7 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
level,
|
||||
constraints,
|
||||
allergies,
|
||||
excludeIngredients,
|
||||
hardDayMode,
|
||||
maxMissing,
|
||||
styleId,
|
||||
|
|
@ -361,6 +386,7 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
pantryMatchOnly,
|
||||
complexityFilter,
|
||||
maxTimeMin,
|
||||
maxTotalMin,
|
||||
nutritionFilters,
|
||||
dismissedIds,
|
||||
dismissedCount,
|
||||
|
|
@ -373,6 +399,7 @@ export const useRecipesStore = defineStore('recipes', () => {
|
|||
clearBookmarks,
|
||||
clearConstraints,
|
||||
clearAllergies,
|
||||
clearExcludeIngredients,
|
||||
missingIngredientMode,
|
||||
builderFilterMode,
|
||||
suggest,
|
||||
|
|
|
|||
|
|
@ -8,11 +8,18 @@ import { defineStore } from 'pinia'
|
|||
import { ref } from 'vue'
|
||||
import { settingsAPI } from '../services/api'
|
||||
import type { UnitSystem } from '../utils/units'
|
||||
import type { SensoryPreferences } from '../services/api'
|
||||
import { DEFAULT_SENSORY_PREFERENCES } from '../services/api'
|
||||
|
||||
export type TimeFirstLayout = 'auto' | 'time_first' | 'normal'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
// State
|
||||
const cookingEquipment = ref<string[]>([])
|
||||
const unitSystem = ref<UnitSystem>('metric')
|
||||
const shoppingLocale = ref<string>('us')
|
||||
const sensoryPreferences = ref<SensoryPreferences>({ ...DEFAULT_SENSORY_PREFERENCES })
|
||||
const timeFirstLayout = ref<TimeFirstLayout>('auto')
|
||||
const loading = ref(false)
|
||||
const saved = ref(false)
|
||||
|
||||
|
|
@ -20,9 +27,12 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [rawEquipment, rawUnits] = await Promise.allSettled([
|
||||
const [rawEquipment, rawUnits, rawLocale, rawSensory, rawTimeFirst] = await Promise.allSettled([
|
||||
settingsAPI.getSetting('cooking_equipment'),
|
||||
settingsAPI.getSetting('unit_system'),
|
||||
settingsAPI.getSetting('shopping_locale'),
|
||||
settingsAPI.getSetting('sensory_preferences'),
|
||||
settingsAPI.getSetting('time_first_layout'),
|
||||
])
|
||||
if (rawEquipment.status === 'fulfilled' && rawEquipment.value) {
|
||||
cookingEquipment.value = JSON.parse(rawEquipment.value)
|
||||
|
|
@ -30,6 +40,19 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
if (rawUnits.status === 'fulfilled' && rawUnits.value) {
|
||||
unitSystem.value = rawUnits.value as UnitSystem
|
||||
}
|
||||
if (rawLocale.status === 'fulfilled' && rawLocale.value) {
|
||||
shoppingLocale.value = rawLocale.value
|
||||
}
|
||||
if (rawSensory.status === 'fulfilled' && rawSensory.value) {
|
||||
try {
|
||||
sensoryPreferences.value = JSON.parse(rawSensory.value)
|
||||
} catch {
|
||||
sensoryPreferences.value = { ...DEFAULT_SENSORY_PREFERENCES }
|
||||
}
|
||||
}
|
||||
if (rawTimeFirst.status === 'fulfilled' && rawTimeFirst.value) {
|
||||
timeFirstLayout.value = rawTimeFirst.value as TimeFirstLayout
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to load settings:', err)
|
||||
} finally {
|
||||
|
|
@ -43,6 +66,9 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
await Promise.all([
|
||||
settingsAPI.setSetting('cooking_equipment', JSON.stringify(cookingEquipment.value)),
|
||||
settingsAPI.setSetting('unit_system', unitSystem.value),
|
||||
settingsAPI.setSetting('shopping_locale', shoppingLocale.value),
|
||||
settingsAPI.setSetting('sensory_preferences', JSON.stringify(sensoryPreferences.value)),
|
||||
settingsAPI.setSetting('time_first_layout', timeFirstLayout.value),
|
||||
])
|
||||
saved.value = true
|
||||
setTimeout(() => {
|
||||
|
|
@ -55,15 +81,35 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||
}
|
||||
}
|
||||
|
||||
async function saveSensory() {
|
||||
loading.value = true
|
||||
try {
|
||||
await settingsAPI.setSetting(
|
||||
'sensory_preferences',
|
||||
JSON.stringify(sensoryPreferences.value),
|
||||
)
|
||||
saved.value = true
|
||||
setTimeout(() => { saved.value = false }, 2000)
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to save sensory preferences:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
cookingEquipment,
|
||||
unitSystem,
|
||||
shoppingLocale,
|
||||
sensoryPreferences,
|
||||
timeFirstLayout,
|
||||
loading,
|
||||
saved,
|
||||
|
||||
// Actions
|
||||
load,
|
||||
save,
|
||||
saveSensory,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||
|
||||
[project]
|
||||
name = "kiwi"
|
||||
version = "0.3.0"
|
||||
version = "0.6.0"
|
||||
description = "Pantry tracking + leftover recipe suggestions"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from __future__ import annotations
|
|||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
|
@ -248,8 +249,37 @@ if __name__ == "__main__":
|
|||
parser.add_argument("--batch-size", type=int, default=2000)
|
||||
parser.add_argument("--force", action="store_true",
|
||||
help="Re-derive tags even if inferred_tags is already set.")
|
||||
parser.add_argument(
|
||||
"--browse-counts-path",
|
||||
type=Path,
|
||||
default=None,
|
||||
metavar="PATH",
|
||||
help=(
|
||||
"Path to the browse_counts.db cache file to refresh after tagging. "
|
||||
"Defaults to DATA_DIR/browse_counts.db if DATA_DIR env var is set, "
|
||||
"otherwise skipped."
|
||||
),
|
||||
)
|
||||
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, args.force)
|
||||
|
||||
# Refresh browse counts cache after a successful run so the app picks up
|
||||
# the updated FTS index without restarting. Skipped if no cache path given
|
||||
# and DATA_DIR env var is not set.
|
||||
cache_path = args.browse_counts_path
|
||||
if cache_path is None:
|
||||
data_dir = os.environ.get("DATA_DIR")
|
||||
if data_dir:
|
||||
cache_path = Path(data_dir) / "browse_counts.db"
|
||||
|
||||
if cache_path is not None:
|
||||
print(f"Refreshing browse counts cache → {cache_path} ...")
|
||||
try:
|
||||
from app.services.recipe.browse_counts_cache import refresh as _refresh
|
||||
computed = _refresh(str(args.db), cache_path)
|
||||
print(f"Browse counts cache refreshed ({computed} keyword sets).")
|
||||
except Exception as exc:
|
||||
print(f"Browse counts refresh skipped: {exc}")
|
||||
|
|
|
|||
247
scripts/tag_sensory_profiles.py
Normal file
247
scripts/tag_sensory_profiles.py
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tag recipes with sensory_tags (texture, smell, noise) based on ingredient
|
||||
names and direction keywords.
|
||||
|
||||
Stores results in the sensory_tags JSON column added by migration 035.
|
||||
Empty "{}" means untagged -- these recipes pass all sensory filters.
|
||||
|
||||
Run:
|
||||
python scripts/tag_sensory_profiles.py [path/to/kiwi.db]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_DEFAULT_PATHS = [
|
||||
"/devl/kiwi-cloud-data/local-dev/kiwi.db",
|
||||
"/devl/kiwi-data/kiwi.db",
|
||||
]
|
||||
|
||||
BATCH_SIZE = 2_000
|
||||
|
||||
TEXTURE_TAGS = ("mushy", "slimy", "crunchy", "chewy", "creamy", "chunky")
|
||||
|
||||
_PROFILE_TO_TEXTURE: dict[str, str] = {
|
||||
"creamy": "creamy",
|
||||
"fatty": "creamy",
|
||||
}
|
||||
|
||||
_DIR_TEXTURE_PATTERNS: dict[str, list[str]] = {
|
||||
"mushy": ["stew", "braise", "slow.cook", "slow cook", "soften", "mash", "slow-cook"],
|
||||
"crunchy": ["fry", "roast", "toast", "bake", "crispy", "raw"],
|
||||
"creamy": ["blend", "puree", "mash smooth"],
|
||||
"chunky": ["chunk", "cube", "dice"],
|
||||
}
|
||||
|
||||
_ING_TEXTURE_PATTERNS: dict[str, list[str]] = {
|
||||
"slimy": ["okra", "seaweed", "natto", "enoki", "oyster mushroom"],
|
||||
"chewy": ["calamari", "squid", "octopus", "jerky", "dried fruit",
|
||||
"sourdough", "bagel", "pretzel"],
|
||||
"crunchy": ["nuts", "seeds", "breadcrumbs", "crackers", "croutons",
|
||||
"granola", "cornflakes"],
|
||||
}
|
||||
|
||||
_SMELL_KEYWORDS: dict[str, list[str]] = {
|
||||
"fermented": [
|
||||
"fish sauce", "soy sauce", "miso", "kimchi", "natto",
|
||||
"blue cheese", "aged cheese", "balsamic",
|
||||
],
|
||||
"pungent": [
|
||||
"garlic", "curry powder", "garam masala",
|
||||
"fish fillet", "fish steak", "fish filet", "liver",
|
||||
],
|
||||
"aromatic": [
|
||||
"basil", "rosemary", "thyme", "cilantro", "citrus zest",
|
||||
"cinnamon", "vanilla", "cardamom",
|
||||
],
|
||||
}
|
||||
_SMELL_ORDER = ("fermented", "pungent", "aromatic", "mild")
|
||||
|
||||
_NOISE_PATTERNS: dict[str, list[str]] = {
|
||||
"very_loud": ["deep fry", "deep-fry", "pressure cook", "instant pot"],
|
||||
"loud": ["sear", "high heat", "wok", "stir-fry", "stir fry"],
|
||||
"moderate": ["saute", "pan-fry", "pan fry", "bake", "roast"],
|
||||
}
|
||||
_NOISE_ORDER = ("very_loud", "loud", "moderate", "quiet")
|
||||
|
||||
|
||||
def _classify_textures(
|
||||
ingredient_names: list[str],
|
||||
directions: list[str],
|
||||
profile_textures: set[str],
|
||||
) -> list[str]:
|
||||
"""Return list of texture tags that apply to this recipe."""
|
||||
dirs_text = " ".join(directions).lower()
|
||||
ings_text = " ".join(ingredient_names).lower()
|
||||
result: list[str] = []
|
||||
|
||||
for tag in TEXTURE_TAGS:
|
||||
fired = False
|
||||
|
||||
if not fired and tag == "creamy" and ("creamy" in profile_textures or "fatty" in profile_textures):
|
||||
fired = True
|
||||
|
||||
if not fired and tag in _DIR_TEXTURE_PATTERNS:
|
||||
for kw in _DIR_TEXTURE_PATTERNS[tag]:
|
||||
if kw in dirs_text:
|
||||
fired = True
|
||||
break
|
||||
|
||||
if not fired and tag in _ING_TEXTURE_PATTERNS:
|
||||
for kw in _ING_TEXTURE_PATTERNS[tag]:
|
||||
if kw in ings_text:
|
||||
fired = True
|
||||
break
|
||||
|
||||
if fired:
|
||||
result.append(tag)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _classify_smell(ingredient_names: list[str]) -> str:
|
||||
"""Return highest smell level present in ingredient list."""
|
||||
ings_lower = " ".join(ingredient_names).lower()
|
||||
for level in ("fermented", "pungent", "aromatic"):
|
||||
for kw in _SMELL_KEYWORDS[level]:
|
||||
if kw in ings_lower:
|
||||
return level
|
||||
return "mild"
|
||||
|
||||
|
||||
def _classify_noise(directions: list[str]) -> str:
|
||||
"""Return highest noise level present in direction steps."""
|
||||
dirs_lower = " ".join(directions).lower()
|
||||
|
||||
for kw in _NOISE_PATTERNS["very_loud"]:
|
||||
if kw in dirs_lower:
|
||||
return "very_loud"
|
||||
|
||||
for kw in _NOISE_PATTERNS["loud"]:
|
||||
if kw in dirs_lower:
|
||||
return "loud"
|
||||
if re.search(r"\bfry\b", dirs_lower) and "deep" not in dirs_lower:
|
||||
return "loud"
|
||||
|
||||
for kw in _NOISE_PATTERNS["moderate"]:
|
||||
if kw in dirs_lower:
|
||||
return "moderate"
|
||||
|
||||
return "quiet"
|
||||
|
||||
|
||||
def tag_recipes(db_path: str) -> None:
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
total = conn.execute("SELECT COUNT(*) FROM recipes").fetchone()[0]
|
||||
print(f"Total recipes: {total:,}")
|
||||
|
||||
updated = 0
|
||||
offset = 0
|
||||
texture_counts: dict[str, int] = {t: 0 for t in TEXTURE_TAGS}
|
||||
smell_counts: dict[str, int] = {s: 0 for s in _SMELL_ORDER}
|
||||
noise_counts: dict[str, int] = {n: 0 for n in _NOISE_ORDER}
|
||||
|
||||
while True:
|
||||
rows = conn.execute(
|
||||
"""SELECT r.id, r.ingredient_names, r.directions
|
||||
FROM recipes r
|
||||
LIMIT ? OFFSET ?""",
|
||||
(BATCH_SIZE, offset),
|
||||
).fetchall()
|
||||
|
||||
if not rows:
|
||||
break
|
||||
|
||||
batch: list[tuple[str, int]] = []
|
||||
|
||||
for row in rows:
|
||||
recipe_id = row["id"]
|
||||
|
||||
try:
|
||||
ingredient_names: list[str] = json.loads(row["ingredient_names"] or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
ingredient_names = []
|
||||
|
||||
try:
|
||||
directions: list[str] = json.loads(row["directions"] or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
directions = []
|
||||
|
||||
if ingredient_names:
|
||||
placeholders = ",".join("?" * len(ingredient_names))
|
||||
profile_rows = conn.execute(
|
||||
f"""SELECT DISTINCT texture_profile
|
||||
FROM ingredient_profiles
|
||||
WHERE LOWER(name) IN ({placeholders})""",
|
||||
[n.lower() for n in ingredient_names],
|
||||
).fetchall()
|
||||
profile_textures = {r["texture_profile"] for r in profile_rows if r["texture_profile"]}
|
||||
else:
|
||||
profile_textures = set()
|
||||
|
||||
textures = _classify_textures(ingredient_names, directions, profile_textures)
|
||||
smell = _classify_smell(ingredient_names)
|
||||
noise = _classify_noise(directions)
|
||||
|
||||
for t in textures:
|
||||
texture_counts[t] = texture_counts.get(t, 0) + 1
|
||||
smell_counts[smell] = smell_counts.get(smell, 0) + 1
|
||||
noise_counts[noise] = noise_counts.get(noise, 0) + 1
|
||||
|
||||
sensory_tags = json.dumps({
|
||||
"textures": textures,
|
||||
"smell": smell,
|
||||
"noise": noise,
|
||||
})
|
||||
batch.append((sensory_tags, recipe_id))
|
||||
|
||||
conn.executemany(
|
||||
"UPDATE recipes SET sensory_tags = ? WHERE id = ?",
|
||||
batch,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
updated += len(batch)
|
||||
offset += BATCH_SIZE
|
||||
print(f" {updated:,} / {total:,} tagged...", end="\r")
|
||||
|
||||
print(f"\nDone. {updated:,} recipes tagged.\n")
|
||||
|
||||
print("Texture tag distribution:")
|
||||
for tag, count in sorted(texture_counts.items(), key=lambda x: -x[1]):
|
||||
pct = count / updated * 100 if updated else 0
|
||||
print(f" {tag:12s} {count:8,} ({pct:.1f}%)")
|
||||
|
||||
print("\nSmell level distribution:")
|
||||
for level in _SMELL_ORDER:
|
||||
count = smell_counts.get(level, 0)
|
||||
pct = count / updated * 100 if updated else 0
|
||||
print(f" {level:12s} {count:8,} ({pct:.1f}%)")
|
||||
|
||||
print("\nNoise level distribution:")
|
||||
for level in _NOISE_ORDER:
|
||||
count = noise_counts.get(level, 0)
|
||||
pct = count / updated * 100 if updated else 0
|
||||
print(f" {level:12s} {count:8,} ({pct:.1f}%)")
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1:
|
||||
path = sys.argv[1]
|
||||
else:
|
||||
path = next((p for p in _DEFAULT_PATHS if Path(p).exists()), None)
|
||||
if not path:
|
||||
print(f"No DB found. Pass path as argument or create one of: {_DEFAULT_PATHS}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Tagging sensory profiles in: {path}")
|
||||
tag_recipes(path)
|
||||
153
tests/api/test_browse_time_effort.py
Normal file
153
tests/api/test_browse_time_effort.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"""Tests for active_min/passive_min fields on browse endpoint responses."""
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from app.services.recipe.time_effort import parse_time_effort
|
||||
|
||||
|
||||
class TestBrowseTimeEffortFields:
|
||||
"""Unit-level: verify that browse result dicts gain active_min/passive_min."""
|
||||
|
||||
def _make_recipe_row(self, recipe_id: int, directions: list[str]) -> dict:
|
||||
"""Build a minimal recipe row as browse_recipes would return."""
|
||||
import json
|
||||
return {
|
||||
"id": recipe_id,
|
||||
"title": f"Recipe {recipe_id}",
|
||||
"category": "Italian",
|
||||
"match_pct": None,
|
||||
"directions": json.dumps(directions), # stored as JSON string
|
||||
}
|
||||
|
||||
def test_active_passive_attached_when_directions_present(self):
|
||||
"""Simulate the enrichment logic that the endpoint applies."""
|
||||
import json
|
||||
row = self._make_recipe_row(1, ["Chop onion.", "Simmer for 20 minutes."])
|
||||
|
||||
# Reproduce the enrichment logic from the endpoint:
|
||||
directions = row.get("directions") or []
|
||||
if isinstance(directions, str):
|
||||
try:
|
||||
directions = json.loads(directions)
|
||||
except Exception:
|
||||
directions = []
|
||||
if directions:
|
||||
profile = parse_time_effort(directions)
|
||||
row["active_min"] = profile.active_min
|
||||
row["passive_min"] = profile.passive_min
|
||||
else:
|
||||
row["active_min"] = None
|
||||
row["passive_min"] = None
|
||||
|
||||
assert row["active_min"] == 0 # no active time found
|
||||
assert row["passive_min"] == 20
|
||||
|
||||
def test_null_when_directions_empty(self):
|
||||
"""active_min and passive_min are None when directions list is empty."""
|
||||
import json
|
||||
row = self._make_recipe_row(2, [])
|
||||
|
||||
directions = row.get("directions") or []
|
||||
if isinstance(directions, str):
|
||||
try:
|
||||
directions = json.loads(directions)
|
||||
except Exception:
|
||||
directions = []
|
||||
if directions:
|
||||
profile = parse_time_effort(directions)
|
||||
row["active_min"] = profile.active_min
|
||||
row["passive_min"] = profile.passive_min
|
||||
else:
|
||||
row["active_min"] = None
|
||||
row["passive_min"] = None
|
||||
|
||||
assert row["active_min"] is None
|
||||
assert row["passive_min"] is None
|
||||
|
||||
def test_null_when_directions_missing_key(self):
|
||||
"""active_min and passive_min are None when key is absent."""
|
||||
row = {"id": 3, "title": "Test", "category": "X", "match_pct": None}
|
||||
|
||||
directions = row.get("directions") or []
|
||||
if isinstance(directions, str):
|
||||
try:
|
||||
import json
|
||||
directions = json.loads(directions)
|
||||
except Exception:
|
||||
directions = []
|
||||
if directions:
|
||||
profile = parse_time_effort(directions)
|
||||
row["active_min"] = profile.active_min
|
||||
row["passive_min"] = profile.passive_min
|
||||
else:
|
||||
row["active_min"] = None
|
||||
row["passive_min"] = None
|
||||
|
||||
assert row["active_min"] is None
|
||||
assert row["passive_min"] is None
|
||||
|
||||
|
||||
class TestDetailTimeEffortField:
|
||||
"""Verify that the detail endpoint response gains a time_effort key."""
|
||||
|
||||
def test_time_effort_field_structure(self):
|
||||
"""Detail endpoint must return the full TimeEffortProfile shape."""
|
||||
import json
|
||||
from app.services.recipe.time_effort import parse_time_effort
|
||||
|
||||
directions = [
|
||||
"Dice the onion.",
|
||||
"Sear chicken for 5 minutes.",
|
||||
"Simmer sauce for 20 minutes.",
|
||||
]
|
||||
|
||||
profile = parse_time_effort(directions)
|
||||
|
||||
# Simulate what the endpoint serialises
|
||||
time_effort_dict = {
|
||||
"active_min": profile.active_min,
|
||||
"passive_min": profile.passive_min,
|
||||
"total_min": profile.total_min,
|
||||
"effort_label": profile.effort_label,
|
||||
"equipment": profile.equipment,
|
||||
"step_analyses": [
|
||||
{"is_passive": sa.is_passive, "detected_minutes": sa.detected_minutes}
|
||||
for sa in profile.step_analyses
|
||||
],
|
||||
}
|
||||
|
||||
assert time_effort_dict["active_min"] == 5
|
||||
assert time_effort_dict["passive_min"] == 20
|
||||
assert time_effort_dict["total_min"] == 25
|
||||
assert time_effort_dict["effort_label"] == "quick" # 3 steps
|
||||
assert isinstance(time_effort_dict["equipment"], list)
|
||||
assert len(time_effort_dict["step_analyses"]) == 3
|
||||
assert time_effort_dict["step_analyses"][2]["is_passive"] is True
|
||||
|
||||
def test_time_effort_none_when_no_directions(self):
|
||||
"""time_effort should be None when recipe has empty directions."""
|
||||
from app.services.recipe.time_effort import parse_time_effort
|
||||
|
||||
recipe_dict = {
|
||||
"id": 99,
|
||||
"title": "Empty",
|
||||
"directions": [],
|
||||
}
|
||||
|
||||
directions = recipe_dict.get("directions") or []
|
||||
if directions:
|
||||
profile = parse_time_effort(directions)
|
||||
recipe_dict["time_effort"] = {
|
||||
"active_min": profile.active_min,
|
||||
"passive_min": profile.passive_min,
|
||||
"total_min": profile.total_min,
|
||||
"effort_label": profile.effort_label,
|
||||
"equipment": profile.equipment,
|
||||
"step_analyses": [
|
||||
{"is_passive": sa.is_passive, "detected_minutes": sa.detected_minutes}
|
||||
for sa in profile.step_analyses
|
||||
],
|
||||
}
|
||||
else:
|
||||
recipe_dict["time_effort"] = None
|
||||
|
||||
assert recipe_dict["time_effort"] is None
|
||||
270
tests/api/test_label_capture.py
Normal file
270
tests/api/test_label_capture.py
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
"""
|
||||
Tests for the visual label capture API endpoints (kiwi#79):
|
||||
POST /api/v1/inventory/scan/label-capture
|
||||
POST /api/v1/inventory/scan/label-confirm
|
||||
GET /api/v1/inventory/scan/text — cache hit + needs_visual_capture flag
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.main import app
|
||||
from app.cloud_session import get_session
|
||||
from app.db.session import get_store
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def _session(tier: str = "paid", has_byok: bool = False) -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.tier = tier
|
||||
m.has_byok = has_byok
|
||||
m.user_id = "test-user"
|
||||
return m
|
||||
|
||||
|
||||
def _store(**extra_returns) -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.get_setting.return_value = None
|
||||
m.get_captured_product.return_value = None
|
||||
m.save_captured_product.return_value = {"id": 1, "barcode": "1234567890", "confirmed_by_user": 1}
|
||||
m.get_or_create_product.return_value = (
|
||||
{"id": 10, "name": "Test Product", "barcode": "1234567890",
|
||||
"brand": None, "category": None, "description": None,
|
||||
"image_url": None, "nutrition_data": {}, "source": "visual_capture",
|
||||
"created_at": "2026-01-01", "updated_at": "2026-01-01"},
|
||||
False,
|
||||
)
|
||||
m.add_inventory_item.return_value = {
|
||||
"id": 99, "product_id": 10, "product_name": "Test Product",
|
||||
"barcode": "1234567890", "category": None,
|
||||
"quantity": 1.0, "unit": "count", "location": "pantry",
|
||||
"sublocation": None, "purchase_date": None,
|
||||
"expiration_date": None, "opened_date": None,
|
||||
"opened_expiry_date": None, "secondary_state": None,
|
||||
"secondary_uses": None, "secondary_warning": None,
|
||||
"secondary_discard_signs": None, "status": "available",
|
||||
"notes": None, "disposal_reason": None,
|
||||
"source": "visual_capture", "created_at": "2026-01-01",
|
||||
"updated_at": "2026-01-01",
|
||||
}
|
||||
for k, v in extra_returns.items():
|
||||
setattr(m, k, v)
|
||||
return m
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_env(monkeypatch):
|
||||
monkeypatch.setenv("KIWI_LABEL_CAPTURE_MOCK", "1")
|
||||
yield
|
||||
monkeypatch.delenv("KIWI_LABEL_CAPTURE_MOCK", raising=False)
|
||||
|
||||
|
||||
# ── /scan/label-capture ───────────────────────────────────────────────────────
|
||||
|
||||
class TestLabelCaptureEndpoint:
|
||||
def setup_method(self):
|
||||
self.session = _session(tier="paid")
|
||||
self.store = _store()
|
||||
app.dependency_overrides[get_session] = lambda: self.session
|
||||
app.dependency_overrides[get_store] = lambda: self.store
|
||||
|
||||
def teardown_method(self):
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_returns_200_for_paid_tier(self):
|
||||
resp = client.post(
|
||||
"/api/v1/inventory/scan/label-capture",
|
||||
data={"barcode": "1234567890"},
|
||||
files={"file": ("label.jpg", io.BytesIO(b"fake_image"), "image/jpeg")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_response_contains_barcode(self):
|
||||
resp = client.post(
|
||||
"/api/v1/inventory/scan/label-capture",
|
||||
data={"barcode": "5901234123457"},
|
||||
files={"file": ("label.jpg", io.BytesIO(b"fake_image"), "image/jpeg")},
|
||||
)
|
||||
data = resp.json()
|
||||
assert data["barcode"] == "5901234123457"
|
||||
|
||||
def test_response_has_needs_review_field(self):
|
||||
resp = client.post(
|
||||
"/api/v1/inventory/scan/label-capture",
|
||||
data={"barcode": "1234567890"},
|
||||
files={"file": ("label.jpg", io.BytesIO(b"fake_image"), "image/jpeg")},
|
||||
)
|
||||
data = resp.json()
|
||||
assert "needs_review" in data
|
||||
|
||||
def test_mock_extraction_has_zero_confidence(self):
|
||||
resp = client.post(
|
||||
"/api/v1/inventory/scan/label-capture",
|
||||
data={"barcode": "1234567890"},
|
||||
files={"file": ("label.jpg", io.BytesIO(b"fake_image"), "image/jpeg")},
|
||||
)
|
||||
data = resp.json()
|
||||
assert data["confidence"] == 0.0
|
||||
assert data["needs_review"] is True
|
||||
|
||||
def test_free_tier_returns_403(self):
|
||||
app.dependency_overrides[get_session] = lambda: _session(tier="free")
|
||||
resp = client.post(
|
||||
"/api/v1/inventory/scan/label-capture",
|
||||
data={"barcode": "1234567890"},
|
||||
files={"file": ("label.jpg", io.BytesIO(b"fake_image"), "image/jpeg")},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_local_tier_bypasses_gate(self):
|
||||
app.dependency_overrides[get_session] = lambda: _session(tier="local")
|
||||
resp = client.post(
|
||||
"/api/v1/inventory/scan/label-capture",
|
||||
data={"barcode": "1234567890"},
|
||||
files={"file": ("label.jpg", io.BytesIO(b"fake_image"), "image/jpeg")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ── /scan/label-confirm ───────────────────────────────────────────────────────
|
||||
|
||||
class TestLabelConfirmEndpoint:
|
||||
def setup_method(self):
|
||||
self.session = _session(tier="paid")
|
||||
self.store = _store()
|
||||
app.dependency_overrides[get_session] = lambda: self.session
|
||||
app.dependency_overrides[get_store] = lambda: self.store
|
||||
|
||||
def teardown_method(self):
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_returns_200(self):
|
||||
resp = client.post("/api/v1/inventory/scan/label-confirm", json={
|
||||
"barcode": "1234567890",
|
||||
"product_name": "Test Crackers",
|
||||
"calories": 120.0,
|
||||
"ingredient_names": ["flour", "salt"],
|
||||
"allergens": ["wheat"],
|
||||
"confidence": 0.88,
|
||||
"auto_add": True,
|
||||
"location": "pantry",
|
||||
"quantity": 1.0,
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_ok_true_in_response(self):
|
||||
resp = client.post("/api/v1/inventory/scan/label-confirm", json={
|
||||
"barcode": "1234567890",
|
||||
"auto_add": False,
|
||||
})
|
||||
assert resp.json()["ok"] is True
|
||||
|
||||
def test_save_captured_product_called(self):
|
||||
client.post("/api/v1/inventory/scan/label-confirm", json={
|
||||
"barcode": "1234567890",
|
||||
"product_name": "Test",
|
||||
"auto_add": False,
|
||||
})
|
||||
self.store.save_captured_product.assert_called_once()
|
||||
call_kwargs = self.store.save_captured_product.call_args
|
||||
assert call_kwargs[0][0] == "1234567890"
|
||||
|
||||
def test_auto_add_true_creates_inventory_item(self):
|
||||
resp = client.post("/api/v1/inventory/scan/label-confirm", json={
|
||||
"barcode": "1234567890",
|
||||
"auto_add": True,
|
||||
})
|
||||
data = resp.json()
|
||||
assert data["inventory_item_id"] is not None
|
||||
self.store.add_inventory_item.assert_called_once()
|
||||
|
||||
def test_auto_add_false_skips_inventory(self):
|
||||
resp = client.post("/api/v1/inventory/scan/label-confirm", json={
|
||||
"barcode": "1234567890",
|
||||
"auto_add": False,
|
||||
})
|
||||
data = resp.json()
|
||||
assert data["inventory_item_id"] is None
|
||||
self.store.add_inventory_item.assert_not_called()
|
||||
|
||||
def test_free_tier_blocked(self):
|
||||
app.dependency_overrides[get_session] = lambda: _session(tier="free")
|
||||
resp = client.post("/api/v1/inventory/scan/label-confirm", json={"barcode": "1234567890"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ── /scan/text cache hit + needs_visual_capture ───────────────────────────────
|
||||
|
||||
class TestScanTextWithCaptureGating:
|
||||
def teardown_method(self):
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def _setup(self, tier: str, cached=None, off_result=None):
|
||||
store = _store()
|
||||
store.get_captured_product.return_value = cached
|
||||
session = _session(tier=tier)
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
app.dependency_overrides[get_store] = lambda: store
|
||||
return store
|
||||
|
||||
def _off_patch(self, result):
|
||||
"""Patch OpenFoodFactsService.lookup_product at the class level."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
return patch(
|
||||
"app.services.openfoodfacts.OpenFoodFactsService.lookup_product",
|
||||
new=AsyncMock(return_value=result),
|
||||
)
|
||||
|
||||
def test_paid_tier_no_product_sets_needs_visual_capture(self):
|
||||
self._setup(tier="paid")
|
||||
with self._off_patch(None):
|
||||
resp = client.post("/api/v1/inventory/scan/text", json={
|
||||
"barcode": "0000000000000", "location": "pantry",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
result = resp.json()["results"][0]
|
||||
assert result["needs_visual_capture"] is True
|
||||
assert result["needs_manual_entry"] is False
|
||||
|
||||
def test_free_tier_no_product_sets_needs_manual_entry(self):
|
||||
self._setup(tier="free")
|
||||
with self._off_patch(None):
|
||||
resp = client.post("/api/v1/inventory/scan/text", json={
|
||||
"barcode": "0000000000000", "location": "pantry",
|
||||
})
|
||||
result = resp.json()["results"][0]
|
||||
assert result["needs_visual_capture"] is False
|
||||
assert result["needs_manual_entry"] is True
|
||||
|
||||
def test_cache_hit_uses_captured_product(self):
|
||||
cached = {
|
||||
"barcode": "9999999999999",
|
||||
"product_name": "Cached Crackers",
|
||||
"brand": "TestBrand",
|
||||
"confirmed_by_user": 1,
|
||||
"ingredient_names": ["flour"],
|
||||
"allergens": [],
|
||||
"calories": 110.0,
|
||||
"fat_g": None, "saturated_fat_g": None, "carbs_g": None,
|
||||
"sugar_g": None, "fiber_g": None, "protein_g": None,
|
||||
"sodium_mg": None, "serving_size_g": None,
|
||||
}
|
||||
store = self._setup(tier="paid", cached=cached)
|
||||
store.get_inventory_item.return_value = store.add_inventory_item.return_value
|
||||
|
||||
with self._off_patch(None): # OFF never called when cache hits
|
||||
resp = client.post("/api/v1/inventory/scan/text", json={
|
||||
"barcode": "9999999999999", "location": "pantry",
|
||||
})
|
||||
|
||||
assert resp.status_code == 200
|
||||
result = resp.json()["results"][0]
|
||||
assert result["added_to_inventory"] is True
|
||||
assert result["needs_visual_capture"] is False
|
||||
# OFF was not called (cache resolved it)
|
||||
# store.get_captured_product was called with the barcode
|
||||
store.get_captured_product.assert_called_once_with("9999999999999")
|
||||
|
|
@ -86,7 +86,7 @@ def test_hard_day_mode_uses_equipment_setting(tmp_store: MagicMock) -> None:
|
|||
result = engine.suggest(req)
|
||||
|
||||
# Engine should have read the equipment setting
|
||||
tmp_store.get_setting.assert_called_with("cooking_equipment")
|
||||
tmp_store.get_setting.assert_any_call("cooking_equipment")
|
||||
# Result is a valid RecipeResult (no crash)
|
||||
assert result is not None
|
||||
assert hasattr(result, "suggestions")
|
||||
|
|
@ -108,3 +108,62 @@ def test_put_null_value_returns_422(tmp_store: MagicMock) -> None:
|
|||
json={"value": None},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_set_and_get_sensory_preferences(tmp_store: MagicMock) -> None:
|
||||
"""PUT then GET round-trips the sensory_preferences value."""
|
||||
prefs = json.dumps({
|
||||
"avoid_textures": ["mushy", "slimy"],
|
||||
"max_smell": "pungent",
|
||||
"max_noise": "loud",
|
||||
})
|
||||
|
||||
put_resp = client.put(
|
||||
"/api/v1/settings/sensory_preferences",
|
||||
json={"value": prefs},
|
||||
)
|
||||
assert put_resp.status_code == 200
|
||||
assert put_resp.json()["key"] == "sensory_preferences"
|
||||
tmp_store.set_setting.assert_called_with("sensory_preferences", prefs)
|
||||
|
||||
tmp_store.get_setting.return_value = prefs
|
||||
get_resp = client.get("/api/v1/settings/sensory_preferences")
|
||||
assert get_resp.status_code == 200
|
||||
assert get_resp.json()["value"] == prefs
|
||||
|
||||
|
||||
def test_sensory_preferences_unknown_key_still_422(tmp_store: MagicMock) -> None:
|
||||
"""Confirm unknown keys still 422 after adding sensory_preferences."""
|
||||
resp = client.put(
|
||||
"/api/v1/settings/sensory_taste_buds",
|
||||
json={"value": "{}"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_set_and_get_time_first_layout(tmp_store: MagicMock) -> None:
|
||||
"""PUT then GET round-trips the time_first_layout value."""
|
||||
layout_value = "time_first"
|
||||
|
||||
put_resp = client.put(
|
||||
"/api/v1/settings/time_first_layout",
|
||||
json={"value": layout_value},
|
||||
)
|
||||
assert put_resp.status_code == 200
|
||||
assert put_resp.json()["key"] == "time_first_layout"
|
||||
assert put_resp.json()["value"] == layout_value
|
||||
tmp_store.set_setting.assert_called_with("time_first_layout", layout_value)
|
||||
|
||||
tmp_store.get_setting.return_value = layout_value
|
||||
get_resp = client.get("/api/v1/settings/time_first_layout")
|
||||
assert get_resp.status_code == 200
|
||||
assert get_resp.json()["value"] == layout_value
|
||||
|
||||
|
||||
def test_time_first_layout_unknown_key_still_422(tmp_store: MagicMock) -> None:
|
||||
"""Confirm unknown keys still 422 after adding time_first_layout."""
|
||||
resp = client.put(
|
||||
"/api/v1/settings/time_first_mode",
|
||||
json={"value": "time_first"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
|
|
|||
111
tests/api/test_stream_token.py
Normal file
111
tests/api/test_stream_token.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"""Tests for POST /api/v1/recipes/stream-token — coordinator proxy integration."""
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.cloud_session import CloudUser, get_session
|
||||
from app.main import app
|
||||
from app.models.schemas.recipe import StreamTokenRequest, StreamTokenResponse
|
||||
|
||||
|
||||
def _make_session(tier: str = "paid", has_byok: bool = False) -> CloudUser:
|
||||
return CloudUser(
|
||||
user_id="test-user",
|
||||
tier=tier,
|
||||
db=Path("/tmp/kiwi_test.db"),
|
||||
has_byok=has_byok,
|
||||
license_key=None,
|
||||
)
|
||||
|
||||
|
||||
def _client(tier: str = "paid", has_byok: bool = False) -> TestClient:
|
||||
app.dependency_overrides[get_session] = lambda: _make_session(tier=tier, has_byok=has_byok)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_coordinator_authorize_missing_url(monkeypatch):
|
||||
"""coordinator_authorize raises RuntimeError when COORDINATOR_URL is unset."""
|
||||
monkeypatch.delenv("COORDINATOR_URL", raising=False)
|
||||
monkeypatch.delenv("COORDINATOR_KIWI_KEY", raising=False)
|
||||
# Will test this properly via endpoint — see Task 3 tests.
|
||||
pass
|
||||
|
||||
|
||||
def test_stream_token_request_defaults():
|
||||
req = StreamTokenRequest()
|
||||
assert req.level == 4
|
||||
assert req.wildcard_confirmed is False
|
||||
|
||||
|
||||
def test_stream_token_request_level_3():
|
||||
req = StreamTokenRequest(level=3)
|
||||
assert req.level == 3
|
||||
|
||||
|
||||
def test_stream_token_response():
|
||||
resp = StreamTokenResponse(
|
||||
stream_url="http://10.1.10.71:7700/proxy/stream",
|
||||
token="abc-123",
|
||||
expires_in_s=60,
|
||||
)
|
||||
assert resp.stream_url.startswith("http")
|
||||
assert resp.expires_in_s == 60
|
||||
|
||||
|
||||
def test_stream_token_tier_gate():
|
||||
"""Free-tier session is rejected with 403."""
|
||||
client = _client(tier="free")
|
||||
try:
|
||||
resp = client.post("/api/v1/recipes/stream-token", json={"level": 3})
|
||||
assert resp.status_code == 403
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_stream_token_level4_requires_confirmation():
|
||||
"""Level 4 without wildcard_confirmed=true returns 400."""
|
||||
client = _client(tier="paid")
|
||||
try:
|
||||
resp = client.post("/api/v1/recipes/stream-token", json={"level": 4, "wildcard_confirmed": False})
|
||||
assert resp.status_code == 400
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@patch("app.api.endpoints.recipes.coordinator_authorize", new_callable=AsyncMock)
|
||||
@patch("app.api.endpoints.recipes._build_stream_prompt", return_value="mock prompt")
|
||||
def test_stream_token_success_level3(mock_prompt, mock_authorize):
|
||||
"""Paid tier, level 3 — returns stream_url and token."""
|
||||
from app.services.coordinator_proxy import StreamTokenResult
|
||||
mock_authorize.return_value = StreamTokenResult(
|
||||
stream_url="http://10.1.10.71:7700/proxy/stream",
|
||||
token="test-token-abc",
|
||||
expires_in_s=60,
|
||||
)
|
||||
client = _client(tier="paid")
|
||||
try:
|
||||
resp = client.post("/api/v1/recipes/stream-token", json={"level": 3})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "stream_url" in data
|
||||
assert "token" in data
|
||||
assert data["expires_in_s"] == 60
|
||||
mock_authorize.assert_awaited_once()
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@patch("app.api.endpoints.recipes.coordinator_authorize", new_callable=AsyncMock)
|
||||
@patch("app.api.endpoints.recipes._build_stream_prompt", return_value="mock prompt")
|
||||
def test_stream_token_coordinator_unavailable(mock_prompt, mock_authorize):
|
||||
"""CoordinatorError maps to 503."""
|
||||
from app.services.coordinator_proxy import CoordinatorError
|
||||
mock_authorize.side_effect = CoordinatorError("No GPU", status_code=503)
|
||||
client = _client(tier="paid")
|
||||
try:
|
||||
resp = client.post("/api/v1/recipes/stream-token", json={"level": 3})
|
||||
assert resp.status_code == 503
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
116
tests/db/test_captured_products.py
Normal file
116
tests/db/test_captured_products.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""Tests for captured_products store methods (kiwi#79)."""
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from app.db.store import Store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path: Path) -> Store:
|
||||
s = Store(tmp_path / "test.db")
|
||||
yield s
|
||||
s.close()
|
||||
|
||||
|
||||
class TestMigration:
|
||||
def test_captured_products_table_exists(self, store):
|
||||
cur = store.conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='captured_products'"
|
||||
)
|
||||
assert cur.fetchone() is not None
|
||||
|
||||
def test_captured_products_columns(self, store):
|
||||
cur = store.conn.execute("PRAGMA table_info(captured_products)")
|
||||
# PRAGMA returns plain tuples: (cid, name, type, notnull, dflt_value, pk)
|
||||
cols = {row[1] for row in cur.fetchall()}
|
||||
expected = {
|
||||
"id", "barcode", "product_name", "brand", "serving_size_g",
|
||||
"calories", "fat_g", "saturated_fat_g", "carbs_g", "sugar_g",
|
||||
"fiber_g", "protein_g", "sodium_mg", "ingredient_names",
|
||||
"allergens", "confidence", "source", "captured_at",
|
||||
"confirmed_by_user",
|
||||
}
|
||||
assert expected.issubset(cols)
|
||||
|
||||
|
||||
class TestGetCapturedProduct:
|
||||
def test_returns_none_for_unknown_barcode(self, store):
|
||||
assert store.get_captured_product("0000000000000") is None
|
||||
|
||||
def test_returns_row_after_save(self, store):
|
||||
store.save_captured_product("1234567890123", product_name="Test Crackers")
|
||||
result = store.get_captured_product("1234567890123")
|
||||
assert result is not None
|
||||
assert result["product_name"] == "Test Crackers"
|
||||
|
||||
def test_ingredient_names_decoded_as_list(self, store):
|
||||
store.save_captured_product(
|
||||
"1111111111111",
|
||||
ingredient_names=["wheat flour", "salt"],
|
||||
)
|
||||
result = store.get_captured_product("1111111111111")
|
||||
assert result["ingredient_names"] == ["wheat flour", "salt"]
|
||||
|
||||
def test_allergens_decoded_as_list(self, store):
|
||||
store.save_captured_product(
|
||||
"2222222222222",
|
||||
allergens=["wheat", "milk"],
|
||||
)
|
||||
result = store.get_captured_product("2222222222222")
|
||||
assert result["allergens"] == ["wheat", "milk"]
|
||||
|
||||
|
||||
class TestSaveCapturedProduct:
|
||||
def test_all_nutrition_fields_persisted(self, store):
|
||||
store.save_captured_product(
|
||||
"3333333333333",
|
||||
product_name="Oat Crackers",
|
||||
brand="TestBrand",
|
||||
serving_size_g=30.0,
|
||||
calories=120.0,
|
||||
fat_g=4.0,
|
||||
saturated_fat_g=0.5,
|
||||
carbs_g=20.0,
|
||||
sugar_g=2.0,
|
||||
fiber_g=1.0,
|
||||
protein_g=3.0,
|
||||
sodium_mg=200.0,
|
||||
confidence=0.92,
|
||||
)
|
||||
row = store.get_captured_product("3333333333333")
|
||||
assert row["brand"] == "TestBrand"
|
||||
assert row["calories"] == 120.0
|
||||
assert row["protein_g"] == 3.0
|
||||
assert row["confidence"] == 0.92
|
||||
|
||||
def test_confirmed_by_user_defaults_true(self, store):
|
||||
store.save_captured_product("4444444444444")
|
||||
row = store.get_captured_product("4444444444444")
|
||||
assert row["confirmed_by_user"] == 1
|
||||
|
||||
def test_confirmed_by_user_false(self, store):
|
||||
store.save_captured_product("5555555555555", confirmed_by_user=False)
|
||||
row = store.get_captured_product("5555555555555")
|
||||
assert row["confirmed_by_user"] == 0
|
||||
|
||||
def test_upsert_on_conflict(self, store):
|
||||
"""Second save for same barcode updates in-place rather than erroring."""
|
||||
store.save_captured_product("6666666666666", product_name="Old Name")
|
||||
store.save_captured_product("6666666666666", product_name="New Name")
|
||||
row = store.get_captured_product("6666666666666")
|
||||
assert row["product_name"] == "New Name"
|
||||
# Still only one row
|
||||
cur = store.conn.execute(
|
||||
"SELECT count(*) FROM captured_products WHERE barcode='6666666666666'"
|
||||
)
|
||||
assert cur.fetchone()[0] == 1
|
||||
|
||||
def test_empty_lists_stored_and_retrieved(self, store):
|
||||
store.save_captured_product("7777777777777", ingredient_names=[], allergens=[])
|
||||
row = store.get_captured_product("7777777777777")
|
||||
assert row["ingredient_names"] == []
|
||||
assert row["allergens"] == []
|
||||
|
||||
def test_source_default(self, store):
|
||||
store.save_captured_product("8888888888888")
|
||||
row = store.get_captured_product("8888888888888")
|
||||
assert row["source"] == "visual_capture"
|
||||
|
|
@ -134,3 +134,73 @@ def test_suggest_returns_no_assembly_results(store_with_recipes):
|
|||
result = engine.suggest(req)
|
||||
assembly_ids = [s.id for s in result.suggestions if s.id < 0]
|
||||
assert assembly_ids == [], f"Found assembly results in suggest(): {assembly_ids}"
|
||||
|
||||
|
||||
# ── _within_time tests (kiwi#52) ──────────────────────────────────────────────
|
||||
|
||||
def test_within_time_no_directions_passes():
|
||||
"""Empty directions -> True (don't hide recipes with no data)."""
|
||||
from app.services.recipe.recipe_engine import _within_time
|
||||
assert _within_time([], max_total_min=10) is True
|
||||
|
||||
|
||||
def test_within_time_no_time_signals_passes():
|
||||
"""Directions with no time signals -> total_min == 0 -> True."""
|
||||
from app.services.recipe.recipe_engine import _within_time
|
||||
steps = ["mix together", "pour over ice", "serve immediately"]
|
||||
assert _within_time(steps, max_total_min=5) is True
|
||||
|
||||
|
||||
def test_within_time_under_limit_passes():
|
||||
"""Recipe with 10 min total and limit of 15 -> passes."""
|
||||
from app.services.recipe.recipe_engine import _within_time
|
||||
steps = ["cook for 10 minutes", "serve"]
|
||||
assert _within_time(steps, max_total_min=15) is True
|
||||
|
||||
|
||||
def test_within_time_at_limit_passes():
|
||||
"""Recipe exactly at limit -> passes (inclusive boundary)."""
|
||||
from app.services.recipe.recipe_engine import _within_time
|
||||
steps = ["simmer for 10 minutes"]
|
||||
assert _within_time(steps, max_total_min=10) is True
|
||||
|
||||
|
||||
def test_within_time_over_limit_fails():
|
||||
"""Recipe with 45 min total and limit of 30 -> fails."""
|
||||
from app.services.recipe.recipe_engine import _within_time
|
||||
steps = ["brown onions for 15 minutes", "simmer for 30 minutes"]
|
||||
assert _within_time(steps, max_total_min=30) is False
|
||||
|
||||
|
||||
# ── Reranker tier-gating tests ────────────────────────────────────────────────
|
||||
|
||||
def test_paid_tier_suggest_populates_rerank_score(store_with_recipes, monkeypatch):
|
||||
"""Paid tier: at least one suggestion should have rerank_score populated."""
|
||||
monkeypatch.setenv("CF_RERANKER_MOCK", "1")
|
||||
try:
|
||||
from circuitforge_core.reranker import reset_reranker
|
||||
reset_reranker()
|
||||
except ImportError:
|
||||
pytest.skip("cf-core reranker not installed")
|
||||
|
||||
from app.services.recipe.recipe_engine import RecipeEngine
|
||||
from app.models.schemas.recipe import RecipeRequest
|
||||
engine = RecipeEngine(store_with_recipes)
|
||||
req = RecipeRequest(pantry_items=["butter", "parmesan", "pasta"], level=1, tier="paid")
|
||||
result = engine.suggest(req)
|
||||
# Need at least _MIN_CANDIDATES for reranker to fire
|
||||
from app.services.recipe.reranker import _MIN_CANDIDATES
|
||||
if len(result.suggestions) >= _MIN_CANDIDATES:
|
||||
assert any(s.rerank_score is not None for s in result.suggestions)
|
||||
|
||||
reset_reranker()
|
||||
|
||||
|
||||
def test_free_tier_suggest_has_no_rerank_score(store_with_recipes):
|
||||
"""Free tier: rerank_score must be None on all suggestions."""
|
||||
from app.services.recipe.recipe_engine import RecipeEngine
|
||||
from app.models.schemas.recipe import RecipeRequest
|
||||
engine = RecipeEngine(store_with_recipes)
|
||||
req = RecipeRequest(pantry_items=["butter", "parmesan"], level=1, tier="free")
|
||||
result = engine.suggest(req)
|
||||
assert all(s.rerank_score is None for s in result.suggestions)
|
||||
|
|
|
|||
287
tests/services/recipe/test_reranker.py
Normal file
287
tests/services/recipe/test_reranker.py
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
"""
|
||||
Tests for app.services.recipe.reranker.
|
||||
|
||||
All tests use CF_RERANKER_MOCK=1 -- no model weights required.
|
||||
The mock reranker scores by Jaccard similarity of query tokens vs candidate
|
||||
tokens, which is deterministic and fast.
|
||||
"""
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_reranker(monkeypatch):
|
||||
"""Force mock backend and reset the cf-core singleton before/after each test."""
|
||||
monkeypatch.setenv("CF_RERANKER_MOCK", "1")
|
||||
try:
|
||||
from circuitforge_core.reranker import reset_reranker
|
||||
reset_reranker()
|
||||
yield
|
||||
reset_reranker()
|
||||
except ImportError:
|
||||
yield
|
||||
|
||||
|
||||
def _make_request(**kwargs):
|
||||
from app.models.schemas.recipe import RecipeRequest
|
||||
defaults = dict(pantry_items=["chicken", "rice"], tier="paid")
|
||||
defaults.update(kwargs)
|
||||
return RecipeRequest(**defaults)
|
||||
|
||||
|
||||
def _make_suggestion(id: int, title: str, matched: list[str], missing: list[str] | None = None, match_count: int | None = None):
|
||||
from app.models.schemas.recipe import RecipeSuggestion
|
||||
mi = missing or []
|
||||
return RecipeSuggestion(
|
||||
id=id,
|
||||
title=title,
|
||||
match_count=match_count if match_count is not None else len(matched),
|
||||
matched_ingredients=matched,
|
||||
missing_ingredients=mi,
|
||||
)
|
||||
|
||||
|
||||
# ── TestBuildQuery ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestBuildQuery:
|
||||
def test_basic_pantry(self):
|
||||
from app.services.recipe.reranker import build_query
|
||||
req = _make_request(pantry_items=["chicken", "rice", "broccoli"])
|
||||
query = build_query(req)
|
||||
assert "chicken" in query
|
||||
assert "rice" in query
|
||||
assert "broccoli" in query
|
||||
|
||||
def test_exclude_ingredients_included(self):
|
||||
from app.services.recipe.reranker import build_query
|
||||
req = _make_request(exclude_ingredients=["cilantro", "fish sauce"])
|
||||
query = build_query(req)
|
||||
assert "cilantro" in query
|
||||
assert "fish sauce" in query
|
||||
|
||||
def test_allergies_separate_from_exclude(self):
|
||||
from app.services.recipe.reranker import build_query
|
||||
req = _make_request(allergies=["shellfish"], exclude_ingredients=["cilantro"])
|
||||
query = build_query(req)
|
||||
# Both should appear, and they should be in separate labeled segments
|
||||
assert "shellfish" in query
|
||||
assert "cilantro" in query
|
||||
allergy_pos = query.index("shellfish")
|
||||
exclude_pos = query.index("cilantro")
|
||||
assert allergy_pos != exclude_pos
|
||||
|
||||
def test_allergies_labeled_separately(self):
|
||||
from app.services.recipe.reranker import build_query
|
||||
req = _make_request(allergies=["peanuts"], exclude_ingredients=[])
|
||||
query = build_query(req)
|
||||
assert "Allergies" in query or "allerg" in query.lower()
|
||||
|
||||
def test_constraints_included(self):
|
||||
from app.services.recipe.reranker import build_query
|
||||
req = _make_request(constraints=["gluten-free", "dairy-free"])
|
||||
query = build_query(req)
|
||||
assert "gluten-free" in query
|
||||
assert "dairy-free" in query
|
||||
|
||||
def test_category_included(self):
|
||||
from app.services.recipe.reranker import build_query
|
||||
req = _make_request(category="Soup")
|
||||
query = build_query(req)
|
||||
assert "Soup" in query
|
||||
|
||||
def test_complexity_filter_included(self):
|
||||
from app.services.recipe.reranker import build_query
|
||||
req = _make_request(complexity_filter="easy")
|
||||
query = build_query(req)
|
||||
assert "easy" in query
|
||||
|
||||
def test_hard_day_mode_signal(self):
|
||||
from app.services.recipe.reranker import build_query
|
||||
req = _make_request(hard_day_mode=True)
|
||||
query = build_query(req)
|
||||
assert "easy" in query.lower() or "minimal" in query.lower() or "effort" in query.lower()
|
||||
|
||||
def test_secondary_pantry_items_expiry(self):
|
||||
from app.services.recipe.reranker import build_query
|
||||
req = _make_request(secondary_pantry_items={"bread": "stale", "banana": "overripe"})
|
||||
query = build_query(req)
|
||||
assert "bread" in query
|
||||
assert "banana" in query
|
||||
# State labels add specificity for the cross-encoder
|
||||
assert "stale" in query or "overripe" in query
|
||||
|
||||
def test_expiry_first_without_secondary(self):
|
||||
from app.services.recipe.reranker import build_query
|
||||
req = _make_request(expiry_first=True, secondary_pantry_items={})
|
||||
query = build_query(req)
|
||||
assert "expir" in query.lower()
|
||||
|
||||
def test_style_id_included(self):
|
||||
from app.services.recipe.reranker import build_query
|
||||
req = _make_request(style_id="mediterranean")
|
||||
query = build_query(req)
|
||||
assert "mediterranean" in query.lower()
|
||||
|
||||
def test_empty_pantry_returns_fallback(self):
|
||||
from app.services.recipe.reranker import build_query
|
||||
req = _make_request(pantry_items=[])
|
||||
query = build_query(req)
|
||||
assert len(query) > 0 # never empty string
|
||||
|
||||
def test_no_duplicate_separators(self):
|
||||
from app.services.recipe.reranker import build_query
|
||||
req = _make_request(
|
||||
pantry_items=["egg"],
|
||||
allergies=["nuts"],
|
||||
constraints=["vegan"],
|
||||
complexity_filter="easy",
|
||||
)
|
||||
query = build_query(req)
|
||||
assert ".." not in query # no doubled periods from empty segments
|
||||
|
||||
|
||||
# ── TestBuildCandidateString ──────────────────────────────────────────────────
|
||||
|
||||
class TestBuildCandidateString:
|
||||
def test_title_and_ingredients(self):
|
||||
from app.services.recipe.reranker import build_candidate_string
|
||||
s = _make_suggestion(1, "Chicken Fried Rice", ["chicken", "rice"], ["soy sauce"])
|
||||
candidate = build_candidate_string(s)
|
||||
assert candidate.startswith("Chicken Fried Rice")
|
||||
assert "chicken" in candidate
|
||||
assert "rice" in candidate
|
||||
assert "soy sauce" in candidate
|
||||
|
||||
def test_title_only_when_no_ingredients(self):
|
||||
from app.services.recipe.reranker import build_candidate_string
|
||||
s = _make_suggestion(2, "Mystery Dish", [], [])
|
||||
candidate = build_candidate_string(s)
|
||||
assert candidate == "Mystery Dish"
|
||||
assert "Ingredients:" not in candidate
|
||||
|
||||
def test_matched_before_missing(self):
|
||||
from app.services.recipe.reranker import build_candidate_string
|
||||
s = _make_suggestion(3, "Pasta Dish", ["pasta", "butter"], ["parmesan", "cream"])
|
||||
candidate = build_candidate_string(s)
|
||||
pasta_pos = candidate.index("pasta")
|
||||
parmesan_pos = candidate.index("parmesan")
|
||||
assert pasta_pos < parmesan_pos
|
||||
|
||||
|
||||
# ── TestBuildRerankerInput ────────────────────────────────────────────────────
|
||||
|
||||
class TestBuildRerankerInput:
|
||||
def test_parallel_ids_and_candidates(self):
|
||||
from app.services.recipe.reranker import build_reranker_input
|
||||
req = _make_request()
|
||||
suggestions = [
|
||||
_make_suggestion(10, "Recipe A", ["chicken"]),
|
||||
_make_suggestion(20, "Recipe B", ["rice"]),
|
||||
_make_suggestion(30, "Recipe C", ["broccoli"]),
|
||||
]
|
||||
rinput = build_reranker_input(req, suggestions)
|
||||
assert len(rinput.candidates) == 3
|
||||
assert len(rinput.suggestion_ids) == 3
|
||||
assert rinput.suggestion_ids == [10, 20, 30]
|
||||
|
||||
def test_query_matches_build_query(self):
|
||||
from app.services.recipe.reranker import build_reranker_input, build_query
|
||||
req = _make_request(pantry_items=["egg", "cheese"], constraints=["vegetarian"])
|
||||
suggestions = [_make_suggestion(1, "Omelette", ["egg", "cheese"])]
|
||||
rinput = build_reranker_input(req, suggestions)
|
||||
assert rinput.query == build_query(req)
|
||||
|
||||
|
||||
# ── TestRerankSuggestions ─────────────────────────────────────────────────────
|
||||
|
||||
class TestRerankSuggestions:
|
||||
def test_free_tier_returns_none(self):
|
||||
from app.services.recipe.reranker import rerank_suggestions
|
||||
req = _make_request(tier="free")
|
||||
suggestions = [_make_suggestion(i, f"Recipe {i}", ["chicken"]) for i in range(5)]
|
||||
result = rerank_suggestions(req, suggestions)
|
||||
assert result is None
|
||||
|
||||
def test_paid_tier_returns_reranked_list(self):
|
||||
from app.services.recipe.reranker import rerank_suggestions
|
||||
req = _make_request(tier="paid", pantry_items=["chicken", "rice"])
|
||||
suggestions = [
|
||||
_make_suggestion(1, "Chicken Fried Rice", ["chicken", "rice"]),
|
||||
_make_suggestion(2, "Chocolate Cake", ["flour", "sugar", "cocoa"]),
|
||||
_make_suggestion(3, "Chicken Soup", ["chicken", "broth"]),
|
||||
]
|
||||
result = rerank_suggestions(req, suggestions)
|
||||
assert result is not None
|
||||
assert len(result) == len(suggestions)
|
||||
|
||||
def test_rerank_score_is_populated(self):
|
||||
from app.services.recipe.reranker import rerank_suggestions
|
||||
req = _make_request(tier="paid")
|
||||
suggestions = [_make_suggestion(i, f"Recipe {i}", ["chicken"]) for i in range(4)]
|
||||
result = rerank_suggestions(req, suggestions)
|
||||
assert result is not None
|
||||
assert all(s.rerank_score is not None for s in result)
|
||||
assert all(isinstance(s.rerank_score, float) for s in result)
|
||||
|
||||
def test_too_few_candidates_returns_none(self):
|
||||
from app.services.recipe.reranker import rerank_suggestions, _MIN_CANDIDATES
|
||||
req = _make_request(tier="paid")
|
||||
suggestions = [_make_suggestion(i, f"Recipe {i}", ["chicken"]) for i in range(_MIN_CANDIDATES - 1)]
|
||||
result = rerank_suggestions(req, suggestions)
|
||||
assert result is None
|
||||
|
||||
def test_premium_tier_gets_reranker(self):
|
||||
from app.services.recipe.reranker import rerank_suggestions
|
||||
req = _make_request(tier="premium")
|
||||
suggestions = [_make_suggestion(i, f"Recipe {i}", ["chicken"]) for i in range(4)]
|
||||
result = rerank_suggestions(req, suggestions)
|
||||
assert result is not None
|
||||
|
||||
def test_local_tier_gets_reranker(self):
|
||||
from app.services.recipe.reranker import rerank_suggestions
|
||||
req = _make_request(tier="local")
|
||||
suggestions = [_make_suggestion(i, f"Recipe {i}", ["chicken"]) for i in range(4)]
|
||||
result = rerank_suggestions(req, suggestions)
|
||||
assert result is not None
|
||||
|
||||
def test_preserves_all_suggestion_fields(self):
|
||||
from app.services.recipe.reranker import rerank_suggestions
|
||||
req = _make_request(tier="paid")
|
||||
original = _make_suggestion(
|
||||
id=42,
|
||||
title="Garlic Butter Pasta",
|
||||
matched=["pasta", "butter", "garlic"],
|
||||
missing=["parmesan"],
|
||||
match_count=3,
|
||||
)
|
||||
result = rerank_suggestions(req, [original, original, original, original])
|
||||
assert result is not None
|
||||
found = next((s for s in result if s.id == 42), None)
|
||||
assert found is not None
|
||||
assert found.title == "Garlic Butter Pasta"
|
||||
assert found.matched_ingredients == ["pasta", "butter", "garlic"]
|
||||
assert found.missing_ingredients == ["parmesan"]
|
||||
assert found.match_count == 3
|
||||
|
||||
def test_graceful_fallback_on_exception(self, monkeypatch):
|
||||
from app.services.recipe.reranker import rerank_suggestions
|
||||
# Simulate reranker raising at runtime
|
||||
import app.services.recipe.reranker as reranker_mod
|
||||
def _boom(query, candidates, top_n=0):
|
||||
raise RuntimeError("model exploded")
|
||||
monkeypatch.setattr(reranker_mod, "_do_rerank", _boom)
|
||||
req = _make_request(tier="paid")
|
||||
suggestions = [_make_suggestion(i, f"Recipe {i}", ["chicken"]) for i in range(4)]
|
||||
result = rerank_suggestions(req, suggestions)
|
||||
assert result is None
|
||||
|
||||
def test_original_suggestions_not_mutated(self):
|
||||
from app.services.recipe.reranker import rerank_suggestions
|
||||
req = _make_request(tier="paid")
|
||||
suggestions = [_make_suggestion(i, f"Recipe {i}", ["chicken"]) for i in range(4)]
|
||||
originals = [s.model_copy() for s in suggestions]
|
||||
rerank_suggestions(req, suggestions)
|
||||
for original, after in zip(originals, suggestions):
|
||||
assert original.rerank_score == after.rerank_score # None == None (no mutation)
|
||||
171
tests/services/test_label_capture.py
Normal file
171
tests/services/test_label_capture.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
"""
|
||||
Tests for app.services.label_capture.
|
||||
|
||||
All tests set KIWI_LABEL_CAPTURE_MOCK=1 so no vision model weights are needed.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_vision(monkeypatch):
|
||||
monkeypatch.setenv("KIWI_LABEL_CAPTURE_MOCK", "1")
|
||||
yield
|
||||
monkeypatch.delenv("KIWI_LABEL_CAPTURE_MOCK", raising=False)
|
||||
|
||||
|
||||
# ── TestExtractLabel ──────────────────────────────────────────────────────────
|
||||
|
||||
class TestExtractLabel:
|
||||
def test_mock_returns_dict(self):
|
||||
from app.services.label_capture import extract_label
|
||||
result = extract_label(b"fake image bytes")
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_mock_returns_all_required_keys(self):
|
||||
from app.services.label_capture import extract_label
|
||||
result = extract_label(b"fake image bytes")
|
||||
for key in ("product_name", "brand", "calories", "fat_g", "carbs_g",
|
||||
"protein_g", "sodium_mg", "ingredient_names", "allergens",
|
||||
"confidence"):
|
||||
assert key in result, f"missing key: {key}"
|
||||
|
||||
def test_mock_ingredient_names_is_list(self):
|
||||
from app.services.label_capture import extract_label
|
||||
result = extract_label(b"fake")
|
||||
assert isinstance(result["ingredient_names"], list)
|
||||
|
||||
def test_mock_allergens_is_list(self):
|
||||
from app.services.label_capture import extract_label
|
||||
result = extract_label(b"fake")
|
||||
assert isinstance(result["allergens"], list)
|
||||
|
||||
def test_mock_confidence_zero(self):
|
||||
from app.services.label_capture import extract_label
|
||||
result = extract_label(b"fake")
|
||||
assert result["confidence"] == 0.0
|
||||
|
||||
def _patch_vision(self, monkeypatch, caption_text: str):
|
||||
"""Patch circuitforge_core.vision.caption to return a VisionResult with caption_text."""
|
||||
from circuitforge_core.vision.backends.base import VisionResult
|
||||
|
||||
def _fake_caption(image_bytes, prompt=""):
|
||||
return VisionResult(caption=caption_text)
|
||||
|
||||
import circuitforge_core.vision as vision_mod
|
||||
monkeypatch.setattr(vision_mod, "caption", _fake_caption)
|
||||
|
||||
def test_exception_falls_back_to_mock(self, monkeypatch):
|
||||
"""Any exception from the vision backend returns mock extraction."""
|
||||
monkeypatch.delenv("KIWI_LABEL_CAPTURE_MOCK", raising=False)
|
||||
|
||||
import circuitforge_core.vision as vision_mod
|
||||
monkeypatch.setattr(vision_mod, "caption", lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("GPU unavailable")))
|
||||
|
||||
import app.services.label_capture as svc_mod
|
||||
result = svc_mod.extract_label(b"image")
|
||||
assert result["confidence"] == 0.0
|
||||
assert isinstance(result["ingredient_names"], list)
|
||||
|
||||
def test_live_path_parses_json_string(self, monkeypatch):
|
||||
"""When the vision backend returns valid JSON, it is parsed correctly."""
|
||||
monkeypatch.delenv("KIWI_LABEL_CAPTURE_MOCK", raising=False)
|
||||
|
||||
payload = {
|
||||
"product_name": "Test Crackers",
|
||||
"brand": "Test Brand",
|
||||
"serving_size_g": 30.0,
|
||||
"calories": 120.0,
|
||||
"fat_g": 4.0,
|
||||
"saturated_fat_g": 0.5,
|
||||
"carbs_g": 20.0,
|
||||
"sugar_g": 2.0,
|
||||
"fiber_g": 1.0,
|
||||
"protein_g": 3.0,
|
||||
"sodium_mg": 200.0,
|
||||
"ingredient_names": ["wheat flour", "canola oil", "salt"],
|
||||
"allergens": ["wheat"],
|
||||
"confidence": 0.92,
|
||||
}
|
||||
self._patch_vision(monkeypatch, json.dumps(payload))
|
||||
|
||||
import app.services.label_capture as svc_mod
|
||||
result = svc_mod.extract_label(b"image")
|
||||
assert result["product_name"] == "Test Crackers"
|
||||
assert result["calories"] == 120.0
|
||||
assert result["ingredient_names"] == ["wheat flour", "canola oil", "salt"]
|
||||
assert result["allergens"] == ["wheat"]
|
||||
assert result["confidence"] == 0.92
|
||||
|
||||
def test_live_path_strips_markdown_fences(self, monkeypatch):
|
||||
"""JSON wrapped in ```json ... ``` fences is still parsed."""
|
||||
monkeypatch.delenv("KIWI_LABEL_CAPTURE_MOCK", raising=False)
|
||||
|
||||
payload = {"product_name": "Fancy", "brand": None, "serving_size_g": None,
|
||||
"calories": None, "fat_g": None, "saturated_fat_g": None,
|
||||
"carbs_g": None, "sugar_g": None, "fiber_g": None,
|
||||
"protein_g": None, "sodium_mg": None,
|
||||
"ingredient_names": [], "allergens": [], "confidence": 0.5}
|
||||
self._patch_vision(monkeypatch, f"```json\n{json.dumps(payload)}\n```")
|
||||
|
||||
import app.services.label_capture as svc_mod
|
||||
result = svc_mod.extract_label(b"image")
|
||||
assert result["product_name"] == "Fancy"
|
||||
|
||||
def test_live_path_bad_json_falls_back(self, monkeypatch):
|
||||
monkeypatch.delenv("KIWI_LABEL_CAPTURE_MOCK", raising=False)
|
||||
self._patch_vision(monkeypatch, "this is not json")
|
||||
|
||||
import app.services.label_capture as svc_mod
|
||||
result = svc_mod.extract_label(b"image")
|
||||
assert result["confidence"] == 0.0
|
||||
|
||||
def test_confidence_clamped_above_one(self, monkeypatch):
|
||||
monkeypatch.delenv("KIWI_LABEL_CAPTURE_MOCK", raising=False)
|
||||
|
||||
payload = {"product_name": None, "brand": None, "serving_size_g": None,
|
||||
"calories": None, "fat_g": None, "saturated_fat_g": None,
|
||||
"carbs_g": None, "sugar_g": None, "fiber_g": None,
|
||||
"protein_g": None, "sodium_mg": None,
|
||||
"ingredient_names": [], "allergens": [], "confidence": 5.0}
|
||||
self._patch_vision(monkeypatch, json.dumps(payload))
|
||||
|
||||
import app.services.label_capture as svc_mod
|
||||
result = svc_mod.extract_label(b"image")
|
||||
assert result["confidence"] == 1.0
|
||||
|
||||
def test_none_list_fields_normalised_to_empty(self, monkeypatch):
|
||||
monkeypatch.delenv("KIWI_LABEL_CAPTURE_MOCK", raising=False)
|
||||
|
||||
payload = {"product_name": None, "brand": None, "serving_size_g": None,
|
||||
"calories": None, "fat_g": None, "saturated_fat_g": None,
|
||||
"carbs_g": None, "sugar_g": None, "fiber_g": None,
|
||||
"protein_g": None, "sodium_mg": None,
|
||||
"ingredient_names": None, "allergens": None, "confidence": 0.8}
|
||||
self._patch_vision(monkeypatch, json.dumps(payload))
|
||||
|
||||
import app.services.label_capture as svc_mod
|
||||
result = svc_mod.extract_label(b"image")
|
||||
assert result["ingredient_names"] == []
|
||||
assert result["allergens"] == []
|
||||
|
||||
|
||||
# ── TestNeedsReview ───────────────────────────────────────────────────────────
|
||||
|
||||
class TestNeedsReview:
|
||||
def test_below_threshold_needs_review(self):
|
||||
from app.services.label_capture import needs_review, REVIEW_THRESHOLD
|
||||
assert needs_review({"confidence": REVIEW_THRESHOLD - 0.01})
|
||||
|
||||
def test_at_threshold_no_review(self):
|
||||
from app.services.label_capture import needs_review, REVIEW_THRESHOLD
|
||||
assert not needs_review({"confidence": REVIEW_THRESHOLD})
|
||||
|
||||
def test_above_threshold_no_review(self):
|
||||
from app.services.label_capture import needs_review
|
||||
assert not needs_review({"confidence": 0.95})
|
||||
|
||||
def test_missing_confidence_needs_review(self):
|
||||
from app.services.label_capture import needs_review
|
||||
assert needs_review({})
|
||||
130
tests/services/test_sensory.py
Normal file
130
tests/services/test_sensory.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""Tests for app/services/recipe/sensory.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.services.recipe.sensory import (
|
||||
SensoryExclude,
|
||||
build_sensory_exclude,
|
||||
passes_sensory_filter,
|
||||
)
|
||||
|
||||
|
||||
class TestBuildSensoryExclude:
|
||||
def test_none_input_returns_empty(self):
|
||||
assert build_sensory_exclude(None).is_empty()
|
||||
|
||||
def test_empty_string_returns_empty(self):
|
||||
assert build_sensory_exclude("").is_empty()
|
||||
|
||||
def test_malformed_json_returns_empty(self):
|
||||
assert build_sensory_exclude("{not valid json}").is_empty()
|
||||
|
||||
def test_parses_avoid_textures(self):
|
||||
prefs = json.dumps({"avoid_textures": ["mushy", "slimy"], "max_smell": None, "max_noise": None})
|
||||
result = build_sensory_exclude(prefs)
|
||||
assert "mushy" in result.textures
|
||||
assert "slimy" in result.textures
|
||||
|
||||
def test_parses_max_smell(self):
|
||||
prefs = json.dumps({"avoid_textures": [], "max_smell": "pungent", "max_noise": None})
|
||||
result = build_sensory_exclude(prefs)
|
||||
assert result.smell_above == "pungent"
|
||||
|
||||
def test_parses_max_noise(self):
|
||||
prefs = json.dumps({"avoid_textures": [], "max_smell": None, "max_noise": "loud"})
|
||||
result = build_sensory_exclude(prefs)
|
||||
assert result.noise_above == "loud"
|
||||
|
||||
def test_unknown_smell_level_becomes_none(self):
|
||||
prefs = json.dumps({"avoid_textures": [], "max_smell": "extremely_pungent", "max_noise": None})
|
||||
result = build_sensory_exclude(prefs)
|
||||
assert result.smell_above is None
|
||||
|
||||
def test_unknown_noise_level_becomes_none(self):
|
||||
prefs = json.dumps({"avoid_textures": [], "max_smell": None, "max_noise": "ear_splitting"})
|
||||
result = build_sensory_exclude(prefs)
|
||||
assert result.noise_above is None
|
||||
|
||||
def test_null_max_smell_is_none(self):
|
||||
prefs = json.dumps({"avoid_textures": [], "max_smell": None, "max_noise": None})
|
||||
assert build_sensory_exclude(prefs).smell_above is None
|
||||
|
||||
def test_is_empty_all_none(self):
|
||||
prefs = json.dumps({"avoid_textures": [], "max_smell": None, "max_noise": None})
|
||||
assert build_sensory_exclude(prefs).is_empty()
|
||||
|
||||
def test_is_not_empty_with_textures(self):
|
||||
prefs = json.dumps({"avoid_textures": ["mushy"]})
|
||||
assert not build_sensory_exclude(prefs).is_empty()
|
||||
|
||||
|
||||
class TestPassesSensoryFilter:
|
||||
def _tags(self, textures=None, smell="mild", noise="quiet") -> str:
|
||||
return json.dumps({"textures": textures or [], "smell": smell, "noise": noise})
|
||||
|
||||
def test_empty_exclude_always_passes(self):
|
||||
tags = self._tags(textures=["mushy"], smell="fermented", noise="very_loud")
|
||||
assert passes_sensory_filter(tags, SensoryExclude.empty()) is True
|
||||
|
||||
def test_untagged_recipe_always_passes(self):
|
||||
exclude = SensoryExclude(textures=("mushy",), smell_above="pungent")
|
||||
assert passes_sensory_filter("{}", exclude) is True
|
||||
assert passes_sensory_filter(None, exclude) is True
|
||||
assert passes_sensory_filter({}, exclude) is True
|
||||
|
||||
def test_texture_hit_returns_false(self):
|
||||
tags = self._tags(textures=["mushy", "creamy"])
|
||||
exclude = SensoryExclude(textures=("mushy",))
|
||||
assert passes_sensory_filter(tags, exclude) is False
|
||||
|
||||
def test_texture_no_overlap_passes(self):
|
||||
tags = self._tags(textures=["crunchy"])
|
||||
exclude = SensoryExclude(textures=("mushy", "slimy"))
|
||||
assert passes_sensory_filter(tags, exclude) is True
|
||||
|
||||
def test_smell_above_threshold_excluded(self):
|
||||
tags = self._tags(smell="fermented")
|
||||
exclude = SensoryExclude(smell_above="pungent")
|
||||
assert passes_sensory_filter(tags, exclude) is False
|
||||
|
||||
def test_smell_at_threshold_passes(self):
|
||||
tags = self._tags(smell="pungent")
|
||||
exclude = SensoryExclude(smell_above="pungent")
|
||||
assert passes_sensory_filter(tags, exclude) is True
|
||||
|
||||
def test_smell_below_threshold_passes(self):
|
||||
for smell in ("aromatic", "mild"):
|
||||
tags = self._tags(smell=smell)
|
||||
exclude = SensoryExclude(smell_above="pungent")
|
||||
assert passes_sensory_filter(tags, exclude) is True
|
||||
|
||||
def test_noise_above_threshold_excluded(self):
|
||||
tags = self._tags(noise="very_loud")
|
||||
exclude = SensoryExclude(noise_above="loud")
|
||||
assert passes_sensory_filter(tags, exclude) is False
|
||||
|
||||
def test_noise_at_threshold_passes(self):
|
||||
tags = self._tags(noise="loud")
|
||||
exclude = SensoryExclude(noise_above="loud")
|
||||
assert passes_sensory_filter(tags, exclude) is True
|
||||
|
||||
def test_noise_below_threshold_passes(self):
|
||||
for noise in ("quiet", "moderate"):
|
||||
tags = self._tags(noise=noise)
|
||||
exclude = SensoryExclude(noise_above="loud")
|
||||
assert passes_sensory_filter(tags, exclude) is True
|
||||
|
||||
def test_combined_texture_and_smell(self):
|
||||
tags = self._tags(textures=["creamy"], smell="fermented")
|
||||
exclude = SensoryExclude(textures=("creamy",), smell_above="pungent")
|
||||
assert passes_sensory_filter(tags, exclude) is False
|
||||
|
||||
def test_dict_input_works(self):
|
||||
tags_dict = {"textures": ["mushy"], "smell": "mild", "noise": "quiet"}
|
||||
exclude = SensoryExclude(textures=("mushy",))
|
||||
assert passes_sensory_filter(tags_dict, exclude) is False
|
||||
|
||||
def test_malformed_sensory_tags_passes(self):
|
||||
exclude = SensoryExclude(textures=("mushy",), smell_above="pungent")
|
||||
assert passes_sensory_filter("{bad json", exclude) is True
|
||||
210
tests/test_services/test_time_effort.py
Normal file
210
tests/test_services/test_time_effort.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
"""Tests for app.services.recipe.time_effort — run RED before implementing."""
|
||||
import pytest
|
||||
from app.services.recipe.time_effort import (
|
||||
TimeEffortProfile,
|
||||
StepAnalysis,
|
||||
parse_time_effort,
|
||||
)
|
||||
|
||||
|
||||
# ── Step classification ────────────────────────────────────────────────────
|
||||
|
||||
class TestPassiveClassification:
|
||||
def test_simmer_is_passive(self):
|
||||
result = parse_time_effort(["Simmer for 10 minutes."])
|
||||
assert result.step_analyses[0].is_passive is True
|
||||
|
||||
def test_bake_is_passive(self):
|
||||
result = parse_time_effort(["Bake at 375°F for 30 minutes."])
|
||||
assert result.step_analyses[0].is_passive is True
|
||||
|
||||
def test_chop_is_active(self):
|
||||
result = parse_time_effort(["Chop the onion finely."])
|
||||
assert result.step_analyses[0].is_passive is False
|
||||
|
||||
def test_sear_is_active(self):
|
||||
result = parse_time_effort(["Sear chicken over high heat."])
|
||||
assert result.step_analyses[0].is_passive is False
|
||||
|
||||
def test_let_rest_is_passive(self):
|
||||
result = parse_time_effort(["Let the dough rest for 20 minutes."])
|
||||
assert result.step_analyses[0].is_passive is True
|
||||
|
||||
def test_passive_keywords_matched_as_whole_words(self):
|
||||
# "settle" contains "set" — must NOT match as passive
|
||||
result = parse_time_effort(["Settle the dish on the table."])
|
||||
assert result.step_analyses[0].is_passive is False
|
||||
|
||||
def test_overnight_is_passive(self):
|
||||
result = parse_time_effort(["Marinate overnight in the fridge."])
|
||||
assert result.step_analyses[0].is_passive is True
|
||||
|
||||
def test_slow_cook_multiword_is_passive(self):
|
||||
result = parse_time_effort(["Slow cook on low for 6 hours."])
|
||||
assert result.step_analyses[0].is_passive is True
|
||||
|
||||
def test_pressure_cook_multiword_is_passive(self):
|
||||
result = parse_time_effort(["Pressure cook on high for 15 minutes."])
|
||||
assert result.step_analyses[0].is_passive is True
|
||||
|
||||
|
||||
# ── Time extraction ────────────────────────────────────────────────────────
|
||||
|
||||
class TestTimeExtraction:
|
||||
def test_simple_minutes(self):
|
||||
result = parse_time_effort(["Cook for 10 minutes."])
|
||||
assert result.step_analyses[0].detected_minutes == 10
|
||||
|
||||
def test_simple_hours_converted(self):
|
||||
result = parse_time_effort(["Braise for 2 hours."])
|
||||
assert result.step_analyses[0].detected_minutes == 120
|
||||
|
||||
def test_range_takes_midpoint(self):
|
||||
# "15-20 minutes" → midpoint = 17 (int division: (15+20)//2 = 17)
|
||||
result = parse_time_effort(["Cook for 15-20 minutes."])
|
||||
assert result.step_analyses[0].detected_minutes == 17
|
||||
|
||||
def test_range_with_endash(self):
|
||||
result = parse_time_effort(["Simmer for 15–20 minutes."])
|
||||
assert result.step_analyses[0].detected_minutes == 17
|
||||
|
||||
def test_abbreviated_min(self):
|
||||
result = parse_time_effort(["Heat oil for 5 min."])
|
||||
assert result.step_analyses[0].detected_minutes == 5
|
||||
|
||||
def test_abbreviated_hr(self):
|
||||
result = parse_time_effort(["Rest for 1 hr."])
|
||||
assert result.step_analyses[0].detected_minutes == 60
|
||||
|
||||
def test_no_time_returns_none(self):
|
||||
result = parse_time_effort(["Add salt to taste."])
|
||||
assert result.step_analyses[0].detected_minutes is None
|
||||
|
||||
def test_cap_at_480_minutes(self):
|
||||
# 10 hours would be 600 min — capped at 480
|
||||
result = parse_time_effort(["Ferment for 10 hours."])
|
||||
assert result.step_analyses[0].detected_minutes == 480
|
||||
|
||||
def test_seconds_converted(self):
|
||||
result = parse_time_effort(["Blend for 30 seconds."])
|
||||
assert result.step_analyses[0].detected_minutes == 1 # rounds up: ceil(30/60) or 1 as min floor
|
||||
|
||||
|
||||
# ── Time totals ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestTimeTotals:
|
||||
def test_active_passive_split(self):
|
||||
steps = [
|
||||
"Chop onions finely.", # active, no time
|
||||
"Sear chicken for 5 minutes per side.", # active, 5 min
|
||||
"Simmer for 20 minutes.", # passive, 20 min
|
||||
]
|
||||
result = parse_time_effort(steps)
|
||||
assert result.active_min == 5
|
||||
assert result.passive_min == 20
|
||||
assert result.total_min == 25
|
||||
|
||||
def test_all_active_passive_zero(self):
|
||||
steps = ["Dice vegetables.", "Season with salt.", "Plate and serve."]
|
||||
result = parse_time_effort(steps)
|
||||
assert result.passive_min == 0
|
||||
|
||||
def test_zero_directions_returns_zero_profile(self):
|
||||
result = parse_time_effort([])
|
||||
assert result.active_min == 0
|
||||
assert result.passive_min == 0
|
||||
assert result.total_min == 0
|
||||
assert result.step_analyses == []
|
||||
assert result.equipment == []
|
||||
assert result.effort_label == "quick"
|
||||
|
||||
|
||||
# ── Effort label ───────────────────────────────────────────────────────────
|
||||
|
||||
class TestEffortLabel:
|
||||
def test_one_step_is_quick(self):
|
||||
result = parse_time_effort(["Serve cold."])
|
||||
assert result.effort_label == "quick"
|
||||
|
||||
def test_three_steps_is_quick(self):
|
||||
result = parse_time_effort(["a", "b", "c"])
|
||||
assert result.effort_label == "quick"
|
||||
|
||||
def test_four_steps_is_moderate(self):
|
||||
result = parse_time_effort(["a", "b", "c", "d"])
|
||||
assert result.effort_label == "moderate"
|
||||
|
||||
def test_seven_steps_is_moderate(self):
|
||||
result = parse_time_effort(["a"] * 7)
|
||||
assert result.effort_label == "moderate"
|
||||
|
||||
def test_eight_steps_is_involved(self):
|
||||
result = parse_time_effort(["a"] * 8)
|
||||
assert result.effort_label == "involved"
|
||||
|
||||
|
||||
# ── Equipment detection ────────────────────────────────────────────────────
|
||||
|
||||
class TestEquipmentDetection:
|
||||
def test_knife_detected(self):
|
||||
result = parse_time_effort(["Dice the onion.", "Mince the garlic."])
|
||||
assert "Knife" in result.equipment
|
||||
|
||||
def test_skillet_keyword_fry(self):
|
||||
result = parse_time_effort(["Pan-fry the chicken over medium heat."])
|
||||
assert "Skillet" in result.equipment
|
||||
|
||||
def test_oven_detected(self):
|
||||
result = parse_time_effort(["Preheat oven to 400°F.", "Bake for 25 minutes."])
|
||||
assert "Oven" in result.equipment
|
||||
|
||||
def test_pot_detected(self):
|
||||
result = parse_time_effort(["Bring a large pot of water to boil."])
|
||||
assert "Pot" in result.equipment
|
||||
|
||||
def test_timer_added_when_any_passive_step(self):
|
||||
result = parse_time_effort(["Chop onion.", "Simmer for 10 minutes."])
|
||||
assert "Timer" in result.equipment
|
||||
|
||||
def test_no_timer_when_all_active(self):
|
||||
result = parse_time_effort(["Chop vegetables.", "Toss with dressing."])
|
||||
assert "Timer" not in result.equipment
|
||||
|
||||
def test_equipment_deduplicated(self):
|
||||
# Multiple steps with 'dice' should still yield only one Knife
|
||||
result = parse_time_effort(["Dice onion.", "Dice carrot.", "Dice celery."])
|
||||
assert result.equipment.count("Knife") == 1
|
||||
|
||||
def test_no_equipment_when_empty(self):
|
||||
result = parse_time_effort([])
|
||||
assert result.equipment == []
|
||||
|
||||
def test_slow_cooker_detected(self):
|
||||
result = parse_time_effort(["Place everything in the slow cooker."])
|
||||
assert "Slow cooker" in result.equipment
|
||||
|
||||
def test_pressure_cooker_detected(self):
|
||||
result = parse_time_effort(["Set instant pot to high pressure."])
|
||||
assert "Pressure cooker" in result.equipment
|
||||
|
||||
def test_colander_detected(self):
|
||||
result = parse_time_effort(["Drain the pasta through a colander."])
|
||||
assert "Colander" in result.equipment
|
||||
|
||||
def test_blender_detected(self):
|
||||
result = parse_time_effort(["Blend until smooth."])
|
||||
assert "Blender" in result.equipment
|
||||
|
||||
|
||||
# ── Dataclass immutability ────────────────────────────────────────────────
|
||||
|
||||
class TestImmutability:
|
||||
def test_time_effort_profile_is_frozen(self):
|
||||
result = parse_time_effort(["Chop onion."])
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
result.active_min = 99 # type: ignore[misc]
|
||||
|
||||
def test_step_analysis_is_frozen(self):
|
||||
result = parse_time_effort(["Simmer for 10 min."])
|
||||
with pytest.raises((AttributeError, TypeError)):
|
||||
result.step_analyses[0].is_passive = False # type: ignore[misc]
|
||||
141
tests/test_tag_sensory_profiles.py
Normal file
141
tests/test_tag_sensory_profiles.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""Tests for scripts/tag_sensory_profiles.py classification logic."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from scripts.tag_sensory_profiles import (
|
||||
_classify_textures,
|
||||
_classify_smell,
|
||||
_classify_noise,
|
||||
)
|
||||
|
||||
|
||||
class TestClassifyTextures:
|
||||
def test_mushy_from_direction(self):
|
||||
assert "mushy" in _classify_textures([], ["stew the vegetables until soft"], set())
|
||||
|
||||
def test_mushy_from_braise(self):
|
||||
assert "mushy" in _classify_textures([], ["braise for 2 hours"], set())
|
||||
|
||||
def test_crunchy_from_roast(self):
|
||||
assert "crunchy" in _classify_textures([], ["roast at 425F until golden"], set())
|
||||
|
||||
def test_crunchy_from_ingredient_name(self):
|
||||
assert "crunchy" in _classify_textures(["breadcrumbs", "chicken"], [], set())
|
||||
|
||||
def test_slimy_from_okra(self):
|
||||
assert "slimy" in _classify_textures(["okra", "tomatoes"], [], set())
|
||||
|
||||
def test_slimy_from_natto(self):
|
||||
assert "slimy" in _classify_textures(["natto", "rice"], [], set())
|
||||
|
||||
def test_chewy_from_calamari(self):
|
||||
assert "chewy" in _classify_textures(["calamari", "lemon"], [], set())
|
||||
|
||||
def test_chewy_from_jerky(self):
|
||||
assert "chewy" in _classify_textures(["beef jerky"], [], set())
|
||||
|
||||
def test_creamy_from_profile(self):
|
||||
assert "creamy" in _classify_textures([], [], {"creamy"})
|
||||
|
||||
def test_creamy_from_fatty_profile(self):
|
||||
assert "creamy" in _classify_textures([], [], {"fatty"})
|
||||
|
||||
def test_creamy_from_blend_direction(self):
|
||||
assert "creamy" in _classify_textures([], ["blend until smooth"], set())
|
||||
|
||||
def test_chunky_from_dice_direction(self):
|
||||
assert "chunky" in _classify_textures([], ["dice the potatoes", "add to stew"], set())
|
||||
|
||||
def test_multiple_textures_can_fire(self):
|
||||
textures = _classify_textures(["okra", "breadcrumbs"], ["roast until crispy"], set())
|
||||
assert "slimy" in textures
|
||||
assert "crunchy" in textures
|
||||
|
||||
def test_no_signals_returns_list(self):
|
||||
result = _classify_textures(["chicken", "rice"], ["cook for 20 minutes"], set())
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_case_insensitive_matching(self):
|
||||
assert "slimy" in _classify_textures(["OKRA", "Tomatoes"], [], set())
|
||||
|
||||
|
||||
class TestClassifySmell:
|
||||
def test_fermented_from_fish_sauce(self):
|
||||
assert _classify_smell(["fish sauce", "lime juice"]) == "fermented"
|
||||
|
||||
def test_fermented_from_miso(self):
|
||||
assert _classify_smell(["miso paste", "ginger"]) == "fermented"
|
||||
|
||||
def test_fermented_from_soy_sauce(self):
|
||||
assert _classify_smell(["soy sauce", "garlic"]) == "fermented"
|
||||
|
||||
def test_fermented_wins_over_pungent(self):
|
||||
assert _classify_smell(["garlic", "soy sauce"]) == "fermented"
|
||||
|
||||
def test_pungent_from_garlic(self):
|
||||
assert _classify_smell(["garlic", "onion", "chicken"]) == "pungent"
|
||||
|
||||
def test_pungent_from_curry_powder(self):
|
||||
assert _classify_smell(["curry powder", "rice"]) == "pungent"
|
||||
|
||||
def test_aromatic_from_basil(self):
|
||||
assert _classify_smell(["basil", "tomatoes", "pasta"]) == "aromatic"
|
||||
|
||||
def test_aromatic_from_cinnamon(self):
|
||||
assert _classify_smell(["cinnamon", "apples", "sugar"]) == "aromatic"
|
||||
|
||||
def test_mild_default(self):
|
||||
assert _classify_smell(["chicken", "broth", "salt"]) == "mild"
|
||||
|
||||
def test_empty_ingredients_mild(self):
|
||||
assert _classify_smell([]) == "mild"
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert _classify_smell(["Fish Sauce", "lime"]) == "fermented"
|
||||
|
||||
|
||||
class TestClassifyNoise:
|
||||
def test_very_loud_from_deep_fry(self):
|
||||
assert _classify_noise(["deep fry the chicken at 375F"]) == "very_loud"
|
||||
|
||||
def test_very_loud_from_pressure_cook(self):
|
||||
assert _classify_noise(["pressure cook on high for 20 minutes"]) == "very_loud"
|
||||
|
||||
def test_very_loud_from_instant_pot(self):
|
||||
assert _classify_noise(["add to instant pot, seal, cook 15 min"]) == "very_loud"
|
||||
|
||||
def test_loud_from_sear(self):
|
||||
assert _classify_noise(["sear the steak over high heat"]) == "loud"
|
||||
|
||||
def test_loud_from_stir_fry(self):
|
||||
assert _classify_noise(["stir fry the vegetables"]) == "loud"
|
||||
|
||||
def test_loud_from_wok(self):
|
||||
assert _classify_noise(["heat the wok until smoking"]) == "loud"
|
||||
|
||||
def test_loud_from_bare_fry_no_deep(self):
|
||||
assert _classify_noise(["fry the eggs until set"]) == "loud"
|
||||
|
||||
def test_very_loud_wins_over_loud(self):
|
||||
assert _classify_noise(["deep fry for 3 minutes"]) == "very_loud"
|
||||
|
||||
def test_moderate_from_saute(self):
|
||||
assert _classify_noise(["saute the onions until translucent"]) == "moderate"
|
||||
|
||||
def test_moderate_from_bake(self):
|
||||
assert _classify_noise(["bake at 350F for 30 minutes"]) == "moderate"
|
||||
|
||||
def test_moderate_from_roast(self):
|
||||
assert _classify_noise(["roast the vegetables for 25 minutes"]) == "moderate"
|
||||
|
||||
def test_quiet_default(self):
|
||||
assert _classify_noise(["mix the ingredients", "chill for 1 hour"]) == "quiet"
|
||||
|
||||
def test_empty_directions_quiet(self):
|
||||
assert _classify_noise([]) == "quiet"
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert _classify_noise(["Deep Fry the chicken"]) == "very_loud"
|
||||
Loading…
Reference in a new issue