Compare commits

...

25 commits

Author SHA1 Message Date
0bac494ecd chore: bump to v0.6.0, fix TS build errors, remove cf-orch sidecar
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run
Release / release (push) Waiting to run
- Bump version to 0.6.0 (visual label capture release)
- Remove unused TimeEffortProfile import in RecipeDetailPanel.vue
- Prefix unused value params with _ in SettingsView.vue sensory fns
- Remove cf-orch agent sidecar from compose.override.yml (Sif now has
  its own dedicated systemd cf-orch-agent service)
2026-04-24 21:19:44 -07:00
17e62c451f feat: visual label capture for unenriched barcodes (kiwi#79)
When a barcode scan finds no product in FDC/OFF, paid-tier users now see a
"Capture label" offer instead of a dead-end "add manually" prompt.

Backend:
- Migration 036: captured_products local cache table (keyed by barcode,
  UPSERT on conflict so re-capture refreshes rather than errors)
- store.get_captured_product / save_captured_product (with JSON decode for
  ingredient_names and allergens)
- app/services/label_capture.py: wraps cf-core VisionRouter (caption API);
  graceful fallback to zero-confidence mock when stub/error; JSON fence
  stripping; confidence clamped to [0,1]; KIWI_LABEL_CAPTURE_MOCK=1 for tests
- New schemas: LabelCaptureResponse, LabelConfirmRequest, LabelConfirmResponse
- POST /inventory/scan/label-capture — image to extraction (paid+ gate, 403)
- POST /inventory/scan/label-confirm — save confirmed product + optional
  inventory add
- Both scan endpoints now: check captured_products cache before FDC/OFF;
  set needs_visual_capture=True for gap products on paid tier; BarcodeScanResult
  gains needs_visual_capture field
- visual_label_capture feature gate added to tiers.py (paid)

Tests: 42 new tests (service, store/migration, API endpoints) — 367 total passing

Frontend:
- InventoryList.vue: capturePhase state machine (offer => uploading => reviewing)
- Offer card appears after scan gap (calm UX: no urgency, Discard always visible)
- Review form: pre-populated from extraction; amber label highlights for
  unread fields (confidence < 0.7); comma-separated ingredients/allergens
- api.ts: LabelCaptureResult + LabelConfirmRequest types; captureLabelPhoto()
  and confirmLabelCapture() API methods
2026-04-24 17:57:25 -07:00
3463aa1e17 feat: wire dietary constraints into secondary use filter on all inventory endpoints
_user_constraints() loads dietary_constraints from user_settings once per
request. All 7 _enrich_item call sites now pass constraints so wine (and
any future alcohol-containing entries) are suppressed for halal/alcohol-free
users at the API response layer.
2026-04-24 17:12:39 -07:00
e45b07c203 feat: expand secondary use windows + dietary constraint filter (kiwi#110)
Adds 10 new secondary use entries and corrects all 8 existing ones.
New: apples/soft, leafy_greens/wilting, tomatoes/soft, cooked_pasta/day-old,
cooked_potatoes/day-old, yogurt/tangy, cream/sour, wine/open,
cooked_beans/day-old, cooked_meat/leftover.

Corrections: milk uses (specific recipes, not 'baking'/'sauces'); dairy uses
expanded; cheese label well-aged→rind-ready with named dishes (minestrone,
ribollita); rice uses (onigiri, arancini, congee); tortillas warning added;
bakery uses and synonyms expanded to named pastries; bananas synonyms
(spotty/brown/black/mushy); rice synonyms (old rice).

New fields on every SECONDARY_WINDOW entry:
- discard_signs: qualitative cues for when the item has gone past its
  secondary window (shown in UI alongside uses)
- constraints_exclude: dietary labels that suppress the entry entirely
  (wine suppressed for halal/alcohol-free)

ExpirationPredictor.filter_secondary_by_constraints() applies constraint
suppression; _enrich_item() now accepts user_constraints and passes
secondary_discard_signs through to the API response.
2026-04-24 17:08:45 -07:00
b5eb8e4772 feat: cross-encoder reranker for recipe suggestions (kiwi#117)
Integrates cf-core reranker into the L1/L2 recipe engine. Paid+ tier
gets a BGE cross-encoder pass over the top-20 FTS candidates, scoring
each recipe against the user's full context: pantry state, dietary
constraints, allergies, expiry urgency, style preference, and effort
preference. Free tier keeps the existing overlap sort unchanged.

- New app/services/recipe/reranker.py: build_query, build_candidate_string,
  rerank_suggestions with tier gate (_RERANKER_TIERS) and graceful fallback
- rerank_score field added to RecipeSuggestion (None on free tier, float on paid+)
- recipe_engine.py: single call after candidate assembly, before final sort;
  hard_day_mode tier grouping preserved as primary sort when reranker active
- Fix pre-existing circular import in app/services/__init__.py (eager import
  of ReceiptService triggered store.py → services → receipt_service → store)
- 27 unit tests (mock backend, no model weights) + 2 engine-level tier tests;
  325 tests passing, no regressions
2026-04-24 16:39:51 -07:00
91867f15f4 feat(streaming): add COORDINATOR_URL and COORDINATOR_KIWI_KEY to cloud compose 2026-04-24 10:26:53 -07:00
1182c6cffb feat(streaming): add EventSource streaming UI to RecipesView 2026-04-24 10:25:35 -07:00
7292c5e7fc feat(streaming): add StreamTokenResponse type and getRecipeStreamToken API 2026-04-24 10:23:09 -07:00
63517d135b feat(streaming): add POST /recipes/stream-token endpoint 2026-04-24 10:22:30 -07:00
2547f80893 feat(streaming): add StreamTokenRequest/Response schemas 2026-04-24 10:19:18 -07:00
0996ea8c7a feat(streaming): add coordinator_proxy service module 2026-04-24 10:18:40 -07:00
c3e7dc1ea4 feat: time-first recipe entry (kiwi#52)
- Add max_total_min to RecipeRequest schema and TypeScript interface
- Add _within_time() helper to recipe_engine using parse_time_effort()
  with graceful degradation (empty directions or no signals -> pass)
- Wire max_total_min filter into suggest() loop after max_time_min
- Add time_first_layout to allowed settings keys
- Add timeFirstLayout ref to settings store (preserves sensoryPreferences)
- Add maxTotalMin ref to recipes store, wired into _buildRequest()
- Add time bucket selector UI (15/30/45/60/90 min) in RecipesView
  Find tab, gated by timeFirstLayout != 'normal'
- Add time-first layout selector section in SettingsView
- Add 5 _within_time unit tests and 2 settings key tests
2026-04-24 10:15:58 -07:00
521cb419bc feat: sensory profile filter — texture/smell/noise filtering for Browse and Find (kiwi#51)
- Migration 035: add sensory_tags column to recipes (default '{}')
- scripts/tag_sensory_profiles.py: batch tagger using ingredient names,
  direction keywords, and ingredient_profiles texture data
- app/services/recipe/sensory.py: SensoryExclude frozen dataclass,
  build_sensory_exclude(), passes_sensory_filter() with graceful degradation
  (untagged recipes always pass; malformed JSON always passes)
- store.browse_recipes and _browse_by_match: accept SensoryExclude, apply
  filter in recipe-building loop (default path) and scoring loop (match sort)
- recipe_engine.suggest: load sensory_preferences from settings, apply
  passes_sensory_filter() after exclude_set check in the rows loop
- settings endpoint: add sensory_preferences to _ALLOWED_KEYS
- Frontend: SensoryPreferences types in api.ts; sensoryPreferences state and
  saveSensory() action in settings store; Sensory section in SettingsView with
  texture avoid pills, smell/noise tolerance scale pills with ok/limit/neutral
  color coding
- 66 new tests (29 classification + 13 sensory service + 2 settings); 281 total
2026-04-24 09:47:48 -07:00
302285a1a5 feat: step-by-step cook mode with progress bar, keyboard nav, and swipe (kiwi#49)
- Cook/Exit toggle button in recipe detail header (hidden for recipes with no steps)
- Cook mode progress bar between header and body showing step N of M
- Single-step view replaces recipe body; shows Active/Wait badge and passive hint
  from #50 time_effort data (null-safe — degrades gracefully without it)
- Prev/Next nav buttons; Next becomes green Done on last step
- ArrowLeft/ArrowRight keyboard navigation (preventDefault to suppress scroll)
- Touch swipe left/right (40px horizontal threshold, 80px vertical abort)
- Done triggers handleCook() then exitCookMode() so success banner appears instantly
2026-04-24 09:35:12 -07:00
b1e187c779 feat: time & effort signals — active/passive split, effort cards, annotated steps (kiwi#50)
- Add app/services/recipe/time_effort.py: parse_time_effort(), TimeEffortProfile,
  StepAnalysis dataclasses; two-branch regex for time ranges and single values;
  whole-word passive keyword detection; 480 min/step cap; 1825 day global cap
- Add directions to browse_recipes and _browse_by_match SELECT queries in store.py
- Enrich browse and detail endpoints with active_min/passive_min/time_effort fields
- Add StepAnalysis, TimeEffortProfile TS interfaces to api.ts
- RecipeBrowserPanel: split pill badge showing active/passive time
- RecipeDetailPanel: collapsible ingredients summary, effort cards (Active/Hands-off/Total),
  equipment chips, annotated step list with Active/Wait badges and passive hints
- 45 new tests (40 unit + 5 API); 215 total passing
2026-04-24 09:29:54 -07:00
70205ebb25 feat(recipe-tags): 'Categorize this' CTA and tag submission modal
Zero-count subcategory buttons show a + badge. Clicking opens a modal:
- Recipe search (debounced, 3-char min) using existing browse API
- Pre-filled domain/category/subcategory from current browse context,
  fully correctable via selects populated from loaded domains/categories
- Submit calls POST /recipes/community-tags; 409 on duplicate
- Success message: 'It will appear once a second user confirms'

api.ts: adds submitRecipeTag(), upvoteRecipeTag(), listRecipeTags() to browserAPI.
CSS: tag-cta pill on subcat buttons, modal-backdrop + modal-box with theme vars.

TODO: wire real community pseudonym (currently hardcoded 'anon').
Refs kiwi#118.
2026-04-22 12:37:56 -07:00
9697c7b64f feat(recipe-tags): merge accepted community tags into browse counts + FTS fallback
browse_counts_cache.py: after FTS counts, _merge_community_tag_counts() queries
  accepted tags (upvotes>=2) grouped by (domain,category,subcategory) and adds
  distinct recipe_id counts to the cached keyword-set totals. Skips silently
  when community Postgres is unavailable.

store.py: fetch_recipes_by_ids() fetches corpus recipes by explicit ID list,
  used by the FTS fallback when a subcategory returns zero FTS results.

recipes.py (browse endpoint): when FTS total==0 for a subcategory, queries
  community store for accepted tag IDs and serves those recipes directly.
  Sets community_tagged=True in the response so the UI can surface context.
  Refs kiwi#118.
2026-04-22 12:37:44 -07:00
f962748073 feat(recipe-tags): community subcategory tagging API endpoints
GET  /recipes/community-tags/{recipe_id} — all tags for a recipe
POST /recipes/community-tags             — submit tag (requires pseudonym)
POST /recipes/community-tags/{id}/upvote — vote on a tag

Validates (domain, category, subcategory) against DOMAINS taxonomy before
accepting. Returns 409 on duplicate submission or double-vote. Fails soft
(503) when community Postgres is unavailable so the browse path is unaffected.
Refs kiwi#118.
2026-04-22 12:37:32 -07:00
a507deddbf feat(tagger): add BBQ cuisine inference signals for tag_inferrer
food.com rarely tags BBQ in its own taxonomy fields, so BBQ recipes were
previously untagged. Added content-derived signals (brisket, pulled pork,
dry rub, regional styles) so infer_recipe_tags.py correctly tags them
as cuisine:BBQ. Companion to the browser_domains.py BBQ keyword expansion.
2026-04-21 15:06:04 -07:00
7a7eae4666 chore(cf-orch): route recipe LLM calls through vllm with model candidates + CF_APP_NAME
Switches recipe generation service type from 'cf-text' to 'vllm' so the
coordinator can route to quantized small models (Qwen2.5-3B, Phi-4-mini)
rather than the full text backend. Passes CF_APP_NAME for per-product
VRAM/request analytics in the coordinator dashboard.

- llm_recipe.py: _SERVICE_TYPE = 'vllm'; _MODEL_CANDIDATES list; passes
  model_candidates and pipeline= to CFOrchClient.allocate()
- compose.cloud.yml: CF_APP_NAME=kiwi env var for coordinator attribution
2026-04-21 15:05:38 -07:00
b223325d77 feat(shopping): locale-aware grocery links with region settings UI
Shopping links previously hardcoded to US storefronts. Users in other regions
got broken Amazon Fresh and Instacart links. Now locale is stored as a user
setting and passed to GroceryLinkBuilder at request time.

- locale_config.py: per-locale Amazon domain/dept config (already existed)
- grocery_links.py: GroceryLinkBuilder accepts locale=; routes Instacart to .ca
  for Canada, uses amazon_domain per locale, Instacart/Walmart US/CA only
- settings.py: adds 'shopping_locale' to allowed settings keys
- shopping.py: reads locale from user's stored setting on all list/add/update paths
- SettingsView.vue: Shopping Region selector (NA, Europe, APAC, LATAM)
- stores/settings.ts: shoppingLocale reactive state, saves via settings API
2026-04-21 15:05:28 -07:00
f1d35dd1ac feat(recipes): 'Not today' per-session ingredient exclusions
Users often have ingredients they want to avoid today (out of stock, not feeling it)
that aren't true allergies. The new 'Not today' filter lets them exclude specific
ingredients per session without permanently modifying their allergy list.

- recipe.py schema: exclude_ingredients field (list[str], default [])
- recipe_engine.py: filters corpus results when any ingredient is in exclude_set
- llm_recipe.py: injects exclusions into both prompt templates so LLM-generated
  recipes respect the constraint at generation time
- RecipesView.vue: tag-chip UI with Enter/comma input, removes on × click
- stores/recipes.ts: excludeIngredients reactive list (not persisted to localStorage)
2026-04-21 15:05:16 -07:00
1ac7e3d76a feat(browse): sort recipes by pantry match percentage
Adds 'Best match' sort button to the recipe browser. When selected, recipes are
ordered by the fraction of their ingredients that are in the user's pantry.

- store.py: _browse_by_match() pushes match_pct computation into SQL via json_each()
  so ORDER BY can sort the full result set before LIMIT/OFFSET pagination
- recipes.py: extends sort pattern validation to accept 'match'; falls back to
  default when no pantry_items provided
- RecipeBrowserPanel.vue: adds 'Best match' button (disabled when pantry empty);
  watcher auto-engages match sort when pantry goes from empty to non-empty
2026-04-21 15:04:34 -07:00
1a7a94a344 feat(browse-counts): add pre-computed FTS counts cache with nightly refresh
Multiple concurrent users browsing the 3.2M recipe corpus would cause FTS5 page
cache contention and slow per-request queries. Solution: pre-compute counts for
all category/subcategory keyword sets into a small SQLite cache.

- browse_counts_cache.py: refresh(), load_into_memory(), is_stale() helpers
- config.py: BROWSE_COUNTS_PATH setting (default DATA_DIR/browse_counts.db)
- main.py: warms in-memory cache on startup; runs nightly refresh task every 24h
- infer_recipe_tags.py: auto-refreshes cache after a successful tag run so the
  app picks up updated FTS counts without a restart
2026-04-21 15:04:23 -07:00
5d0ee2493e feat(browser): expand taxonomy keyword coverage for BBQ and regional subcategories
Top-level category keywords were too narrow, missing common food.com corpus terms
like 'barbecue', 'smoky', 'charcoal'. Subcategory terms also expanded to cover
broader corpus vocabulary so FTS counts register hits across more recipes.
2026-04-21 15:04:13 -07:00
52 changed files with 6338 additions and 134 deletions

View file

@ -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()
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"]
# 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(
@ -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)

View 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)

View file

@ -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,
}

View file

@ -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):

View file

@ -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,

View file

@ -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)

View file

@ -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(

View 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 '{}';

View 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
);

View file

@ -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.
@ -1162,23 +1166,36 @@ class Store:
the pantry set computed deterministically, no LLM needed.
q: optional title substring filter (case-insensitive LIKE).
sort: "default" (corpus order) | "alpha" (AZ) | "alpha_desc" (ZA).
sort: "default" (corpus order) | "alpha" (AZ) | "alpha_desc" (ZA)
| "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,10 +1233,18 @@ 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"],
@ -1229,14 +1254,159 @@ class Store:
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,
),
)

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -3,6 +3,11 @@
Business logic services for Kiwi.
"""
from app.services.receipt_service import 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}")

View 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"],
)

View file

@ -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 34 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)

View 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.01.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

View 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")

View file

@ -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": {

View file

@ -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,18 +24,26 @@ 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)
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

View file

@ -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)

View file

@ -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]] = {
# ── Existing entries (corrected) ─────────────────────────────────────────
("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"],
("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.
# 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),

View 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

View 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

View file

@ -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]]] = [

View 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", "1520 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)),
)

View file

@ -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",

View file

@ -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:-}

View file

@ -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.

View file

@ -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
============================================ */

View file

@ -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"
>ZA</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>

View file

@ -20,6 +20,16 @@
@click="showSaveModal = true"
:aria-label="isSaved ? 'Edit saved recipe' : 'Save recipe'"
>{{ isSaved ? '★ Saved' : '☆ Save' }}</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>
@ -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,6 +72,14 @@
</div>
<!-- Ingredients: have vs. need in a two-column layout -->
<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>
@ -98,6 +127,35 @@
>{{ 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 -->
<details v-if="recipe.swap_candidates.length > 0" class="detail-collapsible">
@ -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>
</ol>
<!-- 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>
</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);

View file

@ -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>

View file

@ -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;
}
/* ── 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>

View file

@ -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 ~510s
})
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

View file

@ -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,

View file

@ -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,
}
})

View file

@ -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"

View file

@ -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}")

View 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)

View 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

View 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")

View file

@ -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

View 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()

View 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"

View file

@ -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)

View 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)

View 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({})

View 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

View 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 1520 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]

View 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"