Recipe cards were rendering full directions, all nutrition chips,
prep notes, swap candidates, and grocery links inline in the grid —
making each card tall enough to push the second row below the fold at
3-column widths. Cards now show title, match/complexity/time badges,
up to 4 pantry ingredient chips, missing count, and calorie hint.
Full detail remains in RecipeDetailPanel on "Make this".
ElementClassifier.classify_batch() was issuing N separate DB queries
(one per pantry item). Replaced with a single WHERE name IN (...)
query + heuristic fallback for misses — same result, one round-trip.
backfill_meal_tags.py merges meal: tags from title-only matching
into existing inferred_tags without re-deriving all other signals.
~10x faster than infer_recipe_tags.py --force for meal-tag-only
updates: 3.19M recipes in ~5-10min vs ~2.5h for full re-derivation.
Adds _MEAL_SIGNALS table to tag_inferrer with title-only matching for:
meal:Breakfast — pancakes, waffles, frittata, oatmeal, granola, etc.
meal:Dessert — cake, cookie, brownie, pudding, ice cream, tart, etc.
meal:Snack — dip, chips, popcorn, nachos, energy balls, etc.
meal:Beverage — smoothie, cocktail, juice, lemonade, etc.
meal:Lunch — sandwich, wrap, panini, grilled cheese, etc.
meal:Bread — bread, sourdough, focaccia, dinner roll, etc.
Uses word-boundary + optional-plural regex (\bWORD(?:s|es)?\b) so:
- "pancakes" matches the "pancake" pattern but "pancake" != "cake"
- "tartare" does not match "tart" (no word boundary after tart in tartare)
- "dipping" does not match "dip" (extra chars prevent boundary)
Title-only matching (not ingredient text) avoids false positives from
ingredient names like "cake flour" or "sandwich bread".
Estimated browse impact after backfill (--force on 3.19M recipes):
Breakfast: 43 → ~70k
Dessert: 372 → ~350k (real desserts, not flavor:Sweet)
Snack: 57 → ~60k
Beverage: 43 → ~36k
Lunch: 69 → ~26k
- Dinner: replace non-matching text keywords with main:X protein inferred tags (0 -> 815k results)
- All meal_type categories: add meal:X structured tag phrases
- Dietary: switch to dietary:X-only phrases; bare text keywords matched can_be:X
tags (nearly all recipes), inflating counts to 1.3M+ falsely
- Cuisine: add cuisine:X structured tag phrases to Italian, Mexican, Asian,
Indian, Mediterranean, American, BBQ, European, Latin American
- Side Dish: use main:Vegetables + main:Grains as proxy (no meal:Side Dish tag exists)
- Dessert: remove 'sweet' keyword (matched flavor:Sweet on all recipes)
- New dietary categories: Low-Sodium, Paleo
Closes#122. Partial progress on #123.
Follow-up: #125 (expand meal: tag inferrer coverage)
New feature: photograph a recipe card, cookbook page, or handwritten
note and have it extracted into a structured, editable recipe.
Backend:
- POST /recipes/scan: accept 1-4 photos, run VLM extraction, return
structured JSON for review (not auto-saved)
- POST /recipes/scan/save: persist a reviewed/edited recipe
- GET/DELETE /recipes/user: user-created recipe CRUD
- Vision backend priority: cf-orch -> local Qwen2.5-VL -> Anthropic BYOK
- 503 with clear config hint when no vision backend available
- Multi-photo support: facing pages (ingredients/directions) sent together
- Pantry cross-reference: marks which ingredients are already on hand
- migration 041: user_recipes table (title, servings, cook_time, steps,
ingredients JSON, source, pantry_match_pct)
- Tier gate: recipe_scan -> paid, BYOK-unlockable
Frontend:
- "Scan" button in the Recipes tab bar (camera icon)
- RecipeScanModal: upload step (drag-drop + file picker, up to 4 photos,
live previews), processing step (spinner), review/edit step (all
fields inline-editable before save), pantry match badge, warning banner
for low-confidence or incomplete scans
Tests: 35 new tests (23 unit + 12 API), 404 total passing
- RecipeBrowserPanel: fix onTagSearchInput using '_all' domain slug
(backend validates domain — was silently returning empty results)
- RecipeDetailPanel: fetch and display accepted community category tags
on recipe open; accepted tags shown with accent chip + checkmark,
pending tags shown in muted style
- browserAPI.listRecipeTags() was already in api.ts but not consumed —
now wired into RecipeDetailPanel onMounted as a background fetch
Style classifier (kiwi#27):
- app/services/recipe/style_classifier.py: LLM prompt with curated vocab,
cf-orch/LLMRouter fallback, JSON + regex tag extraction
- POST /recipes/saved/{recipe_id}/classify-style: Paid/BYOK tier gate,
fetches recipe from corpus, returns {suggested_tags:[...]}
- SaveRecipeModal.vue: "Suggest tags" button with loading state; merges
LLM suggestions into existing tags without overwriting user's choices
- 403/empty list silently ignored — button is a no-op when tier not met
Cooked leftovers shelf-life (kiwi#112):
- app/services/leftovers_predictor.py: deterministic FDA/USDA lookup table
with shortest-component-wins for proteins and dish-type override for
assembled dishes; special entries for ceviche (2d, acid != heat),
fermented/cured (kimchi 14d, confit/lardo 7d), soups, rice, pasta, etc.
- POST /recipes/{recipe_id}/leftovers: free tier, no gate
- RecipeDetailPanel.vue: shelf-life section appears after "I cooked this"
with fridge/freeze days, freeze-by advice, per-instance dismiss; calm
framing per no-panic UX policy
- LeftoversResponse Pydantic schema added to recipe.py
- Add TimeEffortProfile + StepAnalysis Pydantic schemas; serialised into
RecipeSuggestion so the frontend receives active/passive/total minutes,
effort label, and detected equipment per suggestion.
- parse_time_effort() now drives max_total_min filter (falls back to step-count
estimate when directions contain no explicit time mentions).
- _PRODUCT_TOKEN_STOPWORDS: strips marketing/packaging words from multi-word
product labels before adding individual ingredient tokens to pantry_set.
"Organic Extra Firm Tofu" → adds "tofu"; improves packaged-food pantry match.
- L1 candidate pool raised to 60 (was 20); min_match_ratio lowered to 0.35
(was 0.60) to keep enough results for plant-based / packaged-food pantries.
- household.py: tighten import to pull HEIMDALL_URL/ADMIN_TOKEN from
services.heimdall_orch (matches refactor in cloud_session.py).
- Migration 039: drop saved_recipes.recipe_id FK (SQLite table rebuild).
The FK referenced main.recipes but corpus lives in an ATTACH'd DB — caused
500 on every POST /recipes/saved in cloud mode.
- _to_summary: row.get("title") or "" to handle corpus JOIN returning NULL
title (e.g. placeholder recipe_id 99999).
- list_collections: return [] for Free tier instead of 403 — prevents
Promise.all in savedStore.load() from aborting the saved-recipes fetch.
- savedStore.load(): switched to Promise.allSettled so a collections failure
never blocks the saved list from populating.
- RecipesView: star indicator now reflects savedStore.isSaved() (server-side
saved state) rather than localStorage bookmarks; changed to <span> since
the star is now read-only visual feedback.
- Removed { immediate: true } from saved-tab watcher — premature bounce to
Build Your Own before onMounted load() completes.
Delegates JWT validation, Heimdall provision/tier-resolve, bypass-IP
handling, and guest session management to circuitforge_core. Kiwi keeps
its own CloudUser (db path, household fields, BYOK flag) and DB helpers.
detect_byok() is now imported from cf-core instead of a local copy.
household_id/is_household_owner/license_key flow through core_user.meta
(cf-core already forwards all Heimdall response extras into meta).
Removes ~217 lines of duplicated auth code.
Note: guest cookie name changes from kiwi_guest_id to cf_guest_id (cf-core
managed). Existing guest sessions get a new UUID on first visit — acceptable
for alpha.
Migrations 037 and 038 rebuild products and inventory_items tables
to add 'visual_capture' as a valid source value, which the label-confirm
endpoint sets when saving user-verified nutrition label data.
Adds 2 schema tests covering the new allowed value.
- 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)
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
_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.
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.
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
- 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
- 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
- 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
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.
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.
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.
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.
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
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
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)
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
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
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.
Documents Ollama/vLLM local backends, cf-text via cf-orch allocation,
and BYOK cloud options (Anthropic, OpenAI). cf_text leads fallback_order
for meal planning and expiry prediction paths.
Paid+ local users with circuitforge_orch installed now get the coordinator-
aware scheduler automatically — no env var needed. The coordinator's
allocation queue already prefers the local GPU first, so latency stays low.
Priority: USE_ORCH_SCHEDULER env override > CLOUD_MODE > cf-orch importable.
Free-tier local users without cf-orch installed get LocalScheduler as before.
USE_ORCH_SCHEDULER=false can force LocalScheduler even when cf-orch is present.
Switches to OrchestratedScheduler in cloud mode so concurrent recipe_llm
jobs fan out across all registered cf-orch GPU nodes instead of serializing
on one. Under load this eliminates poll timeouts from queue backup.
USE_ORCH_SCHEDULER env var gives explicit control independent of CLOUD_MODE:
unset follow CLOUD_MODE (cloud=orch, local=local)
true OrchestratedScheduler always (e.g. multi-GPU local rig)
false LocalScheduler always (e.g. cloud single-GPU dev instance)
ImportError fallback: if circuitforge_orch is not installed and orch is
requested, logs a warning and falls back to LocalScheduler gracefully.
Frontend now uses the async job queue for level 3/4 requests instead
of a 120s blocking POST. Submits with ?async=true, gets job_id, then
polls every 2.5s up to 90s. Button label reflects live server state:
'Queued...' while waiting, 'Generating...' while the model runs.
- api.ts: RecipeJobStatus interface + suggestAsync/pollJob methods
- store: jobStatus ref (null|queued|running|done|failed); suggest()
branches on level >= 3 to _suggestAsync(); CLOUD_MODE sync fallback
detected via 'suggestions' key on the response
- RecipesView: button spinner text uses jobStatus; aria-live
announcements updated for each phase (queued/running/finding)
- compose.override.yml: cf-orch agent --node-id renamed kiwi -> sif
for the upcoming Sif hardware node
Adds the recipe_jobs table and background task pipeline for level 3/4
recipe generation. POST ?async=true returns 202 with job_id; clients
poll GET /recipes/jobs/{job_id} until status=done.
Key fix: _enqueue_recipe_job now calls scheduler.enqueue() after
insert_task() to wake the in-memory work queue immediately. Without
this, tasks sat in 'queued' until the scheduler's 30s idle cycle or
an API restart triggered _load_queued_tasks().
- Migration 034: recipe_jobs table (job_id, user_id, status, request,
result, error) with indexes on job_id and user_id/created_at
- Store: create/get/update_running/complete/fail recipe job methods
- runner.py: recipe_llm task type + _run_recipe_llm handler; MUST
call fail_recipe_job() before re-raising so status stays consistent
- CLOUD_MODE guard: falls back to sync generation (scheduler only
polls shared settings DB, not per-user DBs)
- L4 wildcard is covered by the same req.level in (3, 4) dispatch
When the coordinator returns 429 (all nodes at max_concurrent limit), the previous
code fell back to LLMRouter which is also overloaded at high concurrency. This
caused the request to hang for ~60s before nginx returned a 504.
Now: detect 429/max_concurrent in the RuntimeError message and return "" immediately
so the caller gets an empty RecipeResult (graceful degradation) rather than a timeout.