Compare commits

...

93 commits

Author SHA1 Message Date
69e2ca7914 feat(browser): expand cuisine taxonomy to 13 categories + 105 subcategories
Some checks are pending
CI / Frontend (Vue) (push) Waiting to run
CI / Backend (Python) (push) Waiting to run
Mirror / mirror (push) Waiting to run
- 5 new top-level categories: BBQ & Smoke, Central American, African,
  Pacific & Oceania, Central Asian & Caucasus
- British/Irish split into British + Irish + Scottish with regional keywords
- Scandinavian: dish-level keyword expansion to fix zero-count gap
- Mediterranean: Israeli → Jewish (Ashkenazi/Sephardic/NY deli/z'houg/hawaiij);
  Palestinian, Yemeni, Egyptian, Syrian added; Moroccan moved to African
- Mexican: +Baja/Cal-Mex, +Mexico City
- Asian: +Hong Kong, +Cambodian, +Laotian, +Mongolian (16 subcategories)
- Indian: +Bangladeshi, +Pakistani, +Sri Lankan, +Nepali (8 subcategories)
- Latin American: full Caribbean depth (Jamaican, Puerto Rican, Dominican,
  Haitian, Trinidad); +Argentinian, +Venezuelan, +Chilean
- American: +Pacific Northwest, +Hawaiian; BBQ promoted to own category
- BBQ & Smoke: 8 regional styles (Texas, Carolina, KC, Memphis, Alabama,
  Kentucky, St. Louis, Backyard)
- feat(shopping): locale_config.py — Amazon/Instacart/Walmart locale routing
  for multi-currency affiliate link support (#114)
- chore: gitleaks allowlist for Amazon grocery dept IDs in locale_config.py
2026-04-21 10:15:58 -07:00
0bef082ff0 chore(config): add llm.yaml.example with cf-text trunk backend pattern
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run
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.
2026-04-20 13:28:56 -07:00
c6f45be1ba feat(config): add CF_APP_NAME for cf-orch analytics attribution 2026-04-20 07:02:00 -07:00
be050f5492 feat(scheduler): auto-detect OrchestratedScheduler when cf-orch is installed
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.
2026-04-19 22:12:44 -07:00
e2658f743f feat(scheduler): OrchestratedScheduler for cloud/multi-GPU, configurable via env
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.
2026-04-19 22:11:34 -07:00
dbc4aa3c68 feat(frontend): async polling for L3/L4 recipe generation + rename cf-orch node to sif
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
2026-04-19 21:52:21 -07:00
ed4595d960 feat(recipes): async L3/L4 recipe job queue with poll endpoint
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
2026-04-19 21:44:27 -07:00
eba536070c fix(recipe): fail fast on cf-orch 429 instead of slow LLMRouter fallback
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.
2026-04-19 20:24:21 -07:00
79f345aae6 fix: install circuitforge-orch in kiwi image for cf-orch-agent sidecar
Some checks failed
CI / Backend (Python) (push) Has been cancelled
CI / Frontend (Vue) (push) Has been cancelled
Mirror / mirror (push) Has been cancelled
cf-orch-agent in compose.override.yml was crash-looping (exit 127) because
the circuitforge_orch package wasn't installed in the kiwi conda env.
Same COPY + editable-install pattern already used for circuitforge-core.
2026-04-18 22:29:08 -07:00
5385adc52a feat: title search and sort controls in recipe browser
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run
Adds minimal sort/search to the recipe browser for cognitive access diversity —
linear scanners, alphabet browsers, and keyword diggers each get a different
way in without duplicating the full search tab.

- browse_recipes: q (LIKE title filter) + sort (default/alpha/alpha_desc)
- API endpoint: q/sort query params with validation
- Frontend: debounced search input (350ms) + sort pills (Default/A→Z/Z→A)
- Search and sort reset on domain/category change
- _all path supports q+sort; keyword-FTS path adds AND filter on top
2026-04-18 22:14:36 -07:00
e7ba305e63 feat: hierarchical subcategory navigation in recipe browser
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run
Adds a two-level browse tree (domain → category → subcategory) to the
recipe browser, plus an "All" unfiltered option at the top of every
domain.

browser_domains.py:
- Category values now support list[str] (flat) or dict with "keywords"
  and "subcategories" keys — backward compatible with all existing flat
  categories
- Added subcategories to: Italian (Sicilian, Neapolitan, Tuscan, Roman,
  Venetian, Ligurian), Mexican (Oaxacan, Yucatecan, Veracruz, Street
  Food, Mole), Asian (Korean, Japanese, Chinese, Thai, Vietnamese,
  Filipino, Indonesian), Indian (North, South, Bengali, Gujarati),
  Mediterranean (Greek, Turkish, Moroccan, Lebanese, Israeli), American
  (Southern, Cajun/Creole, BBQ, Tex-Mex, New England), European
  (French, Spanish, German, British/Irish, Scandinavian), Latin American
  (Peruvian, Brazilian, Colombian, Cuban, Caribbean), Dinner, Lunch,
  Breakfast, Snack, Dessert, Chicken, Beef, Pork, Fish, Vegetables
- New helpers: category_has_subcategories, get_subcategory_names,
  get_keywords_for_subcategory

store.py:
- get_browser_categories now accepts has_subcategories_by_category and
  includes has_subcategories: bool in each result row
- New get_browser_subcategories method for subcategory count queries

recipes.py endpoints:
- GET /browse/{domain}/{category}/subcategories — returns subcategory
  list with recipe counts (registered before /{subcategory} to avoid
  path collision)
- GET /browse/{domain}/{category} gains optional ?subcategory=X param
  to narrow results within a category
- GET /browse/{domain}/{category}/_all — unfiltered paginated browse
  (landed in previous commit)

api.ts: BrowserCategory adds has_subcategories; new BrowserSubcategory
type; listSubcategories() call; browse() gains subcategory param

RecipeBrowserPanel.vue:
- Category pills show a › indicator when subcategories exist
- Selecting such a category fetches subcategories in the background
  (non-blocking — recipes load immediately at the category level)
- Subcategory row appears below the category list with an
  "All [Category]" pill + one pill per subcategory with count
- Active subcategory highlighted; clicking "All [Category]" resets
  to the full category view
2026-04-18 21:07:06 -07:00
b2c546e86a feat: wire secondary-use window hints into recipe engine (#83)
Secondary-state items (stale bread, overripe bananas, day-old rice, etc.)
are now surfaced to the recipe engine so relevant recipes get matched even
when the ingredient is phrased differently in the corpus (e.g. "day-old
rice" vs. "rice").

Backend:
- Add rice and tortillas entries to SECONDARY_WINDOW in expiration_predictor
- Add secondary_pantry_items: dict[str, str] field to RecipeRequest schema
  (maps product_name → secondary_state label, e.g. {"Bread": "stale"})
- Add _SECONDARY_STATE_SYNONYMS lookup in recipe_engine — keyed by
  (category, state_label), returns corpus-matching ingredient phrases
- Update _expand_pantry_set() to accept secondary_pantry_items and inject
  synonym terms into the expanded pantry set used for FTS matching

Frontend:
- Add secondary_pantry_items to RecipeRequest interface in api.ts
- Add secondaryPantryItems param to _buildRequest / suggest / loadMore
  in the recipes store
- Add secondaryPantryItems computed to RecipesView — reads secondary_state
  from inventory items (expired but still in secondary window) and builds
  the product_name → state_label map
- Pass secondaryPantryItems into handleSuggest and handleLoadMore

Closes #83
2026-04-18 19:06:53 -07:00
8fd77bd1f2 fix: suppress E2E test sessions from log-based analytics
Add E2E_TEST_USER_ID setting (opt-in via env); session bootstrap logs
at DEBUG instead of INFO for the known test user so test runs don't
inflate session counts.  Still visible with DEBUG=true.
2026-04-18 19:06:37 -07:00
22a3da61c3 fix: frontend concurrent-mount errors, nginx routing, and browser UX (#98 #106 #107)
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run
- App.vue: lazy-mount pattern (v-if + v-show) so non-active tabs only mount on
  first visit, eliminating concurrent onMounted calls across all components (#98)
- nginx.cloud.conf: add /kiwi/api/ location to proxy API calls on direct-port
  access (localhost:8515); was serving SPA HTML → causing M.map/filter/find
  TypeError chain on load (#98)
- nginx.cloud.conf: $host → $http_host so 307 redirects preserve port number (#107)
- RecipeBrowserPanel: show friendly "corpus not loaded" notice and skip auto-select
  when all category counts are 0, instead of rendering confusing empty buttons (#106)
- Defensive Array.isArray guards in inventory store, mealPlan store, ReceiptsView
2026-04-18 17:12:34 -07:00
bea61054fa fix: re-fetch inventory item after insert to populate product_name (#99)
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run
2026-04-18 16:02:35 -07:00
38382a4fc9 fix: merge recipe enrichment backfill, main_ingredient browser, bug batch (#109)
Some checks failed
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Has been cancelled
Release / release (push) Has been cancelled
2026-04-18 15:39:45 -07:00
01aae2eec8 fix: recipe enrichment backfill, main_ingredient browser domain, bug batch
Some checks failed
CI / Backend (Python) (push) Has been cancelled
CI / Frontend (Vue) (push) Has been cancelled
CI / Backend (Python) (pull_request) Has been cancelled
CI / Frontend (Vue) (pull_request) Has been cancelled
Recipe corpus (#108):
- Add _MAIN_INGREDIENT_SIGNALS to tag_inferrer.py (Chicken/Beef/Pork/Fish/Pasta/
  Vegetables/Eggs/Legumes/Grains/Cheese) — infers main:* tags from ingredient names
- Update browser_domains.py main_ingredient categories to use main:* tag queries
  instead of raw food terms; recipe_browser_fts now has full 3.19M row coverage
  (was ~1.2K before backfill)

Bug fixes:
- Fix community posts response shape (#96): add total/page/page_size fields
- Fix export endpoint arg types (#92)
- Fix household invite store leak (#93)
- Fix receipts endpoint issues
- Fix saved_recipes endpoint
- Add session endpoint (app/api/endpoints/session.py)

Shopping list:
- Add migration 033_shopping_list.sql
- Add shopping schemas (app/models/schemas/shopping.py)
- Add ShoppingView.vue, ShoppingItemRow.vue, shopping.ts store

Frontend:
- InventoryList, RecipesView, RecipeDetailPanel polish
- App.vue routing updates for shopping view

Docs:
- Add user-facing docs under docs/ (getting-started, user-guide, reference)
- Add screenshots
2026-04-18 15:38:56 -07:00
890216a1f0 fix: wire recipe corpus to cloud per-user DBs via SQLite ATTACH (#102)
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run
Cloud mode: attach shared read-only corpus DB (RECIPE_DB_PATH env var)
as "corpus" schema so per-user SQLite DBs can access 3.19M recipes.
All corpus table references now use self._cp prefix ("corpus." in cloud,
"" in local). FTS5 pseudo-column kept unqualified per SQLite spec.
compose.cloud.yml: bind-mount /Library/Assets/kiwi/kiwi.db read-only.

Also fix batch of audit issues:
- #101: OCR approval used source="receipt_ocr" for inventory_items — use "receipt"
- #89/#100: Shopping confirm-purchase used source="shopping_list" — use "manual"
- #103: Frontend inventory filter sent ?status= but API expects ?item_status=
- #104: InventoryItemUpdate schema missing purchase_date field; store.py allowed set also missing it
- #105: Guest cookie Secure flag tied to CLOUD_MODE instead of X-Forwarded-Proto; broke HTTP direct-port access
2026-04-18 14:21:56 -07:00
8483b9ae5f feat: add Plausible analytics to Vue SPA and docs
Some checks failed
CI / Backend (Python) (push) Has been cancelled
CI / Frontend (Vue) (push) Has been cancelled
Mirror / mirror (push) Has been cancelled
2026-04-16 21:15:56 -07:00
23a2e8fe38 feat: remove and reorder meal types in weekly planner
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run
- MealPlanGrid: edit-mode toggle (visible when >1 meal type) reveals
  per-row ↑/↓ reorder and ✕ remove controls; label column expands to
  auto width via CSS class swap
- mealPlan store: removeMealType() and reorderMealTypes() — both send
  the full updated array to the existing PATCH /meal-plans/{id} endpoint
- MealPlanView: wires remove-meal-type and reorder-meal-types emits;
  shares mealTypeAdding loading flag to disable controls during save
- Guard: remove disabled when only one type remains (always keep ≥1)
2026-04-16 15:13:59 -07:00
6aa63cf2f0 chore: bump version to 0.3.0
Some checks failed
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Has been cancelled
Release / release (push) Has been cancelled
2026-04-16 14:24:16 -07:00
e745ce4375 feat: wire meal planner slot editor and meal type picker
Slot click now opens an inline editor panel:
- Pick from saved recipes via dropdown (pre-loaded on mount)
- Or type a custom label
- Clear slot button when a slot is already filled
- Save/Cancel with loading state

Add meal type opens a chip picker showing the types not yet active
(breakfast / lunch / snack minus whatever is already on the plan).
Selecting one calls the new PATCH /meal-plans/{plan_id} endpoint.

Backend:
- PATCH /meal-plans/{plan_id} with UpdatePlanRequest(meal_types)
- store.update_meal_plan_types() UPDATE ... RETURNING *
- 409 on IntegrityError in create_plan (already in place)
2026-04-16 14:23:38 -07:00
de0008f5c7 fix: meal planner auto-selects current week on load, + New week idempotent
- Add autoSelectPlan() to the store: after loadPlans() resolves, set
  activePlan to the current week's plan (or most recent) without a second
  API round-trip -- list already returns full PlanSummary with slots
- Call autoSelectPlan(mondayOfCurrentWeek()) in onMounted so the grid
  populates immediately without the user touching the dropdown
- Make onNewPlan idempotent: if a 409 comes back, activate the existing
  plan for that week instead of surfacing an error to the user
2026-04-16 10:50:34 -07:00
dbaf2b6ac8 fix: meal planner week add button crashing on r.name / add duplicate guard
- Fix sqlite3.OperationalError: the recipes table uses `title` not `name`;
  get_plan_slots JOIN was crashing every list_plans call with a 500,
  making the + New week button appear broken (plans were being created
  silently but the selector refresh always failed)
- Add migration 032 to add UNIQUE INDEX on meal_plans(week_start)
  to prevent duplicate plans accumulating while the button was broken
- Raise HTTP 409 on IntegrityError in create_plan so duplicates produce
  a clear error instead of a 500
- Fix mondayOfCurrentWeek to build the date string from local date parts
  instead of toISOString(), which converts through UTC and can produce the
  wrong calendar day for UTC+ timezones
- Add planCreating/planError state to MealPlanView so button shows
  "Creating..." during the request and displays errors inline
2026-04-16 10:46:28 -07:00
9a277f9b42 fix: barcode scan performance + timeout + success message
- Refactor _lookup_in_database to accept a shared httpx.AsyncClient so
  all three Open*Facts database attempts reuse one TLS connection instead
  of opening a new one per call; restores pre-fallback scan speed
- Increase recipe suggest timeout to 120s (was 30s) to survive cf-orch
  model cold-start on first request of a session
- Include product brand in barcode scan success message so the user can
  clearly see what was found (e.g. "Added: Cheerios (General Mills) to pantry")
2026-04-16 09:57:53 -07:00
200a6ef87b feat(recipes): complexity badges, time hints, Surprise Me, Just Pick One
#55 — Complexity rating on recipe cards:
  - Derived from direction text via _classify_method_complexity()
  - Badge displayed on every card: easy (green), moderate (amber), involved (red)
  - Filterable via complexity filter chips in the results bar

#58 — Cooking time + difficulty as filter domains:
  - estimated_time_min derived from step count + complexity
  - Time hint (~Nm) shown on every card
  - complexity_filter and max_time_min fields in RecipeRequest
  - Both applied in the engine before suggestions are built

#53 — Surprise Me: picks a random suggestion from the filtered pool,
  avoids repeating the last pick. Shown in a spotlight card.

#57 — Just Pick One: surfaces the top-matched suggestion in the same
  spotlight card. One tap to commit to cooking it.

Closes #55, #58, #53, #57
2026-04-16 09:27:34 -07:00
c8fdc21c29 feat(export): JSON full-backup download (pantry + saved recipes)
Adds GET /export/json that bundles inventory and saved recipes into a
single timestamped JSON file for data portability. The export envelope
includes schema version and export timestamp so future import logic can
handle version differences.

Frontend: new primary-styled JSON download button in the Export card with
a short description of what is included.

Closes #62
2026-04-16 09:16:33 -07:00
2ad71f2636 feat(recipes): pantry match floor filter — 'can make now' toggle
Adds pantry_match_only flag to RecipeRequest. When enabled, any recipe
with one or more missing ingredients (after swaps) is excluded from
results. Swapped ingredients count as covered.

Frontend: toggle checkbox in recipe settings panel, disabled when
shopping mode is active (the two modes are mutually exclusive). Hides
the max-missing input when pantry-match-only is on (irrelevant there).

Closes #63
2026-04-16 09:12:24 -07:00
0de6182f48 feat(scan): barcode miss fallback chain — Open Beauty Facts + Open Products Facts
When a barcode is not found in Open Food Facts, the service now tries
Open Beauty Facts and Open Products Facts before giving up. All three
share the same API format; only the host URL differs.

When all databases miss, the scan endpoint sets needs_manual_entry=true
in the result. The frontend detects this, shows a calm informational
message, and switches to manual entry mode automatically.

Also fixes a latent bug where not-found scans showed 'Added: item to
pantry' due to the success condition checking barcodes_found (always 1)
instead of added_to_inventory.

Closes #65
2026-04-16 08:30:49 -07:00
fb18a9c78c feat: partial consumption tracking and waste/disposal logging (#12 #60)
#12 — partial consume:
- POST /inventory/items/{id}/consume now accepts optional {quantity}
  body; decrements by that amount and only marks status=consumed when
  quantity reaches zero (store.partial_consume_item)
- OFFs barcode scan pre-fills sub-unit quantity when product data
  includes a pack size (number_of_units or 'N x ...' quantity string)
- Consume button shows quantity-aware label and opens ActionDialog
  with number input for multi-unit items ('use some or all')
- consumeItem() in api.ts now returns InventoryItem and accepts
  optional quantity param

#60 — disposal logging:
- Migration 031: adds disposal_reason TEXT column to inventory_items
  (status='discarded' was already in the CHECK constraint)
- POST /inventory/items/{id}/discard endpoint with optional DiscardRequest
  body (free text or preset reason)
- Calm framing: 'item not used' not 'wasted'; reason presets avoid
  blame language ('went bad before I could use it', 'too much — had excess')
- Muted discard button (X icon, tertiary color) — not alarming

Shared:
- New ActionDialog.vue component for dialogs with inline inputs
  (quantity stepper or reason dropdown); keeps ConfirmDialog simple
- disposal_reason field added to InventoryItemResponse

Closes #12
Closes #60
2026-04-16 07:28:21 -07:00
443e68ba3f fix: wire recipe engine to cf-text service instead of vllm
Aligns llm_recipe.py with the pattern already used by the meal plan
service. cf-text routes through a lighter GGUF/llama.cpp path and
shares VRAM budget with other products via cf-orch, rather than
requiring a dedicated vLLM process. Also drops model_candidates
(not applicable to cf-text allocation).

Closes #70
2026-04-16 06:25:46 -07:00
64a0abebe3 feat: pantry intel cluster — #61 expiry display, #64 cook log, #66 scaling, #59 open-package tracking
#61: expiry badge now shows relative + calendar date ("5d · Apr 15") with
tooltip "Expires in 5 days (Apr 15)"; traffic-light colors already in place

#64: RecipeDetailPanel.handleCook() calls recipesStore.logCook(); SavedRecipesPanel
shows "Last made: X ago" below each card using cookLog entries

#66: Serving multiplier (1x/2x/3x/4x) in RecipeDetailPanel scales ingredient
quantities using regex; handles integers, decimals, fractions (1/2, 3/4),
mixed numbers (1 1/2), and ranges (2-3); leaves unrecognised strings unchanged

#59: migration 030 adds opened_date column; ExpirationPredictor gains
SHELF_LIFE_AFTER_OPENING table + days_after_opening(); POST /inventory/items/{id}/open
sets opened_date=today and returns computed opened_expiry_date; InventoryList
shows lock-open button for unopened items and an "📂 5d · Apr 15" badge once opened
2026-04-16 06:01:25 -07:00
4423373750 feat: screenshot attachment in feedback form (#82)
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run
- Backend: new /api/v1/feedback/attach endpoint uploads image to
  Forgejo as an issue asset, then pins it as a comment so the
  screenshot is visible inline on the issue
- Frontend: file input in feedback form (all types, max 5 MB)
  with inline thumbnail preview and remove button
- Attach call is non-fatal: if upload fails after issue creation,
  the issue is still filed and the user sees success
- Screenshot state clears on modal reset

Closes #82
2026-04-15 23:08:02 -07:00
76516abd62 feat: metric/imperial unit preference (#81)
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run
- Settings: add unit_system key (metric | imperial, default metric)
- Recipe LLM prompts: inject unit instruction into L3 and L4 prompts
  so generated recipes use the user's preferred units throughout
- Frontend: new utils/units.ts converter (mirrors Python units.py)
- Inventory list: display quantities converted to preferred units
- Settings view: metric/imperial toggle with save button
- Settings store: load/save unit_system alongside cooking_equipment

Closes #81
2026-04-15 23:04:29 -07:00
757f779030 ci: add GitHub Actions CI for public credibility badge
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run
Lean self-contained workflow — no Forgejo-specific secrets.
circuitforge-core installs from Forgejo git (public repo).
Forgejo (.forgejo/workflows/ci.yml) remains the canonical CI.

Backend: ruff + pytest | Frontend: vue-tsc only (no vitest yet)
2026-04-15 20:20:13 -07:00
c984e6162e ci: wire Forgejo Actions CI, mirror, and release workflows
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run
- .forgejo/workflows/ci.yml — backend (ruff + pytest) + frontend (vue-tsc)
- .forgejo/workflows/mirror.yml — push to GitHub + Codeberg on main/tags
- .forgejo/workflows/release.yml — git-cliff changelog + Forgejo release on v* tags
- .cliff.toml — conventional commits changelog config

Frontend CI runs typecheck only (no vitest yet; tracked separately).
circuitforge-core installed from Forgejo git (public; not on PyPI).
Docker push disabled pending BSL registry policy (cf-agents#3).

Closes #23
2026-04-15 20:10:57 -07:00
ab97af73f7 Merge pull request 'fix(a11y): accessibility and ND-design audit fixes (#42-#48, #54, #80)' (#85) from fix/a11y-audit into main 2026-04-15 10:21:13 -07:00
6741c6981d fix(kiwi-a11y): chip-remove touch targets, btn-link undo target, star rating label, notes aria-expanded 2026-04-15 10:12:17 -07:00
5c135d0860 fix(kiwi-a11y): undo toast for 'I cooked this' dismiss action (#45) 2026-04-15 10:06:31 -07:00
bc04739447 fix(kiwi-a11y): SavedRecipesPanel empty state, remove confirmation, notes expand (#43 #44 #48) 2026-04-15 10:01:41 -07:00
ceb03f8b5b fix(kiwi-a11y): ND/calm-UX policy fixes — deficit language, wildcard styling, depletion framing (#42 #46 #47 #80-M)
- #42: Replace deficit framing — "You'd need:" → "To complete this recipe:", element_gaps
  card-warning → card-secondary, missing/gap chips status-warning → status-info,
  "Your pantry is missing..." → "These would expand your options:"
- #46: Add activeNutritionFilterCount computed; show count in Advanced filters summary
  when filters are active so it's visible while collapsed
- #47: Wildcard confirmation status-warning → status-info, copy updated to calm framing;
  wildcard recipe card badge status-warning → status-info
- M1: Add re-search hint below Hard Day Mode toggle when results are already showing
- M8: Move swap candidates collapsible to after directions/steps section
- L2: Add autocomplete="off" to filter search, constraint, and allergy text inputs
- L5: Add title="This is an affiliate link" disclosure to grocery affiliate links

Items already correct (no change needed):
- M2: Level description already always visible via activeLevel computed
- M3: Rate limit copy already using calm framing
- M5: No-results copy already calm
- M6: levelLabels already uses full names
- M7: "that's part of the fun" was part of the wildcard copy fixed under #47
- L1: Neon/konami handler not present in this file
2026-04-15 09:57:48 -07:00
9de42c3088 fix(kiwi-a11y): tab focus, silent fail, emoji labels, form for/id pairs (H3-H8, #80) 2026-04-15 09:53:15 -07:00
41837f348c fix(kiwi-a11y): darken light-mode muted text to #7a5c2e for WCAG 1.4.3 AA (H1, #80) 2026-04-15 09:51:26 -07:00
4de4f63614 fix(kiwi-a11y): btn-icon touch targets; aria-busy loading; role=alert on error (C4-C6, #80) 2026-04-15 09:48:19 -07:00
391e79ac86 fix(kiwi-a11y): deep watchers for constraint/allergy persistence (#54) 2026-04-15 09:43:54 -07:00
91724caf96 fix(kiwi-a11y): persist constraint and allergy preferences to localStorage (#54) 2026-04-15 09:42:13 -07:00
fdc477b395 fix(kiwi-fe): MealPlanView strict TS split index type narrowing 2026-04-14 15:55:38 -07:00
33c619b6b5 feat(kiwi-fe): wire OrchUsagePill into RecipesView and Settings opt-in toggle
- Import and mount OrchUsagePill near the recipe level selector in RecipesView;
  pill is self-hiding when not enabled or no lifetime key is present
- Add useOrchUsage composable to SettingsView with a Display section checkbox
  so users can opt in to seeing the cloud recipe call budget pill
- Add @/ path alias to vite.config.ts and tsconfig.app.json to resolve the
  existing @/services/api import in useOrchUsage.ts (fixes vite build error)
- tsc --noEmit and vite build both pass clean
2026-04-14 15:51:34 -07:00
1ae54c370d feat(kiwi-fe): add OrchUsagePill component with calm low-budget state 2026-04-14 15:46:58 -07:00
b4f8bde952 feat(kiwi-fe): add useOrchUsage composable with opt-in localStorage persistence 2026-04-14 15:46:12 -07:00
bdfbc963b7 feat(kiwi-fe): add getOrchUsage API call and OrchUsage type 2026-04-14 15:45:22 -07:00
99e9cbb8c1 refactor(kiwi): remove unused LIFETIME_SOURCES import from recipes.py 2026-04-14 15:44:42 -07:00
006582b179 feat(kiwi): add /orch-usage proxy endpoint for frontend budget display 2026-04-14 15:42:58 -07:00
1a6898324c feat(kiwi): merge meal planner feature into main
Adds full meal planning workflow to Kiwi:
- Weekly meal plan creation with configurable meal types (Paid gate)
- Drag-and-assign recipe slots per day
- Prep session generation with sequenced task lists and time estimates
- LLM-assisted full-week plan and timing fill-in (BYOK-unlockable)
- Community feed (local ActivityPub-compat + cloud federation)
- Build Your Own recipe tab with assembly templates
- Save/bookmark any recipe with star rating, notes, and style tags
- Shopping list export from built recipes
- Tab reorder: Saved > Build > Community > Find > Browse
- Auto-redirect from empty Saved tab to Build
- Custom ingredient injection persists in candidate list
- z-index fix: save modal above recipe detail panel
- Route ordering fix: /recipes/saved before /{recipe_id} catch-all
2026-04-14 15:37:57 -07:00
01216b82c3 feat(kiwi): gate L3/L4 recipes behind orch budget check; fallback to L2 on exhaustion 2026-04-14 15:24:57 -07:00
2071540a56 feat(kiwi): add Heimdall orch budget client with fail-open semantics 2026-04-14 15:15:43 -07:00
bd73ca0b6d fix(tests): correct build endpoint test fixture
- Use monkeypatch.setattr to patch cloud_session._LOCAL_KIWI_DB
  instead of wrong KIWI_DB_PATH env var (module-level singleton
  computed at import time; env var had no effect)
- Assert id > 0 (real persisted DB id) instead of -1 (old
  pre-persistence sentinel value)
2026-04-14 14:57:16 -07:00
9941227fae chore: merge main into feature/meal-planner
Resolves three conflicts:
- app/api/routes.py: fixed saved_recipes-before-recipes ordering from main;
  meal_plans and community_router from feature branch
- app/db/store.py: meal plan/prep session methods (feature) + community
  pseudonym methods (main) -- both additive
- app/tiers.py: KIWI_BYOK_UNLOCKABLE includes meal_plan_llm,
  meal_plan_llm_timing (feature) and community_fork_adapt (main)
2026-04-14 14:53:52 -07:00
3933136666 fix: save, shopping list, and route ordering for Build Your Own
- Persist built recipes to recipes table on /build so they get real DB IDs
  and can be bookmarked via saved_recipes (FK was pointing at negative IDs)
- Populate missing_ingredients in build_from_selection() from role_overrides
  vs pantry diff -- backend now owns shopping list computation
- Remove client-side cartItems tracking; shopping list derived from
  builtRecipe.missing_ingredients instead
- Fix saved_recipes 422: mount saved_recipes router before recipes router in
  routes.py so /recipes/saved isn't captured by /recipes/{recipe_id}
- Bump SaveRecipeModal z-index to 500 (above detail-overlay at 400)
- Replace "Add to pantry" primary action with "Grocery list" clipboard copy;
  "Add to pantry" demoted to compact secondary button
2026-04-14 14:48:30 -07:00
b4f031e87d feat(kiwi): add orch_fallback field to RecipeResult 2026-04-14 14:38:37 -07:00
fbae9ced72 feat(kiwi): add LIFETIME_ORCH_CAPS and LIFETIME_SOURCES constants 2026-04-14 14:38:36 -07:00
1882116235 feat: UX polish for Build Your Own tab and default landing
- Default app landing changed from Pantry to Recipes tab
- Pre-fetch inventory on app mount so Find tab has data immediately
- Reorder recipe sub-tabs: Saved > Build Your Own > Community > Find > Browse
- Default active sub-tab changed to Saved
- Auto-redirect from Saved to Build Your Own when saved list is empty
- Add freeform custom ingredient input: typing a non-pantry item now shows
  "Use X anyway" button so users aren't blocked on unknown ingredients
2026-04-14 13:53:54 -07:00
144d1dc6c4 chore: commit in-progress work -- tag inferrer, imitate endpoint, hall-of-chaos easter egg, migration files, Dockerfile .env defense
- app/services/recipe/tag_inferrer.py: infer tags from recipe ingredient text
- app/db/migrations/022_recipe_generic_flag.sql, 029_inferred_tags.sql: schema migrations
- app/api/endpoints/imitate.py: recipe imitation endpoint stub
- app/api/endpoints/community.py: hall-of-chaos easter egg endpoint
- scripts/pipeline/infer_recipe_tags.py, backfill_keywords.py: pipeline scripts
- scripts/pipeline/build_recipe_index.py: extended index builder
- Dockerfile: explicit .env removal as defense-in-depth
- frontend/src/components/FeedbackButton.vue: feedback UX improvements
- frontend/src/style.css: minor style tweaks
- app/cloud_session.py: cloud session improvements
- tests/api/test_community_endpoints.py: additional test coverage
2026-04-14 13:23:15 -07:00
fe18fb48c0 feat: wire Build Your Own tab into RecipesView and add sparse-result nudge 2026-04-14 12:26:32 -07:00
40a12764c4 feat: add BuildYourOwnTab wizard component (template grid + picker + result) 2026-04-14 12:08:20 -07:00
9a42cdd4ae feat: add missingIngredientMode and builderFilterMode to recipes store 2026-04-14 11:53:29 -07:00
77ab6fb94a feat: add getTemplates, getRoleCandidates, buildRecipe to recipesAPI client 2026-04-14 11:48:33 -07:00
8c4965123f feat: add GET /templates, GET /template-candidates, POST /build endpoints
Wires the three Build Your Own API routes into the recipes router,
registered before the catch-all /{recipe_id} route to avoid shadowing.
Adds 5 endpoint tests covering template list count/shape, candidate
response structure, successful recipe build, and 404 on unknown template.
2026-04-14 11:45:43 -07:00
c02e538cb2 feat: remove assembly results from suggest() -- moved to Build Your Own tab 2026-04-14 11:39:57 -07:00
da940ebaec feat: add get_role_candidates() and build_from_selection() to assembly engine
Both functions are DB-free public API additions to assembly_recipes.py.
get_role_candidates() scores pantry candidates against a wizard step using
element-profile overlap with prior picks; build_from_selection() builds a
RecipeSuggestion from explicit role overrides with required-role validation.
2026-04-14 11:06:08 -07:00
4f1570ee6f feat: add Store.get_element_profiles() for wizard role candidate lookup 2026-04-14 10:50:46 -07:00
1a5fb23dfd feat: add slug/icon/descriptor to AssemblyTemplate and get_templates_for_api()
Extends AssemblyTemplate dataclass with slug, icon, descriptor, and
role_hints fields. Updates all 13 template instantiations with
appropriate values. Adds _TEMPLATE_BY_SLUG lookup dict and
get_templates_for_api() serialiser for the templates endpoint.
2026-04-14 10:36:58 -07:00
65ef65bb4c feat: add Pydantic schemas for Build Your Own tab endpoints 2026-04-14 09:45:05 -07:00
d24f87a476 docs: add link to docs.circuitforge.tech/kiwi in README 2026-04-14 08:19:32 -07:00
878a9a268c fix: community module integration fixes -- slots payload + ForkResult type
- PublishPayload gains optional slots field; PublishPlanModal maps
  plan.slots into the payload so the backend can compute element
  snapshot scores (seasoning, richness, etc.) from actual recipes
- plan-forked emit type updated to ForkResult across CommunityFeedPanel
  and RecipesView so forked_from is preserved for future navigation
2026-04-13 14:21:33 -07:00
d7bfc083e7 fix: Hall of Chaos quality fixes -- timer cleanup, aria-live, reduced-motion
- onUnmounted clears blooperHoldTimer to prevent stale callback after
  component teardown
- HallOfChaosView loading state gains aria-live="polite" so state
  transitions are announced to screen readers
- CommunityFeedPanel reduced-motion block resets toast translateY offset
  to avoid flash-of-offset-position on slow paint cycles
2026-04-13 12:34:23 -07:00
9246935fd7 feat: Hall of Chaos easter egg -- HallOfChaosView + long-press trigger
Adds the Hall of Chaos overlay component (recipe blooper gallery with
static CSS tilts, chaos level counter, panel-local overlay) and wires
the 800ms long-press trigger on the Bloopers filter tab in
CommunityFeedPanel. Pairs with the backend /community/hall-of-chaos
endpoint and test added in Task 10.
2026-04-13 12:30:48 -07:00
f92ac7a509 fix: publish modal a11y -- dialog-scoped Tab guard + correct first-focus in OutcomeModal 2026-04-13 11:48:57 -07:00
9603d421b6 feat: community publish modals -- focus traps, aria-live, plan + outcome forms 2026-04-13 11:45:32 -07:00
730445e479 fix: community feed a11y -- reduced-motion guards + tablist focus management 2026-04-13 11:38:17 -07:00
8731cad854 feat: community feed Vue frontend -- Pinia store + feed panel + RecipesView tab 2026-04-13 11:34:54 -07:00
86dd9adbcb refactor: use sqlite3.IntegrityError directly for slug collision guard 2026-04-13 11:25:10 -07:00
69e1b70072 fix: community endpoint quality issues — input validation, slot key guard, slug collision, ValueError handling 2026-04-13 11:23:45 -07:00
9ae886aabf fix: community endpoint spec gaps — ld+json content negotiation + premium post tier filter 2026-04-13 11:20:28 -07:00
9c64de2acf feat(community): community API endpoints — browse, publish, fork, delete, RSS, AP
Adds GET /community/posts, GET /community/posts/{slug}, GET /community/feed.rss,
GET /community/local-feed, POST /community/posts, DELETE /community/posts/{slug},
POST /community/posts/{slug}/fork, and POST /community/posts/{slug}/fork-adapt (501 stub).
Wires init_community_store into main.py lifespan. 7 new tests; 115 total passing.
2026-04-13 11:14:18 -07:00
81107ed238 feat(community): KiwiCommunityStore + pseudonym helpers in per-user store 2026-04-13 10:54:13 -07:00
b1ed369ea6 feat(community): mDNS advertisement via zeroconf — defaults OFF, opt-in per a11y audit 2026-04-13 09:59:50 -07:00
f12699349b feat(community): RSS 2.0 feed generator + ActivityPub JSON-LD scaffold 2026-04-13 09:44:51 -07:00
74c7272a50 feat(community): element snapshot — SFAH scores, allergen detection, dietary tags 2026-04-13 09:19:57 -07:00
1a9a8579a2 feat(community): add COMMUNITY_DB_URL config + community features to tiers 2026-04-13 09:02:44 -07:00
b97cd59920 feat(community): migration 028 — community_pseudonyms table in per-user kiwi.db 2026-04-13 08:13:39 -07:00
0b08fbb18d fix: correct leftover_mode tier in README — Free (5/day) not Premium
tiers.py and recipe_engine.py have always implemented this as Free with
a 5/day rate limit. README inherited a stale tier assignment from an
earlier design that was superseded when the rate-limit approach was chosen.

Closes #67
2026-04-12 18:06:44 -07:00
0b74915ee0 feat(community): wire COMMUNITY_DB_URL + COMMUNITY_PSEUDONYM_SALT into cloud compose 2026-04-12 17:45:20 -07:00
140 changed files with 12979 additions and 477 deletions

44
.cliff.toml Normal file
View file

@ -0,0 +1,44 @@
# git-cliff changelog configuration for Kiwi
# See: https://git-cliff.org/docs/configuration
[changelog]
header = """
# Changelog\n
"""
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [Unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}**{{ commit.scope }}:** {% endif %}{{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}\n
"""
trim = true
[git]
conventional_commits = true
filter_unconventional = true
split_commits = false
commit_preprocessors = []
commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Refactoring" },
{ message = "^docs", group = "Documentation" },
{ message = "^test", group = "Testing" },
{ message = "^chore", group = "Chores" },
{ message = "^ci", group = "CI/CD" },
{ message = "^revert", group = "Reverts" },
]
filter_commits = false
tag_pattern = "v[0-9].*"
skip_tags = ""
ignore_tags = ""
topo_order = false
sort_commits = "oldest"

View file

@ -51,6 +51,12 @@ ENABLE_OCR=false
DEBUG=false
CLOUD_MODE=false
DEMO_MODE=false
# Product identifier reported in cf-orch coordinator analytics for per-app breakdown
CF_APP_NAME=kiwi
# USE_ORCH_SCHEDULER: use coordinator-aware multi-GPU scheduler instead of local FIFO.
# Unset = auto-detect: true if CLOUD_MODE or circuitforge_orch is installed (paid+ local).
# Set false to force LocalScheduler even when cf-orch is present.
# USE_ORCH_SCHEDULER=false
# Cloud mode (set in compose.cloud.yml; also set here for reference)
# CLOUD_DATA_ROOT=/devl/kiwi-cloud-data
@ -68,9 +74,14 @@ DEMO_MODE=false
# HEIMDALL_URL=https://license.circuitforge.tech
# HEIMDALL_ADMIN_TOKEN=
# Directus JWT (must match cf-directus SECRET env var)
# Directus JWT (must match cf-directus SECRET env var exactly, including base64 == padding)
# DIRECTUS_JWT_SECRET=
# E2E test account (Directus — free tier, used by automated tests)
# E2E_TEST_EMAIL=e2e@circuitforge.tech
# E2E_TEST_PASSWORD=
# E2E_TEST_USER_ID=
# In-app feedback → Forgejo issue creation
# FORGEJO_API_TOKEN=
# FORGEJO_REPO=Circuit-Forge/kiwi
@ -83,3 +94,10 @@ DEMO_MODE=false
# INSTACART_AFFILIATE_ID=circuitforge
# Walmart Impact network affiliate ID (inline, path-based redirect)
# WALMART_AFFILIATE_ID=
# Community PostgreSQL — shared across CF products (cloud only; leave unset for local dev)
# Points at cf-orch's cf-community-postgres container (port 5434 on the orch host).
# When unset, community write paths fail soft with a plain-language message.
# COMMUNITY_DB_URL=postgresql://cf_community:changeme@cf-orch-host:5434/cf_community
# COMMUNITY_PSEUDONYM_SALT=change-this-to-a-random-32-char-string

62
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,62 @@
# Kiwi CI — lint, type-check, test on PR/push
# Full-stack: FastAPI (Python) + Vue 3 SPA (Node)
# Adapted from Circuit-Forge/cf-agents workflows/ci.yml (cf-agents#4 tracks the
# upstream ci-fullstack.yml variant; update this file when that lands).
#
# Note: frontend has no test suite yet — CI runs typecheck only.
# Add `npm run test` when vitest is wired (kiwi#XX).
#
# circuitforge-core is not on PyPI — installed from Forgejo git (public repo).
name: CI
on:
push:
branches: [main, 'feature/**', 'fix/**']
pull_request:
branches: [main]
jobs:
backend:
name: Backend (Python)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: pip
- name: Install circuitforge-core
run: pip install git+https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git@main
- name: Install dependencies
run: pip install -e ".[dev]" || pip install -e . pytest pytest-asyncio httpx ruff
- name: Lint
run: ruff check .
- name: Test
run: pytest tests/ -v --tb=short
frontend:
name: Frontend (Vue)
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Type check
run: npx vue-tsc --noEmit

View file

@ -0,0 +1,34 @@
# Mirror push to GitHub and Codeberg on every push to main or tag.
# Copied from Circuit-Forge/cf-agents workflows/mirror.yml
# Required secrets: GITHUB_MIRROR_TOKEN, CODEBERG_MIRROR_TOKEN
name: Mirror
on:
push:
branches: [main]
tags: ['v*']
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Mirror to GitHub
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_MIRROR_TOKEN }}
REPO: ${{ github.event.repository.name }}
run: |
git remote add github "https://x-access-token:${GITHUB_TOKEN}@github.com/CircuitForgeLLC/${REPO}.git"
git push github --mirror
- name: Mirror to Codeberg
env:
CODEBERG_TOKEN: ${{ secrets.CODEBERG_MIRROR_TOKEN }}
REPO: ${{ github.event.repository.name }}
run: |
git remote add codeberg "https://CircuitForge:${CODEBERG_TOKEN}@codeberg.org/CircuitForge/${REPO}.git"
git push codeberg --mirror

View file

@ -0,0 +1,71 @@
# Tag-triggered release workflow.
# Generates changelog and creates Forgejo release on v* tags.
# Copied from Circuit-Forge/cf-agents workflows/release.yml
#
# Docker push is intentionally disabled — BSL 1.1 registry policy not yet resolved.
# Tracked in Circuit-Forge/cf-agents#3. Re-enable the Docker steps when that lands.
#
# Required secrets: FORGEJO_RELEASE_TOKEN
# (GHCR_TOKEN not needed until Docker push is enabled)
name: Release
on:
push:
tags: ['v*']
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# ── Changelog ────────────────────────────────────────────────────────────
- name: Generate changelog
uses: orhun/git-cliff-action@v3
id: cliff
with:
config: .cliff.toml
args: --latest --strip header
env:
OUTPUT: CHANGES.md
# ── Docker (disabled — BSL registry policy pending cf-agents#3) ──────────
# - name: Set up QEMU
# uses: docker/setup-qemu-action@v3
# - name: Set up Buildx
# uses: docker/setup-buildx-action@v3
# - name: Log in to GHCR
# uses: docker/login-action@v3
# with:
# registry: ghcr.io
# username: ${{ github.actor }}
# password: ${{ secrets.GHCR_TOKEN }}
# - name: Build and push Docker image
# uses: docker/build-push-action@v6
# with:
# context: .
# push: true
# platforms: linux/amd64,linux/arm64
# tags: |
# ghcr.io/circuitforgellc/kiwi:${{ github.ref_name }}
# ghcr.io/circuitforgellc/kiwi:latest
# cache-from: type=gha
# cache-to: type=gha,mode=max
# ── Forgejo Release ───────────────────────────────────────────────────────
- name: Create Forgejo release
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_RELEASE_TOKEN }}
REPO: ${{ github.event.repository.name }}
TAG: ${{ github.ref_name }}
NOTES: ${{ steps.cliff.outputs.content }}
run: |
curl -sS -X POST \
"https://git.opensourcesolarpunk.com/api/v1/repos/Circuit-Forge/${REPO}/releases" \
-H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg tag "$TAG" --arg body "$NOTES" \
'{tag_name: $tag, name: $tag, body: $body}')"

59
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,59 @@
# Kiwi CI — runs on GitHub mirror for public credibility badge.
# Forgejo (.forgejo/workflows/ci.yml) is the canonical CI — keep these in sync.
# No Forgejo-specific secrets used here; circuitforge-core is public on Forgejo.
#
# Note: frontend has no test suite yet — CI runs typecheck only.
# Add 'npm run test' when vitest is wired.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend:
name: Backend (Python)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: pip
- name: Install circuitforge-core
run: pip install git+https://git.opensourcesolarpunk.com/Circuit-Forge/circuitforge-core.git@main
- name: Install dependencies
run: pip install -e . pytest pytest-asyncio httpx ruff
- name: Lint
run: ruff check .
- name: Test
run: pytest tests/ -v --tb=short
frontend:
name: Frontend (Vue)
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Type check
run: npx vue-tsc --noEmit

34
.gitleaks.toml Normal file
View file

@ -0,0 +1,34 @@
# Kiwi gitleaks config — extends base CircuitForge config with local rules
[extend]
path = "/Library/Development/CircuitForge/circuitforge-hooks/gitleaks.toml"
# ── Global allowlist ──────────────────────────────────────────────────────────
# Amazon grocery department IDs (rh=n:<10-digit>) false-positive as phone
# numbers. locale_config.py is a static lookup table with no secrets.
[allowlist]
# Amazon grocery dept IDs (rh=n:<digits>) false-positive as phone numbers.
regexes = [
'''rh=n:\d{8,12}''',
]
# ── Test fixture allowlists ───────────────────────────────────────────────────
[[rules]]
id = "cf-generic-env-token"
description = "Generic KEY=<token> in env-style assignment — catches FORGEJO_API_TOKEN=hex etc."
regex = '''(?i)(token|secret|key|password|passwd|pwd|api_key)\s*[=:]\s*['"]?[A-Za-z0-9\-_]{20,}['"]?'''
[rules.allowlist]
paths = [
'.*test.*',
]
regexes = [
'api_key:\s*ollama',
'api_key:\s*any',
'your-[a-z\-]+-here',
'replace-with-',
'xxxx',
'test-fixture-',
'CFG-KIWI-TEST-',
]

View file

@ -11,13 +11,23 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY circuitforge-core/ ./circuitforge-core/
RUN conda run -n base pip install --no-cache-dir -e ./circuitforge-core
# Install circuitforge-orch — needed for the cf-orch-agent sidecar (compose.override.yml)
COPY circuitforge-orch/ ./circuitforge-orch/
# Create kiwi conda env and install app
COPY kiwi/environment.yml .
RUN conda env create -f environment.yml
COPY kiwi/ ./kiwi/
# Install cf-core into the kiwi env BEFORE installing kiwi (kiwi lists it as a dep)
# Remove gitignored config files that may exist locally — defense-in-depth.
# The parent .dockerignore should exclude these, but an explicit rm guarantees
# they never end up in the cloud image regardless of .dockerignore placement.
RUN rm -f /app/kiwi/.env
# Install cf-core and cf-orch into the kiwi env BEFORE installing kiwi
RUN conda run -n kiwi pip install --no-cache-dir -e /app/circuitforge-core
RUN conda run -n kiwi pip install --no-cache-dir -e /app/circuitforge-orch
WORKDIR /app/kiwi
RUN conda run -n kiwi pip install --no-cache-dir -e .

View file

@ -10,6 +10,8 @@ Scan barcodes, photograph receipts, and get recipe ideas based on what you alrea
**Status:** Beta · CircuitForge LLC
**[Documentation](https://docs.circuitforge.tech/kiwi/)** · [circuitforge.tech](https://circuitforge.tech)
---
## What it does
@ -21,7 +23,7 @@ Scan barcodes, photograph receipts, and get recipe ideas based on what you alrea
- **Receipt OCR** — extract line items from receipt photos automatically (Paid tier, BYOK-unlockable)
- **Recipe suggestions** — four levels from pantry-match to full LLM generation (Paid tier, BYOK-unlockable)
- **Style auto-classifier** — LLM suggests style tags (comforting, hands-off, quick, etc.) for saved recipes (Paid tier, BYOK-unlockable)
- **Leftover mode** — prioritize nearly-expired items in recipe ranking (Premium tier)
- **Leftover mode** — prioritize nearly-expired items in recipe ranking (Free, 5/day; unlimited at Paid+)
- **LLM backend config** — configure inference via `circuitforge-core` env-var system; BYOK unlocks Paid AI features at any tier
- **Feedback FAB** — in-app feedback button; status probed on load, hidden if CF feedback endpoint unreachable
@ -68,7 +70,7 @@ cp .env.example .env
| LLM style auto-classifier | — | BYOK | ✓ |
| Meal planning | — | ✓ | ✓ |
| Multi-household | — | — | ✓ |
| Leftover mode | — | — | ✓ |
| Leftover mode (5/day) | ✓ | ✓ | ✓ |
BYOK = bring your own LLM backend (configure `~/.config/circuitforge/llm.yaml`)

View file

@ -0,0 +1,358 @@
# app/api/endpoints/community.py
# MIT License
from __future__ import annotations
import asyncio
import logging
import re
import sqlite3
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from app.cloud_session import CloudUser, get_session
from app.core.config import settings
from app.db.store import Store
from app.services.community.feed import posts_to_rss
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/community", tags=["community"])
_community_store = None
def _get_community_store():
return _community_store
def init_community_store(community_db_url: str | None) -> None:
global _community_store
if not community_db_url:
logger.info(
"COMMUNITY_DB_URL not set — community write features disabled. "
"Browse still works via cloud feed."
)
return
from circuitforge_core.community import CommunityDB
from app.services.community.community_store import KiwiCommunityStore
db = CommunityDB(dsn=community_db_url)
db.run_migrations()
_community_store = KiwiCommunityStore(db)
logger.info("Community store initialized.")
def _visible(post, session=None) -> bool:
"""Return False for premium-tier posts when the session is not paid/premium."""
tier = getattr(post, "tier", None)
if tier == "premium":
if session is None or getattr(session, "tier", None) not in ("paid", "premium"):
return False
return True
@router.get("/posts")
async def list_posts(
post_type: str | None = None,
dietary_tags: str | None = None,
allergen_exclude: str | None = None,
page: int = 1,
page_size: int = 20,
):
store = _get_community_store()
if store is None:
return {
"posts": [],
"total": 0,
"page": page,
"page_size": page_size,
"note": "Community DB not available on this instance.",
}
dietary = [t.strip() for t in dietary_tags.split(",")] if dietary_tags else None
allergen_ex = [t.strip() for t in allergen_exclude.split(",")] if allergen_exclude else None
offset = (page - 1) * min(page_size, 100)
posts = await asyncio.to_thread(
store.list_posts,
limit=min(page_size, 100),
offset=offset,
post_type=post_type,
dietary_tags=dietary,
allergen_exclude=allergen_ex,
)
visible = [_post_to_dict(p) for p in posts if _visible(p)]
return {"posts": visible, "total": len(visible), "page": page, "page_size": page_size}
@router.get("/posts/{slug}")
async def get_post(slug: str, request: Request):
store = _get_community_store()
if store is None:
raise HTTPException(status_code=503, detail="Community DB not available on this instance.")
post = await asyncio.to_thread(store.get_post_by_slug, slug)
if post is None:
raise HTTPException(status_code=404, detail="Post not found.")
accept = request.headers.get("accept", "")
if "application/activity+json" in accept or "application/ld+json" in accept:
from app.services.community.ap_compat import post_to_ap_json_ld
base_url = str(request.base_url).rstrip("/")
return post_to_ap_json_ld(_post_to_dict(post), base_url=base_url)
return _post_to_dict(post)
@router.get("/feed.rss")
async def get_rss_feed(request: Request):
store = _get_community_store()
posts_data: list[dict] = []
if store is not None:
posts = await asyncio.to_thread(store.list_posts, limit=50)
posts_data = [_post_to_dict(p) for p in posts]
base_url = str(request.base_url).rstrip("/")
rss = posts_to_rss(posts_data, base_url=base_url)
return Response(content=rss, media_type="application/rss+xml; charset=utf-8")
@router.get("/local-feed")
async def local_feed():
store = _get_community_store()
if store is None:
return []
posts = await asyncio.to_thread(store.list_posts, limit=50)
return [_post_to_dict(p) for p in posts]
@router.get("/hall-of-chaos")
async def hall_of_chaos():
"""Hidden easter egg endpoint -- returns the 10 most chaotic bloopers."""
store = _get_community_store()
if store is None:
return {"posts": [], "chaos_level": 0}
posts = await asyncio.to_thread(
store.list_posts, limit=10, post_type="recipe_blooper"
)
return {
"posts": [_post_to_dict(p) for p in posts],
"chaos_level": len(posts),
}
_VALID_POST_TYPES = {"plan", "recipe_success", "recipe_blooper"}
_MAX_TITLE_LEN = 200
_MAX_TEXT_LEN = 2000
def _validate_publish_body(body: dict) -> None:
"""Raise HTTPException(422) for any invalid fields in a publish request."""
post_type = body.get("post_type", "plan")
if post_type not in _VALID_POST_TYPES:
raise HTTPException(
status_code=422,
detail=f"post_type must be one of: {', '.join(sorted(_VALID_POST_TYPES))}",
)
title = body.get("title") or ""
if len(title) > _MAX_TITLE_LEN:
raise HTTPException(status_code=422, detail=f"title exceeds {_MAX_TITLE_LEN} character limit.")
for field in ("description", "outcome_notes", "recipe_name"):
value = body.get(field)
if value and len(str(value)) > _MAX_TEXT_LEN:
raise HTTPException(status_code=422, detail=f"{field} exceeds {_MAX_TEXT_LEN} character limit.")
photo_url = body.get("photo_url")
if photo_url and not str(photo_url).startswith("https://"):
raise HTTPException(status_code=422, detail="photo_url must be an https:// URL.")
@router.post("/posts", status_code=201)
async def publish_post(body: dict, session: CloudUser = Depends(get_session)):
from app.tiers import can_use
if not can_use("community_publish", session.tier, session.has_byok):
raise HTTPException(status_code=402, detail="Community publishing requires Paid tier.")
_validate_publish_body(body)
store = _get_community_store()
if store is None:
raise HTTPException(
status_code=503,
detail="This Kiwi instance is not connected to a community database. "
"Publishing is only available on cloud instances.",
)
from app.services.community.community_store import get_or_create_pseudonym
def _get_pseudonym():
s = Store(session.db)
try:
return get_or_create_pseudonym(
store=s,
directus_user_id=session.user_id,
requested_name=body.get("pseudonym_name"),
)
finally:
s.close()
try:
pseudonym = await asyncio.to_thread(_get_pseudonym)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
recipe_ids = [slot["recipe_id"] for slot in body.get("slots", []) if slot.get("recipe_id")]
from app.services.community.element_snapshot import compute_snapshot
def _snapshot():
s = Store(session.db)
try:
return compute_snapshot(recipe_ids=recipe_ids, store=s)
finally:
s.close()
snapshot = await asyncio.to_thread(_snapshot)
post_type = body.get("post_type", "plan")
slug_title = re.sub(r"[^a-z0-9]+", "-", (body.get("title") or "plan").lower()).strip("-")
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
slug = f"kiwi-{_post_type_prefix(post_type)}-{pseudonym.lower().replace(' ', '')}-{today}-{slug_title}"[:120]
from circuitforge_core.community.models import CommunityPost
post = CommunityPost(
slug=slug,
pseudonym=pseudonym,
post_type=post_type,
published=datetime.now(timezone.utc),
title=(body.get("title") or "Untitled")[:_MAX_TITLE_LEN],
description=body.get("description"),
photo_url=body.get("photo_url"),
slots=body.get("slots", []),
recipe_id=body.get("recipe_id"),
recipe_name=body.get("recipe_name"),
level=body.get("level"),
outcome_notes=body.get("outcome_notes"),
seasoning_score=snapshot.seasoning_score,
richness_score=snapshot.richness_score,
brightness_score=snapshot.brightness_score,
depth_score=snapshot.depth_score,
aroma_score=snapshot.aroma_score,
structure_score=snapshot.structure_score,
texture_profile=snapshot.texture_profile,
dietary_tags=list(snapshot.dietary_tags),
allergen_flags=list(snapshot.allergen_flags),
flavor_molecules=list(snapshot.flavor_molecules),
fat_pct=snapshot.fat_pct,
protein_pct=snapshot.protein_pct,
moisture_pct=snapshot.moisture_pct,
)
try:
inserted = await asyncio.to_thread(store.insert_post, post)
except sqlite3.IntegrityError as exc:
raise HTTPException(
status_code=409,
detail="A post with this title already exists today. Try a different title.",
) from exc
return _post_to_dict(inserted)
@router.delete("/posts/{slug}", status_code=204)
async def delete_post(slug: str, session: CloudUser = Depends(get_session)):
store = _get_community_store()
if store is None:
raise HTTPException(status_code=503, detail="Community DB not available.")
def _get_pseudonym():
s = Store(session.db)
try:
return s.get_current_pseudonym(session.user_id)
finally:
s.close()
pseudonym = await asyncio.to_thread(_get_pseudonym)
if not pseudonym:
raise HTTPException(status_code=400, detail="No pseudonym set. Cannot delete posts.")
deleted = await asyncio.to_thread(store.delete_post, slug=slug, pseudonym=pseudonym)
if not deleted:
raise HTTPException(status_code=404, detail="Post not found or you are not the author.")
@router.post("/posts/{slug}/fork", status_code=201)
async def fork_post(slug: str, session: CloudUser = Depends(get_session)):
store = _get_community_store()
if store is None:
raise HTTPException(status_code=503, detail="Community DB not available.")
post = await asyncio.to_thread(store.get_post_by_slug, slug)
if post is None:
raise HTTPException(status_code=404, detail="Post not found.")
if post.post_type != "plan":
raise HTTPException(status_code=400, detail="Only plan posts can be forked as a meal plan.")
required_slot_keys = {"day", "meal_type", "recipe_id"}
if any(not required_slot_keys.issubset(slot) for slot in post.slots):
raise HTTPException(status_code=400, detail="Post contains malformed slots and cannot be forked.")
from datetime import date
week_start = date.today().strftime("%Y-%m-%d")
def _create_plan():
s = Store(session.db)
try:
meal_types = list({slot["meal_type"] for slot in post.slots})
plan = s.create_meal_plan(week_start=week_start, meal_types=meal_types or ["dinner"])
for slot in post.slots:
s.assign_recipe_to_slot(
plan_id=plan["id"],
day_of_week=slot["day"],
meal_type=slot["meal_type"],
recipe_id=slot["recipe_id"],
)
return plan
finally:
s.close()
plan = await asyncio.to_thread(_create_plan)
return {"plan_id": plan["id"], "week_start": plan["week_start"], "forked_from": slug}
@router.post("/posts/{slug}/fork-adapt", status_code=201)
async def fork_adapt_post(slug: str, session: CloudUser = Depends(get_session)):
from app.tiers import can_use
if not can_use("community_fork_adapt", session.tier, session.has_byok):
raise HTTPException(status_code=402, detail="Fork with adaptation requires Paid tier or BYOK.")
# Stub: full LLM adaptation deferred
raise HTTPException(status_code=501, detail="Fork-adapt not yet implemented.")
def _post_to_dict(post) -> dict:
return {
"slug": post.slug,
"pseudonym": post.pseudonym,
"post_type": post.post_type,
"published": post.published.isoformat() if hasattr(post.published, "isoformat") else str(post.published),
"title": post.title,
"description": post.description,
"photo_url": post.photo_url,
"slots": list(post.slots),
"recipe_id": post.recipe_id,
"recipe_name": post.recipe_name,
"level": post.level,
"outcome_notes": post.outcome_notes,
"element_profiles": {
"seasoning_score": post.seasoning_score,
"richness_score": post.richness_score,
"brightness_score": post.brightness_score,
"depth_score": post.depth_score,
"aroma_score": post.aroma_score,
"structure_score": post.structure_score,
"texture_profile": post.texture_profile,
},
"dietary_tags": list(post.dietary_tags),
"allergen_flags": list(post.allergen_flags),
"flavor_molecules": list(post.flavor_molecules),
"fat_pct": post.fat_pct,
"protein_pct": post.protein_pct,
"moisture_pct": post.moisture_pct,
}
def _post_type_prefix(post_type: str) -> str:
return {"plan": "plan", "recipe_success": "success", "recipe_blooper": "blooper"}.get(post_type, "post")

View file

@ -1,9 +1,11 @@
"""Export endpoints — CSV/Excel of receipt and inventory data."""
"""Export endpoints — CSV and JSON export of user data."""
from __future__ import annotations
import asyncio
import csv
import io
import json
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
@ -45,3 +47,33 @@ async def export_inventory_csv(store: Store = Depends(get_store)):
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=inventory.csv"},
)
@router.get("/json")
async def export_full_json(store: Store = Depends(get_store)):
"""Export full pantry inventory + saved recipes as a single JSON file.
Intended for data portability users can import this into another
Kiwi instance or keep it as an offline backup.
"""
inventory, saved = await asyncio.gather(
asyncio.to_thread(store.list_inventory),
asyncio.to_thread(store.get_saved_recipes),
)
export_doc = {
"kiwi_export": {
"version": "1.0",
"exported_at": datetime.now(timezone.utc).isoformat(),
"inventory": [dict(row) for row in inventory],
"saved_recipes": [dict(row) for row in saved],
}
}
body = json.dumps(export_doc, default=str, indent=2)
filename = f"kiwi-export-{datetime.now(timezone.utc).strftime('%Y%m%d')}.json"
return StreamingResponse(
iter([body]),
media_type="application/json",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)

View file

@ -0,0 +1,103 @@
"""Screenshot attachment endpoint for in-app feedback.
After the cf-core feedback router creates a Forgejo issue, the frontend
can call POST /feedback/attach to upload a screenshot and pin it as a
comment on that issue.
The endpoint is separate from the cf-core router so Kiwi owns it
without modifying shared infrastructure.
"""
from __future__ import annotations
import base64
import os
import requests
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
router = APIRouter()
_FORGEJO_BASE = os.environ.get(
"FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1"
)
_REPO = "Circuit-Forge/kiwi"
_MAX_BYTES = 5 * 1024 * 1024 # 5 MB
class AttachRequest(BaseModel):
issue_number: int
filename: str = Field(default="screenshot.png", max_length=80)
image_b64: str # data URI or raw base64
class AttachResponse(BaseModel):
comment_url: str
def _forgejo_headers() -> dict[str, str]:
token = os.environ.get("FORGEJO_API_TOKEN", "")
return {"Authorization": f"token {token}"}
def _decode_image(image_b64: str) -> tuple[bytes, str]:
"""Return (raw_bytes, mime_type) from a base64 string or data URI."""
if image_b64.startswith("data:"):
header, _, data = image_b64.partition(",")
mime = header.split(";")[0].split(":")[1] if ":" in header else "image/png"
else:
data = image_b64
mime = "image/png"
return base64.b64decode(data), mime
@router.post("/attach", response_model=AttachResponse)
def attach_screenshot(payload: AttachRequest) -> AttachResponse:
"""Upload a screenshot to a Forgejo issue as a comment with embedded image.
The image is uploaded as an issue asset, then referenced in a comment
so it is visible inline when the issue is viewed.
"""
token = os.environ.get("FORGEJO_API_TOKEN", "")
if not token:
raise HTTPException(status_code=503, detail="Feedback not configured.")
raw_bytes, mime = _decode_image(payload.image_b64)
if len(raw_bytes) > _MAX_BYTES:
raise HTTPException(
status_code=413,
detail=f"Screenshot exceeds 5 MB limit ({len(raw_bytes) // 1024} KB received).",
)
# Upload image as issue asset
asset_resp = requests.post(
f"{_FORGEJO_BASE}/repos/{_REPO}/issues/{payload.issue_number}/assets",
headers=_forgejo_headers(),
files={"attachment": (payload.filename, raw_bytes, mime)},
timeout=20,
)
if not asset_resp.ok:
raise HTTPException(
status_code=502,
detail=f"Forgejo asset upload failed: {asset_resp.text[:200]}",
)
asset_url = asset_resp.json().get("browser_download_url", "")
# Pin as a comment so the image is visible inline
comment_body = f"**Screenshot attached by reporter:**\n\n![screenshot]({asset_url})"
comment_resp = requests.post(
f"{_FORGEJO_BASE}/repos/{_REPO}/issues/{payload.issue_number}/comments",
headers={**_forgejo_headers(), "Content-Type": "application/json"},
json={"body": comment_body},
timeout=15,
)
if not comment_resp.ok:
raise HTTPException(
status_code=502,
detail=f"Forgejo comment failed: {comment_resp.text[:200]}",
)
comment_url = comment_resp.json().get("html_url", "")
return AttachResponse(comment_url=comment_url)

View file

@ -128,15 +128,18 @@ async def household_status(session: CloudUser = Depends(_require_premium)):
@router.post("/invite", response_model=HouseholdInviteResponse)
async def create_invite(session: CloudUser = Depends(_require_household_owner)):
"""Generate a one-time invite token valid for 7 days."""
store = Store(session.db)
token = secrets.token_hex(32)
expires_at = (datetime.now(timezone.utc) + timedelta(days=_INVITE_TTL_DAYS)).isoformat()
store.conn.execute(
"""INSERT INTO household_invites (token, household_id, created_by, expires_at)
VALUES (?, ?, ?, ?)""",
(token, session.household_id, session.user_id, expires_at),
)
store.conn.commit()
store = Store(session.db)
try:
store.conn.execute(
"""INSERT INTO household_invites (token, household_id, created_by, expires_at)
VALUES (?, ?, ?, ?)""",
(token, session.household_id, session.user_id, expires_at),
)
store.conn.commit()
finally:
store.close()
invite_url = f"{_KIWI_BASE_URL}/#/join?household_id={session.household_id}&token={token}"
return HouseholdInviteResponse(token=token, invite_url=invite_url, expires_at=expires_at)
@ -152,24 +155,27 @@ async def accept_invite(
hh_store = _household_store(body.household_id)
now = datetime.now(timezone.utc).isoformat()
row = hh_store.conn.execute(
"""SELECT token, expires_at, used_at FROM household_invites
WHERE token = ? AND household_id = ?""",
(body.token, body.household_id),
).fetchone()
try:
row = hh_store.conn.execute(
"""SELECT token, expires_at, used_at FROM household_invites
WHERE token = ? AND household_id = ?""",
(body.token, body.household_id),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Invite not found.")
if row["used_at"] is not None:
raise HTTPException(status_code=410, detail="Invite already used.")
if row["expires_at"] < now:
raise HTTPException(status_code=410, detail="Invite has expired.")
if not row:
raise HTTPException(status_code=404, detail="Invite not found.")
if row["used_at"] is not None:
raise HTTPException(status_code=410, detail="Invite already used.")
if row["expires_at"] < now:
raise HTTPException(status_code=410, detail="Invite has expired.")
hh_store.conn.execute(
"UPDATE household_invites SET used_at = ?, used_by = ? WHERE token = ?",
(now, session.user_id, body.token),
)
hh_store.conn.commit()
hh_store.conn.execute(
"UPDATE household_invites SET used_at = ?, used_by = ? WHERE token = ?",
(now, session.user_id, body.token),
)
hh_store.conn.commit()
finally:
hh_store.close()
_heimdall_post("/admin/household/add-member", {
"household_id": body.household_id,

View file

@ -0,0 +1,185 @@
"""Kiwi — /api/v1/imitate/samples endpoint for Avocet Imitate tab.
Returns the actual assembled prompt Kiwi sends to its LLM for recipe generation,
including the full pantry context (expiry-first ordering), dietary constraints
(from user_settings if present), and the Level 3 format instructions.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.cloud_session import get_session, CloudUser
from app.db.store import Store
router = APIRouter()
_LEVEL3_FORMAT = [
"",
"Reply using EXACTLY this plain-text format — no markdown, no bold, no extra commentary:",
"Title: <name of the dish>",
"Ingredients: <comma-separated list>",
"Directions:",
"1. <first step>",
"2. <second step>",
"3. <continue for each step>",
"Notes: <optional tips>",
]
_LEVEL4_FORMAT = [
"",
"Reply using EXACTLY this plain-text format — no markdown, no bold:",
"Title: <name of the dish>",
"Ingredients: <comma-separated list>",
"Directions:",
"1. <first step>",
"2. <second step>",
"Notes: <optional tips>",
]
def _read_user_settings(store: Store) -> dict:
"""Read all key/value pairs from user_settings table."""
try:
rows = store.conn.execute("SELECT key, value FROM user_settings").fetchall()
return {r["key"]: r["value"] for r in rows}
except Exception:
return {}
def _build_recipe_prompt(
pantry_names: list[str],
expiring_names: list[str],
constraints: list[str],
allergies: list[str],
level: int = 3,
) -> str:
"""Assemble the recipe generation prompt matching Kiwi's Level 3/4 format."""
# Expiring items first, then remaining pantry items (deduped)
expiring_set = set(expiring_names)
ordered = list(expiring_names) + [n for n in pantry_names if n not in expiring_set]
if not ordered:
ordered = pantry_names
if level == 4:
lines = [
"Surprise me with a creative, unexpected recipe.",
"Only use ingredients that make culinary sense together. "
"Do not force flavoured/sweetened items (vanilla yoghurt, flavoured syrups, jam) into savoury dishes.",
f"Ingredients available: {', '.join(ordered)}",
]
if constraints:
lines.append(f"Constraints: {', '.join(constraints)}")
if allergies:
lines.append(f"Must NOT contain: {', '.join(allergies)}")
lines.append("Treat any mystery ingredient as a wildcard — use your imagination.")
lines += _LEVEL4_FORMAT
else:
lines = [
"You are a creative chef. Generate a recipe using the ingredients below.",
"IMPORTANT: When you use a pantry item, list it in Ingredients using its exact name "
"from the pantry list. Do not add adjectives, quantities, or cooking states "
"(e.g. use 'butter', not 'unsalted butter' or '2 tbsp butter').",
"IMPORTANT: Only use pantry items that make culinary sense for the dish. "
"Do NOT force flavoured/sweetened items (vanilla yoghurt, fruit yoghurt, jam, "
"dessert sauces, flavoured syrups) into savoury dishes.",
"IMPORTANT: Do not default to the same ingredient repeatedly across dishes. "
"If a pantry item does not genuinely improve this specific dish, leave it out.",
"",
f"Pantry items: {', '.join(ordered)}",
]
if expiring_names:
lines.append(
f"Priority — use these soon (expiring): {', '.join(expiring_names)}"
)
if constraints:
lines.append(f"Dietary constraints: {', '.join(constraints)}")
if allergies:
lines.append(f"IMPORTANT — must NOT contain: {', '.join(allergies)}")
lines += _LEVEL3_FORMAT
return "\n".join(lines)
@router.get("/samples")
async def imitate_samples(
limit: int = 5,
level: int = 3,
session: CloudUser = Depends(get_session),
):
"""Return assembled recipe generation prompts for Avocet's Imitate tab.
Each sample includes:
system_prompt empty (Kiwi uses no system context)
input_text full Level 3/4 prompt with pantry items, expiring items,
dietary constraints, and format instructions
output_text empty (no prior LLM output stored per-request)
level: 3 (structured with element biasing context) or 4 (wildcard creative)
limit: max number of distinct prompt variants to return (varies by pantry state)
"""
limit = max(1, min(limit, 10))
store = Store(session.db)
# Full pantry for context
all_items = store.list_inventory()
pantry_names = [r["product_name"] for r in all_items if r.get("product_name")]
# Expiring items as priority ingredients
expiring = store.expiring_soon(days=14)
expiring_names = [r["product_name"] for r in expiring if r.get("product_name")]
# Dietary constraints from user_settings (keys: constraints, allergies)
settings = _read_user_settings(store)
import json as _json
try:
constraints = _json.loads(settings.get("dietary_constraints", "[]")) or []
except Exception:
constraints = []
try:
allergies = _json.loads(settings.get("dietary_allergies", "[]")) or []
except Exception:
allergies = []
if not pantry_names:
return {"samples": [], "total": 0, "type": f"recipe_level{level}"}
# Build prompt variants: one per expiring item as the "anchor" ingredient,
# plus one general pantry prompt. Cap at limit.
samples = []
seen_anchors: set[str] = set()
for item in (expiring[:limit - 1] if expiring else []):
anchor = item.get("product_name", "")
if not anchor or anchor in seen_anchors:
continue
seen_anchors.add(anchor)
# Put this item first in the list for the prompt
ordered_expiring = [anchor] + [n for n in expiring_names if n != anchor]
prompt = _build_recipe_prompt(pantry_names, ordered_expiring, constraints, allergies, level)
samples.append({
"id": item.get("id", 0),
"anchor_item": anchor,
"expiring_count": len(expiring_names),
"pantry_count": len(pantry_names),
"system_prompt": "",
"input_text": prompt,
"output_text": "",
})
# One general prompt using all expiring as priority
if len(samples) < limit:
prompt = _build_recipe_prompt(pantry_names, expiring_names, constraints, allergies, level)
samples.append({
"id": 0,
"anchor_item": "full pantry",
"expiring_count": len(expiring_names),
"pantry_count": len(pantry_names),
"system_prompt": "",
"input_text": prompt,
"output_text": "",
})
return {"samples": samples, "total": len(samples), "type": f"recipe_level{level}"}

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
import logging
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional
@ -11,18 +12,25 @@ import aiofiles
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from pydantic import BaseModel
from app.cloud_session import CloudUser, get_session
from app.cloud_session import CloudUser, _auth_label, get_session
log = logging.getLogger(__name__)
from app.db.session import get_store
from app.services.expiration_predictor import ExpirationPredictor
_predictor = ExpirationPredictor()
from app.db.store import Store
from app.models.schemas.inventory import (
BarcodeScanResponse,
BulkAddByNameRequest,
BulkAddByNameResponse,
BulkAddItemResult,
DiscardRequest,
InventoryItemCreate,
InventoryItemResponse,
InventoryItemUpdate,
InventoryStats,
PartialConsumeRequest,
ProductCreate,
ProductResponse,
ProductUpdate,
@ -33,6 +41,34 @@ from app.models.schemas.inventory import (
router = APIRouter()
# ── Helpers ───────────────────────────────────────────────────────────────────
def _enrich_item(item: dict) -> dict:
"""Attach computed fields: opened_expiry_date, secondary_state/uses/warning."""
from datetime import date, timedelta
opened = item.get("opened_date")
if opened:
days = _predictor.days_after_opening(item.get("category"))
if days is not None:
try:
opened_expiry = date.fromisoformat(opened) + timedelta(days=days)
item = {**item, "opened_expiry_date": str(opened_expiry)}
except ValueError:
pass
if "opened_expiry_date" not in item:
item = {**item, "opened_expiry_date": None}
# Secondary use window — check sell-by date (not opened expiry)
sec = _predictor.secondary_state(item.get("category"), item.get("expiration_date"))
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,
}
return item
# ── Products ──────────────────────────────────────────────────────────────────
@router.post("/products", response_model=ProductResponse, status_code=status.HTTP_201_CREATED)
@ -117,7 +153,12 @@ async def delete_product(product_id: int, store: Store = Depends(get_store)):
# ── Inventory items ───────────────────────────────────────────────────────────
@router.post("/items", response_model=InventoryItemResponse, status_code=status.HTTP_201_CREATED)
async def create_inventory_item(body: InventoryItemCreate, store: Store = Depends(get_store)):
async def create_inventory_item(
body: InventoryItemCreate,
store: Store = Depends(get_store),
session: CloudUser = Depends(get_session),
):
log.info("add_item auth=%s tier=%s product_id=%s", _auth_label(session.user_id), session.tier, body.product_id)
item = await asyncio.to_thread(
store.add_inventory_item,
body.product_id,
@ -130,7 +171,10 @@ async def create_inventory_item(body: InventoryItemCreate, store: Store = Depend
notes=body.notes,
source=body.source,
)
return InventoryItemResponse.model_validate(item)
# RETURNING * omits joined columns (product_name, barcode, category).
# Re-fetch with the products JOIN so the response is fully populated (#99).
full_item = await asyncio.to_thread(store.get_inventory_item, item["id"])
return InventoryItemResponse.model_validate(full_item)
@router.post("/items/bulk-add-by-name", response_model=BulkAddByNameResponse)
@ -143,7 +187,7 @@ async def bulk_add_items_by_name(body: BulkAddByNameRequest, store: Store = Depe
for entry in body.items:
try:
product, _ = await asyncio.to_thread(
store.get_or_create_product, entry.name, None, source="shopping"
store.get_or_create_product, entry.name, None, source="manual"
)
item = await asyncio.to_thread(
store.add_inventory_item,
@ -151,7 +195,7 @@ async def bulk_add_items_by_name(body: BulkAddByNameRequest, store: Store = Depe
entry.location,
quantity=entry.quantity,
unit=entry.unit,
source="shopping",
source="manual",
)
results.append(BulkAddItemResult(name=entry.name, ok=True, item_id=item["id"]))
except Exception as exc:
@ -168,13 +212,13 @@ 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(i) for i in items]
return [InventoryItemResponse.model_validate(_enrich_item(i)) 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(i) for i in items]
return [InventoryItemResponse.model_validate(_enrich_item(i)) for i in items]
@router.get("/items/{item_id}", response_model=InventoryItemResponse)
@ -182,7 +226,7 @@ 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(item)
return InventoryItemResponse.model_validate(_enrich_item(item))
@router.patch("/items/{item_id}", response_model=InventoryItemResponse)
@ -194,24 +238,79 @@ async def update_inventory_item(
updates["purchase_date"] = str(updates["purchase_date"])
if "expiration_date" in updates and updates["expiration_date"]:
updates["expiration_date"] = str(updates["expiration_date"])
if "opened_date" in updates and updates["opened_date"]:
updates["opened_date"] = str(updates["opened_date"])
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(item)
return InventoryItemResponse.model_validate(_enrich_item(item))
@router.post("/items/{item_id}/open", response_model=InventoryItemResponse)
async def mark_item_opened(item_id: int, store: Store = Depends(get_store)):
"""Record that this item was opened today, triggering secondary shelf-life tracking."""
from datetime import date
item = await asyncio.to_thread(
store.update_inventory_item,
item_id,
opened_date=str(date.today()),
)
if not item:
raise HTTPException(status_code=404, detail="Inventory item not found")
return InventoryItemResponse.model_validate(_enrich_item(item))
@router.post("/items/{item_id}/consume", response_model=InventoryItemResponse)
async def consume_item(item_id: int, store: Store = Depends(get_store)):
async def consume_item(
item_id: int,
body: Optional[PartialConsumeRequest] = None,
store: Store = Depends(get_store),
):
"""Consume an inventory item fully or partially.
When body.quantity is provided, decrements by that amount and only marks
status=consumed when quantity reaches zero. Omit body to consume all.
"""
from datetime import datetime, timezone
now = datetime.now(timezone.utc).isoformat()
if body is not None:
item = await asyncio.to_thread(
store.partial_consume_item, item_id, body.quantity, now
)
else:
item = await asyncio.to_thread(
store.update_inventory_item,
item_id,
status="consumed",
consumed_at=now,
)
if not item:
raise HTTPException(status_code=404, detail="Inventory item not found")
return InventoryItemResponse.model_validate(_enrich_item(item))
@router.post("/items/{item_id}/discard", response_model=InventoryItemResponse)
async def discard_item(
item_id: int,
body: DiscardRequest = DiscardRequest(),
store: Store = Depends(get_store),
):
"""Mark an item as discarded (not used, spoiled, etc).
Optional reason field accepts free text or a preset label
('not used', 'spoiled', 'excess', 'other').
"""
from datetime import datetime, timezone
item = await asyncio.to_thread(
store.update_inventory_item,
item_id,
status="consumed",
status="discarded",
consumed_at=datetime.now(timezone.utc).isoformat(),
disposal_reason=body.reason,
)
if not item:
raise HTTPException(status_code=404, detail="Inventory item not found")
return InventoryItemResponse.model_validate(item)
return InventoryItemResponse.model_validate(_enrich_item(item))
@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
@ -241,6 +340,7 @@ async def scan_barcode_text(
session: CloudUser = Depends(get_session),
):
"""Scan a barcode from a text string (e.g. from a hardware scanner or manual entry)."""
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
@ -267,10 +367,14 @@ async def scan_barcode_text(
tier=session.tier,
has_byok=session.has_byok,
)
# Use OFFs pack size when detected; caller-supplied quantity is a fallback
resolved_qty = product_info.get("pack_quantity") or body.quantity
resolved_unit = product_info.get("pack_unit") or "count"
inventory_item = await asyncio.to_thread(
store.add_inventory_item,
product["id"], body.location,
quantity=body.quantity,
quantity=resolved_qty,
unit=resolved_unit,
expiration_date=str(exp) if exp else None,
source="barcode_scan",
)
@ -278,6 +382,7 @@ async def scan_barcode_text(
else:
result_product = None
product_found = product_info is not None
return BarcodeScanResponse(
success=True,
barcodes_found=1,
@ -287,7 +392,8 @@ 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,
"message": "Added to inventory" if inventory_item else "Product not found in database",
"needs_manual_entry": not product_found,
"message": "Added to inventory" if inventory_item else "Not found in any product database — add manually",
}],
message="Barcode processed",
)
@ -303,6 +409,7 @@ async def scan_barcode_image(
session: CloudUser = Depends(get_session),
):
"""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)
temp_dir = Path("/tmp/kiwi_barcode_scans")
temp_dir.mkdir(parents=True, exist_ok=True)
temp_file = temp_dir / f"{uuid.uuid4()}_{file.filename}"
@ -345,10 +452,13 @@ async def scan_barcode_image(
tier=session.tier,
has_byok=session.has_byok,
)
resolved_qty = product_info.get("pack_quantity") or quantity
resolved_unit = product_info.get("pack_unit") or "count"
inventory_item = await asyncio.to_thread(
store.add_inventory_item,
product["id"], location,
quantity=quantity,
quantity=resolved_qty,
unit=resolved_unit,
expiration_date=str(exp) if exp else None,
source="barcode_scan",
)

View file

@ -19,6 +19,7 @@ from app.models.schemas.meal_plan import (
PrepTaskSummary,
ShoppingListResponse,
SlotSummary,
UpdatePlanRequest,
UpdatePrepTaskRequest,
UpsertSlotRequest,
VALID_MEAL_TYPES,
@ -81,13 +82,21 @@ async def create_plan(
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> PlanSummary:
import sqlite3
# Free tier is locked to dinner-only; paid+ may configure meal types
if can_use("meal_plan_config", session.tier):
meal_types = [t for t in req.meal_types if t in VALID_MEAL_TYPES] or ["dinner"]
else:
meal_types = ["dinner"]
plan = await asyncio.to_thread(store.create_meal_plan, str(req.week_start), meal_types)
try:
plan = await asyncio.to_thread(store.create_meal_plan, str(req.week_start), meal_types)
except sqlite3.IntegrityError:
raise HTTPException(
status_code=409,
detail=f"A meal plan for the week of {req.week_start} already exists.",
)
slots = await asyncio.to_thread(store.get_plan_slots, plan["id"])
return _plan_summary(plan, slots)
@ -105,6 +114,28 @@ async def list_plans(
return result
@router.patch("/{plan_id}", response_model=PlanSummary)
async def update_plan(
plan_id: int,
req: UpdatePlanRequest,
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> PlanSummary:
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
if plan is None:
raise HTTPException(status_code=404, detail="Plan not found.")
# Free tier stays dinner-only; paid+ may add meal types
if can_use("meal_plan_config", session.tier):
meal_types = [t for t in req.meal_types if t in VALID_MEAL_TYPES] or ["dinner"]
else:
meal_types = ["dinner"]
updated = await asyncio.to_thread(store.update_meal_plan_types, plan_id, meal_types)
if updated is None:
raise HTTPException(status_code=404, detail="Plan not found.")
slots = await asyncio.to_thread(store.get_plan_slots, plan_id)
return _plan_summary(updated, slots)
@router.get("/{plan_id}", response_model=PlanSummary)
async def get_plan(
plan_id: int,

View file

@ -219,7 +219,7 @@ def _commit_items(
receipt_id=receipt_id,
purchase_date=str(purchase_date) if purchase_date else None,
expiration_date=str(exp) if exp else None,
source="receipt_ocr",
source="receipt",
)
created.append(ApprovedInventoryItem(

View file

@ -0,0 +1,27 @@
"""Proxy endpoint: exposes cf-orch call budget to the Kiwi frontend.
Only lifetime/founders users have a license_key subscription and free
users receive null (no budget UI shown).
"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.cloud_session import CloudUser, get_session
from app.services.heimdall_orch import get_orch_usage
router = APIRouter()
@router.get("")
async def orch_usage_endpoint(
session: CloudUser = Depends(get_session),
) -> dict | None:
"""Return the current period's orch usage for the authenticated user.
Returns null if the user has no lifetime/founders license key (i.e. they
are on a subscription or free plan no budget cap applies to them).
"""
if session.license_key is None:
return None
return get_orch_usage(session.license_key, "kiwi")

View file

@ -42,9 +42,11 @@ async def upload_receipt(
)
# Only queue OCR if the feature is enabled server-side AND the user's tier allows it.
# Check tier here, not inside the background task — once dispatched it can't be cancelled.
# Pass session.db (a Path) rather than store — the store dependency closes before
# background tasks run, so the task opens its own store from the DB path.
ocr_allowed = settings.ENABLE_OCR and can_use("receipt_ocr", session.tier, session.has_byok)
if ocr_allowed:
background_tasks.add_task(_process_receipt_ocr, receipt["id"], saved, store)
background_tasks.add_task(_process_receipt_ocr, receipt["id"], saved, session.db)
return ReceiptResponse.model_validate(receipt)
@ -64,7 +66,7 @@ async def upload_receipts_batch(
store.create_receipt, file.filename, str(saved)
)
if ocr_allowed:
background_tasks.add_task(_process_receipt_ocr, receipt["id"], saved, store)
background_tasks.add_task(_process_receipt_ocr, receipt["id"], saved, session.db)
results.append(ReceiptResponse.model_validate(receipt))
return results
@ -97,8 +99,13 @@ async def get_receipt_quality(receipt_id: int, store: Store = Depends(get_store)
return QualityAssessment.model_validate(qa)
async def _process_receipt_ocr(receipt_id: int, image_path: Path, store: Store) -> None:
"""Background task: run OCR pipeline on an uploaded receipt."""
async def _process_receipt_ocr(receipt_id: int, image_path: Path, db_path: Path) -> None:
"""Background task: run OCR pipeline on an uploaded receipt.
Accepts db_path (not a Store instance) because FastAPI closes the request-scoped
store before background tasks execute. This task owns its store lifecycle.
"""
store = Store(db_path)
try:
await asyncio.to_thread(store.update_receipt_status, receipt_id, "processing")
from app.services.receipt_service import ReceiptService
@ -108,3 +115,5 @@ async def _process_receipt_ocr(receipt_id: int, image_path: Path, store: Store)
await asyncio.to_thread(
store.update_receipt_status, receipt_id, "error", str(exc)
)
finally:
store.close()

View file

@ -2,21 +2,42 @@
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
from app.cloud_session import CloudUser, get_session
from app.cloud_session import CloudUser, _auth_label, get_session
log = logging.getLogger(__name__)
from app.db.session import get_store
from app.db.store import Store
from app.models.schemas.recipe import RecipeRequest, RecipeResult
from app.models.schemas.recipe import (
AssemblyTemplateOut,
BuildRequest,
RecipeJobStatus,
RecipeRequest,
RecipeResult,
RecipeSuggestion,
RoleCandidatesResponse,
)
from app.services.recipe.assembly_recipes import (
build_from_selection,
get_role_candidates,
get_templates_for_api,
)
from app.services.recipe.browser_domains import (
DOMAINS,
category_has_subcategories,
get_category_names,
get_domain_labels,
get_keywords_for_category,
get_keywords_for_subcategory,
get_subcategory_names,
)
from app.services.recipe.recipe_engine import RecipeEngine
from app.services.heimdall_orch import check_orch_budget
from app.tiers import can_use
router = APIRouter()
@ -37,13 +58,55 @@ def _suggest_in_thread(db_path: Path, req: RecipeRequest) -> RecipeResult:
store.close()
@router.post("/suggest", response_model=RecipeResult)
async def _enqueue_recipe_job(session: CloudUser, req: RecipeRequest):
"""Queue an async recipe_llm job and return 202 with job_id.
Falls back to synchronous generation in CLOUD_MODE (scheduler polls only
the shared settings DB, not per-user DBs see snipe#45 / kiwi backlog).
"""
import json
import uuid
from fastapi.responses import JSONResponse
from app.cloud_session import CLOUD_MODE
from app.tasks.runner import insert_task
if CLOUD_MODE:
log.warning("recipe_llm async jobs not supported in CLOUD_MODE — falling back to sync")
result = await asyncio.to_thread(_suggest_in_thread, session.db, req)
return result
job_id = f"rec_{uuid.uuid4().hex}"
def _create(db_path: Path) -> int:
store = Store(db_path)
try:
row = store.create_recipe_job(job_id, session.user_id, req.model_dump_json())
return row["id"]
finally:
store.close()
int_id = await asyncio.to_thread(_create, session.db)
params_json = json.dumps({"job_id": job_id})
task_id, is_new = insert_task(session.db, "recipe_llm", int_id, params=params_json)
if is_new:
from app.tasks.scheduler import get_scheduler
get_scheduler(session.db).enqueue(task_id, "recipe_llm", int_id, params_json)
return JSONResponse(content={"job_id": job_id, "status": "queued"}, status_code=202)
@router.post("/suggest")
async def suggest_recipes(
req: RecipeRequest,
async_mode: bool = Query(default=False, alias="async"),
session: CloudUser = Depends(get_session),
) -> RecipeResult:
store: Store = Depends(get_store),
):
log.info("recipes auth=%s tier=%s level=%s", _auth_label(session.user_id), session.tier, req.level)
# Inject session-authoritative tier/byok immediately — client-supplied values are ignored.
req = req.model_copy(update={"tier": session.tier, "has_byok": session.has_byok})
# Also read stored unit_system preference; default to metric if not set.
unit_system = store.get_setting("unit_system") or "metric"
req = req.model_copy(update={"tier": session.tier, "has_byok": session.has_byok, "unit_system": unit_system})
if req.level == 4 and not req.wildcard_confirmed:
raise HTTPException(
status_code=400,
@ -56,7 +119,62 @@ async def suggest_recipes(
)
if req.style_id and not can_use("style_picker", req.tier):
raise HTTPException(status_code=403, detail="Style picker requires Paid tier.")
return await asyncio.to_thread(_suggest_in_thread, session.db, req)
# Orch budget check for lifetime/founders keys — downgrade to L2 (local) if exhausted.
# Subscription and local/BYOK users skip this check entirely.
orch_fallback = False
if (
req.level in (3, 4)
and session.license_key is not None
and not session.has_byok
and session.tier != "local"
):
budget = check_orch_budget(session.license_key, "kiwi")
if not budget.get("allowed", True):
req = req.model_copy(update={"level": 2})
orch_fallback = True
if req.level in (3, 4) and async_mode:
return await _enqueue_recipe_job(session, req)
result = await asyncio.to_thread(_suggest_in_thread, session.db, req)
if orch_fallback:
result = result.model_copy(update={"orch_fallback": True})
return result
@router.get("/jobs/{job_id}", response_model=RecipeJobStatus)
async def get_recipe_job_status(
job_id: str,
session: CloudUser = Depends(get_session),
) -> RecipeJobStatus:
"""Poll the status of an async recipe generation job.
Returns 404 when job_id is unknown or belongs to a different user.
On status='done' with suggestions=[], the LLM returned empty client
should show a 'no recipe generated, try again' message.
"""
def _get(db_path: Path) -> dict | None:
store = Store(db_path)
try:
return store.get_recipe_job(job_id, session.user_id)
finally:
store.close()
row = await asyncio.to_thread(_get, session.db)
if row is None:
raise HTTPException(status_code=404, detail="Job not found.")
result = None
if row["status"] == "done" and row["result"]:
result = RecipeResult.model_validate_json(row["result"])
return RecipeJobStatus(
job_id=row["job_id"],
status=row["status"],
result=result,
error=row["error"],
)
@router.get("/browse/domains")
@ -76,15 +194,42 @@ async def list_browse_categories(
if domain not in DOMAINS:
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
keywords_by_category = {
cat: get_keywords_for_category(domain, cat)
for cat in get_category_names(domain)
cat_names = get_category_names(domain)
keywords_by_category = {cat: get_keywords_for_category(domain, cat) for cat in cat_names}
has_subs = {cat: category_has_subcategories(domain, cat) for cat in cat_names}
def _get(db_path: Path) -> list[dict]:
store = Store(db_path)
try:
return store.get_browser_categories(domain, keywords_by_category, has_subs)
finally:
store.close()
return await asyncio.to_thread(_get, session.db)
@router.get("/browse/{domain}/{category}/subcategories")
async def list_browse_subcategories(
domain: str,
category: str,
session: CloudUser = Depends(get_session),
) -> list[dict]:
"""Return [{subcategory, recipe_count}] for a category that supports subcategories."""
if domain not in DOMAINS:
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
if not category_has_subcategories(domain, category):
return []
subcat_names = get_subcategory_names(domain, category)
keywords_by_subcat = {
sub: get_keywords_for_subcategory(domain, category, sub)
for sub in subcat_names
}
def _get(db_path: Path) -> list[dict]:
store = Store(db_path)
try:
return store.get_browser_categories(domain, keywords_by_category)
return store.get_browser_subcategories(domain, keywords_by_subcat)
finally:
store.close()
@ -98,22 +243,36 @@ async def browse_recipes(
page: Annotated[int, Query(ge=1)] = 1,
page_size: Annotated[int, Query(ge=1, le=100)] = 20,
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",
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 on each result.
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).
"""
if domain not in DOMAINS:
raise HTTPException(status_code=404, detail=f"Unknown domain '{domain}'.")
keywords = get_keywords_for_category(domain, category)
if not keywords:
raise HTTPException(
status_code=404,
detail=f"Unknown category '{category}' in domain '{domain}'.",
)
if category == "_all":
keywords = None # unfiltered browse
elif subcategory:
keywords = get_keywords_for_subcategory(domain, category, subcategory)
if not keywords:
raise HTTPException(
status_code=404,
detail=f"Unknown subcategory '{subcategory}' in '{category}'.",
)
else:
keywords = get_keywords_for_category(domain, category)
if not keywords:
raise HTTPException(
status_code=404,
detail=f"Unknown category '{category}' in domain '{domain}'.",
)
pantry_list = (
[p.strip() for p in pantry_items.split(",") if p.strip()]
@ -129,6 +288,8 @@ async def browse_recipes(
page=page,
page_size=page_size,
pantry_items=pantry_list,
q=q or None,
sort=sort,
)
store.log_browser_telemetry(
domain=domain,
@ -143,6 +304,96 @@ async def browse_recipes(
return await asyncio.to_thread(_browse, session.db)
@router.get("/templates", response_model=list[AssemblyTemplateOut])
async def list_assembly_templates() -> list[dict]:
"""Return all 13 assembly templates with ordered role sequences.
Cache-friendly: static data, no per-user state.
"""
return get_templates_for_api()
@router.get("/template-candidates", response_model=RoleCandidatesResponse)
async def get_template_role_candidates(
template_id: str = Query(..., description="Template slug, e.g. 'burrito_taco'"),
role: str = Query(..., description="Role display name, e.g. 'protein'"),
prior_picks: str = Query(default="", description="Comma-separated prior selections"),
session: CloudUser = Depends(get_session),
) -> dict:
"""Return pantry-matched candidates for one wizard step."""
def _get(db_path: Path) -> dict:
store = Store(db_path)
try:
items = store.list_inventory(status="available")
pantry_set = {
item["product_name"]
for item in items
if item.get("product_name")
}
pantry_list = list(pantry_set)
prior = [p.strip() for p in prior_picks.split(",") if p.strip()]
profile_index = store.get_element_profiles(pantry_list + prior)
return get_role_candidates(
template_slug=template_id,
role_display=role,
pantry_set=pantry_set,
prior_picks=prior,
profile_index=profile_index,
)
finally:
store.close()
return await asyncio.to_thread(_get, session.db)
@router.post("/build", response_model=RecipeSuggestion)
async def build_recipe(
req: BuildRequest,
session: CloudUser = Depends(get_session),
) -> RecipeSuggestion:
"""Build a recipe from explicit role selections."""
def _build(db_path: Path) -> RecipeSuggestion | None:
store = Store(db_path)
try:
items = store.list_inventory(status="available")
pantry_set = {
item["product_name"]
for item in items
if item.get("product_name")
}
suggestion = build_from_selection(
template_slug=req.template_id,
role_overrides=req.role_overrides,
pantry_set=pantry_set,
)
if suggestion is None:
return None
# Persist to recipes table so the result can be saved/bookmarked.
# external_id encodes template + selections for stable dedup.
import hashlib as _hl, json as _js
sel_hash = _hl.md5(
_js.dumps(req.role_overrides, sort_keys=True).encode()
).hexdigest()[:8]
external_id = f"assembly:{req.template_id}:{sel_hash}"
real_id = store.upsert_built_recipe(
external_id=external_id,
title=suggestion.title,
ingredients=suggestion.matched_ingredients,
directions=suggestion.directions,
)
return suggestion.model_copy(update={"id": real_id})
finally:
store.close()
result = await asyncio.to_thread(_build, session.db)
if result is None:
raise HTTPException(
status_code=404,
detail="Template not found or required ingredient missing.",
)
return result
@router.get("/{recipe_id}")
async def get_recipe(recipe_id: int, session: CloudUser = Depends(get_session)) -> dict:
def _get(db_path: Path, rid: int) -> dict | None:

View file

@ -104,6 +104,8 @@ async def list_saved_recipes(
async def list_collections(
session: CloudUser = Depends(get_session),
) -> list[CollectionSummary]:
if not can_use("recipe_collections", session.tier):
raise HTTPException(status_code=403, detail="Collections require Paid tier.")
rows = await asyncio.to_thread(
_in_thread, session.db, lambda s: s.get_collections()
)

View file

@ -0,0 +1,37 @@
"""Session bootstrap endpoint — called once per app load by the frontend.
Logs auth= + tier= for log-based analytics without client-side tracking.
See Circuit-Forge/kiwi#86.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends
from app.cloud_session import CloudUser, _auth_label, get_session
from app.core.config import settings
router = APIRouter()
log = logging.getLogger(__name__)
@router.get("/bootstrap")
def session_bootstrap(session: CloudUser = Depends(get_session)) -> dict:
"""Record auth type and tier for log-based analytics.
Expected log output:
INFO:app.api.endpoints.session: session auth=authed tier=paid
INFO:app.api.endpoints.session: session auth=anon tier=free
E2E test sessions (E2E_TEST_USER_ID) are logged at DEBUG so they don't
pollute analytics counts while still being visible when DEBUG=true.
"""
is_test = bool(settings.E2E_TEST_USER_ID and session.user_id == settings.E2E_TEST_USER_ID)
logger = log.debug if is_test else log.info
logger("session auth=%s tier=%s%s", _auth_label(session.user_id), session.tier, " e2e=true" if is_test else "")
return {
"auth": _auth_label(session.user_id),
"tier": session.tier,
"has_byok": session.has_byok,
}

View file

@ -10,7 +10,7 @@ from app.db.store import Store
router = APIRouter()
_ALLOWED_KEYS = frozenset({"cooking_equipment"})
_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system"})
class SettingBody(BaseModel):

View file

@ -0,0 +1,224 @@
"""Shopping list endpoints.
Free tier for all users (anonymous guests included shopping list is the
primary affiliate revenue surface). Confirm-purchase action is also Free:
it moves a checked item into pantry inventory without a tier gate so the
flow works for anyone who signs up or browses without an account.
Routes:
GET /shopping list items (with affiliate links)
POST /shopping add item manually
PATCH /shopping/{id} update (check/uncheck, rename, qty)
DELETE /shopping/{id} remove single item
DELETE /shopping/checked clear all checked items
DELETE /shopping/all clear entire list
POST /shopping/from-recipe bulk add gaps from a recipe
POST /shopping/{id}/confirm confirm purchase add to pantry inventory
"""
from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from app.cloud_session import CloudUser, get_session
from app.db.session import get_store
from app.db.store import Store
from app.models.schemas.shopping import (
BulkAddFromRecipeRequest,
ConfirmPurchaseRequest,
ShoppingItemCreate,
ShoppingItemResponse,
ShoppingItemUpdate,
)
from app.services.recipe.grocery_links import GroceryLinkBuilder
log = logging.getLogger(__name__)
router = APIRouter()
def _enrich(item: dict, builder: GroceryLinkBuilder) -> ShoppingItemResponse:
"""Attach live affiliate links to a raw store row."""
links = builder.build_links(item["name"])
return ShoppingItemResponse(
**{**item, "checked": bool(item.get("checked", 0))},
grocery_links=[{"ingredient": l.ingredient, "retailer": l.retailer, "url": l.url} for l in links],
)
def _in_thread(db_path, fn):
store = Store(db_path)
try:
return fn(store)
finally:
store.close()
# ── List ──────────────────────────────────────────────────────────────────────
@router.get("", response_model=list[ShoppingItemResponse])
async def list_shopping_items(
include_checked: bool = True,
session: CloudUser = Depends(get_session),
):
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok)
items = await asyncio.to_thread(
_in_thread, session.db, lambda s: s.list_shopping_items(include_checked)
)
return [_enrich(i, builder) for i in items]
# ── Add manually ──────────────────────────────────────────────────────────────
@router.post("", response_model=ShoppingItemResponse, status_code=status.HTTP_201_CREATED)
async def add_shopping_item(
body: ShoppingItemCreate,
session: CloudUser = Depends(get_session),
):
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok)
item = await asyncio.to_thread(
_in_thread,
session.db,
lambda s: s.add_shopping_item(
name=body.name,
quantity=body.quantity,
unit=body.unit,
category=body.category,
notes=body.notes,
source=body.source,
recipe_id=body.recipe_id,
sort_order=body.sort_order,
),
)
return _enrich(item, builder)
# ── Bulk add from recipe ───────────────────────────────────────────────────────
@router.post("/from-recipe", response_model=list[ShoppingItemResponse], status_code=status.HTTP_201_CREATED)
async def add_from_recipe(
body: BulkAddFromRecipeRequest,
session: CloudUser = Depends(get_session),
):
"""Add missing ingredients from a recipe to the shopping list.
Runs pantry gap analysis and adds only the items the user doesn't have
(unless include_covered=True). Skips duplicates already on the list.
"""
from app.services.meal_plan.shopping_list import compute_shopping_list
def _run(store: Store):
recipe = store.get_recipe(body.recipe_id)
if not recipe:
raise HTTPException(status_code=404, detail="Recipe not found")
inventory = store.list_inventory()
gaps, covered = compute_shopping_list([recipe], inventory)
targets = (gaps + covered) if body.include_covered else gaps
# Avoid duplicates already on the list
existing = {i["name"].lower() for i in store.list_shopping_items()}
added = []
for gap in targets:
if gap.ingredient_name.lower() in existing:
continue
item = store.add_shopping_item(
name=gap.ingredient_name,
quantity=None,
unit=gap.have_unit,
source="recipe",
recipe_id=body.recipe_id,
)
added.append(item)
return added
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok)
items = await asyncio.to_thread(_in_thread, session.db, _run)
return [_enrich(i, builder) for i in items]
# ── Update ────────────────────────────────────────────────────────────────────
@router.patch("/{item_id}", response_model=ShoppingItemResponse)
async def update_shopping_item(
item_id: int,
body: ShoppingItemUpdate,
session: CloudUser = Depends(get_session),
):
builder = GroceryLinkBuilder(tier=session.tier, has_byok=session.has_byok)
item = await asyncio.to_thread(
_in_thread,
session.db,
lambda s: s.update_shopping_item(item_id, **body.model_dump(exclude_none=True)),
)
if not item:
raise HTTPException(status_code=404, detail="Shopping item not found")
return _enrich(item, builder)
# ── Confirm purchase → pantry ─────────────────────────────────────────────────
@router.post("/{item_id}/confirm", status_code=status.HTTP_201_CREATED)
async def confirm_purchase(
item_id: int,
body: ConfirmPurchaseRequest,
session: CloudUser = Depends(get_session),
):
"""Confirm a checked item was purchased and add it to pantry inventory.
Human approval step: the user explicitly confirms what they actually bought
before it lands in their pantry. Returns the new inventory item.
"""
def _run(store: Store):
shopping_item = store.get_shopping_item(item_id)
if not shopping_item:
raise HTTPException(status_code=404, detail="Shopping item not found")
qty = body.quantity if body.quantity is not None else (shopping_item.get("quantity") or 1.0)
unit = body.unit or shopping_item.get("unit") or "count"
category = shopping_item.get("category")
product = store.get_or_create_product(
name=shopping_item["name"],
category=category,
)
inv_item = store.add_inventory_item(
product_id=product["id"],
location=body.location,
quantity=qty,
unit=unit,
source="manual",
)
# Mark the shopping item checked and leave it for the user to clear
store.update_shopping_item(item_id, checked=True)
return inv_item
return await asyncio.to_thread(_in_thread, session.db, _run)
# ── Delete ────────────────────────────────────────────────────────────────────
@router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_shopping_item(
item_id: int,
session: CloudUser = Depends(get_session),
):
deleted = await asyncio.to_thread(
_in_thread, session.db, lambda s: s.delete_shopping_item(item_id)
)
if not deleted:
raise HTTPException(status_code=404, detail="Shopping item not found")
@router.delete("/checked", status_code=status.HTTP_204_NO_CONTENT)
async def clear_checked(session: CloudUser = Depends(get_session)):
await asyncio.to_thread(
_in_thread, session.db, lambda s: s.clear_checked_shopping_items()
)
@router.delete("/all", status_code=status.HTTP_204_NO_CONTENT)
async def clear_all(session: CloudUser = Depends(get_session)):
await asyncio.to_thread(
_in_thread, session.db, lambda s: s.clear_all_shopping_items()
)

View file

@ -1,17 +1,24 @@
from fastapi import APIRouter
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes, meal_plans
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
api_router = APIRouter()
api_router.include_router(health.router, prefix="/health", tags=["health"])
api_router.include_router(receipts.router, prefix="/receipts", tags=["receipts"])
api_router.include_router(ocr.router, prefix="/receipts", tags=["ocr"])
api_router.include_router(export.router, tags=["export"])
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
api_router.include_router(household.router, prefix="/household", tags=["household"])
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=["meal-plans"])
api_router.include_router(session.router, prefix="/session", tags=["session"])
api_router.include_router(health.router, prefix="/health", tags=["health"])
api_router.include_router(receipts.router, prefix="/receipts", tags=["receipts"])
api_router.include_router(ocr.router, prefix="/receipts", tags=["ocr"])
api_router.include_router(export.router, tags=["export"])
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
api_router.include_router(saved_recipes.router, prefix="/recipes/saved", tags=["saved-recipes"])
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])
api_router.include_router(feedback.router, prefix="/feedback", tags=["feedback"])
api_router.include_router(feedback_attach.router, prefix="/feedback", tags=["feedback"])
api_router.include_router(household.router, prefix="/household", tags=["household"])
api_router.include_router(imitate.router, prefix="/imitate", tags=["imitate"])
api_router.include_router(meal_plans.router, prefix="/meal-plans", tags=["meal-plans"])
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)

View file

@ -22,10 +22,12 @@ import time
from dataclasses import dataclass
from pathlib import Path
import uuid
import jwt as pyjwt
import requests
import yaml
from fastapi import Depends, HTTPException, Request
from fastapi import Depends, HTTPException, Request, Response
log = logging.getLogger(__name__)
@ -82,6 +84,15 @@ _TIER_CACHE_TTL = 300 # 5 minutes
TIERS = ["free", "paid", "premium", "ultra"]
def _auth_label(user_id: str) -> str:
"""Classify a user_id into a short tag for structured log lines. No PII emitted."""
if user_id in ("local", "local-dev"):
return "local"
if user_id.startswith("anon-"):
return "anon"
return "authed"
# ── Domain ────────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
@ -92,6 +103,7 @@ class CloudUser:
has_byok: bool # True if a configured LLM backend is present in llm.yaml
household_id: str | None = None
is_household_owner: bool = False
license_key: str | None = None # key_display for lifetime/founders keys; None for subscription/free
# ── JWT validation ─────────────────────────────────────────────────────────────
@ -132,16 +144,16 @@ def _ensure_provisioned(user_id: str) -> None:
log.warning("Heimdall provision failed for user %s: %s", user_id, exc)
def _fetch_cloud_tier(user_id: str) -> tuple[str, str | None, bool]:
"""Returns (tier, household_id | None, is_household_owner)."""
def _fetch_cloud_tier(user_id: str) -> tuple[str, str | None, bool, str | None]:
"""Returns (tier, household_id | None, is_household_owner, license_key | None)."""
now = time.monotonic()
cached = _TIER_CACHE.get(user_id)
if cached and (now - cached[1]) < _TIER_CACHE_TTL:
entry = cached[0]
return entry["tier"], entry.get("household_id"), entry.get("is_household_owner", False)
return entry["tier"], entry.get("household_id"), entry.get("is_household_owner", False), entry.get("license_key")
if not HEIMDALL_ADMIN_TOKEN:
return "free", None, False
return "free", None, False, None
try:
resp = requests.post(
f"{HEIMDALL_URL}/admin/cloud/resolve",
@ -153,12 +165,13 @@ def _fetch_cloud_tier(user_id: str) -> tuple[str, str | None, bool]:
tier = data.get("tier", "free")
household_id = data.get("household_id")
is_owner = data.get("is_household_owner", False)
license_key = data.get("key_display")
except Exception as exc:
log.warning("Heimdall tier resolve failed for user %s: %s", user_id, exc)
tier, household_id, is_owner = "free", None, False
tier, household_id, is_owner, license_key = "free", None, False, None
_TIER_CACHE[user_id] = ({"tier": tier, "household_id": household_id, "is_household_owner": is_owner}, now)
return tier, household_id, is_owner
_TIER_CACHE[user_id] = ({"tier": tier, "household_id": household_id, "is_household_owner": is_owner, "license_key": license_key}, now)
return tier, household_id, is_owner, license_key
def _user_db_path(user_id: str, household_id: str | None = None) -> Path:
@ -170,6 +183,17 @@ def _user_db_path(user_id: str, household_id: str | None = None) -> Path:
return path
def _anon_guest_db_path(guest_id: str) -> Path:
"""Per-session DB for unauthenticated guest visitors.
Each anonymous visitor gets an isolated SQLite DB keyed by their guest UUID
cookie, so shopping lists and affiliate interactions never bleed across sessions.
"""
path = CLOUD_DATA_ROOT / f"anon-{guest_id}" / "kiwi.db"
path.parent.mkdir(parents=True, exist_ok=True)
return path
# ── BYOK detection ────────────────────────────────────────────────────────────
_LLM_CONFIG_PATH = Path.home() / ".config" / "circuitforge" / "llm.yaml"
@ -195,20 +219,52 @@ def _detect_byok(config_path: Path = _LLM_CONFIG_PATH) -> bool:
# ── FastAPI dependency ────────────────────────────────────────────────────────
def get_session(request: Request) -> CloudUser:
_GUEST_COOKIE = "kiwi_guest_id"
_GUEST_COOKIE_MAX_AGE = 60 * 60 * 24 * 90 # 90 days
def _resolve_guest_session(request: Request, response: Response, has_byok: bool) -> CloudUser:
"""Return a per-session anonymous CloudUser, creating a guest UUID cookie if needed."""
guest_id = request.cookies.get(_GUEST_COOKIE, "").strip()
is_new = not guest_id
if is_new:
guest_id = str(uuid.uuid4())
log.debug("New guest session assigned: anon-%s", guest_id[:8])
# Secure flag only when the request actually arrived over HTTPS
# (Caddy sets X-Forwarded-Proto=https in cloud; absent on direct port access).
# Avoids losing the session cookie on HTTP direct-port testing of the cloud stack.
is_https = request.headers.get("x-forwarded-proto", "http").lower() == "https"
response.set_cookie(
key=_GUEST_COOKIE,
value=guest_id,
max_age=_GUEST_COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
secure=is_https,
)
return CloudUser(
user_id=f"anon-{guest_id}",
tier="free",
db=_anon_guest_db_path(guest_id),
has_byok=has_byok,
)
def get_session(request: Request, response: Response) -> CloudUser:
"""FastAPI dependency — resolves the current user from the request.
Local mode: fully-privileged "local" user pointing at local DB.
Cloud mode: validates X-CF-Session JWT, provisions license, resolves tier.
Dev bypass: if CLOUD_AUTH_BYPASS_IPS is set and the client IP matches,
returns a "local" session without JWT validation (dev/LAN use only).
Anonymous: per-session UUID cookie isolates each guest visitor's data.
"""
has_byok = _detect_byok()
if not CLOUD_MODE:
return CloudUser(user_id="local", tier="local", db=_LOCAL_KIWI_DB, has_byok=has_byok)
# Prefer X-Real-IP (set by nginx from the actual client address) over the
# Prefer X-Real-IP (set by Caddy from the actual client address) over the
# TCP peer address (which is nginx's container IP when behind the proxy).
client_ip = (
request.headers.get("x-real-ip", "")
@ -220,20 +276,23 @@ def get_session(request: Request) -> CloudUser:
dev_db = _user_db_path("local-dev")
return CloudUser(user_id="local-dev", tier="local", db=dev_db, has_byok=has_byok)
raw_header = (
request.headers.get("x-cf-session", "")
or request.headers.get("cookie", "")
)
if not raw_header:
raise HTTPException(status_code=401, detail="Not authenticated")
# Resolve cf_session JWT: prefer the explicit header injected by Caddy, then
# fall back to the cf_session cookie value. Other cookies (e.g. kiwi_guest_id)
# must never be treated as auth tokens.
raw_session = request.headers.get("x-cf-session", "").strip()
if not raw_session:
raw_session = request.cookies.get("cf_session", "").strip()
token = _extract_session_token(raw_header) # gitleaks:allow — function name, not a secret
if not raw_session:
return _resolve_guest_session(request, response, has_byok)
token = _extract_session_token(raw_session) # gitleaks:allow — function name, not a secret
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
return _resolve_guest_session(request, response, has_byok)
user_id = validate_session_jwt(token)
_ensure_provisioned(user_id)
tier, household_id, is_household_owner = _fetch_cloud_tier(user_id)
tier, household_id, is_household_owner, license_key = _fetch_cloud_tier(user_id)
return CloudUser(
user_id=user_id,
tier=tier,
@ -241,6 +300,7 @@ def get_session(request: Request) -> CloudUser:
has_byok=has_byok,
household_id=household_id,
is_household_owner=is_household_owner,
license_key=license_key,
)

View file

@ -35,6 +35,16 @@ class Settings:
# Database
DB_PATH: Path = Path(os.environ.get("DB_PATH", str(DATA_DIR / "kiwi.db")))
# Community feature settings
COMMUNITY_DB_URL: str | None = os.environ.get("COMMUNITY_DB_URL") or None
COMMUNITY_PSEUDONYM_SALT: str = os.environ.get(
"COMMUNITY_PSEUDONYM_SALT", "kiwi-default-salt-change-in-prod"
)
COMMUNITY_CLOUD_FEED_URL: str = os.environ.get(
"COMMUNITY_CLOUD_FEED_URL",
"https://menagerie.circuitforge.tech/kiwi/api/v1/community/posts",
)
# Processing
MAX_CONCURRENT_JOBS: int = int(os.environ.get("MAX_CONCURRENT_JOBS", "4"))
USE_GPU: bool = os.environ.get("USE_GPU", "true").lower() in ("1", "true", "yes")
@ -50,8 +60,19 @@ class Settings:
# CFOrchClient reads CF_LICENSE_KEY automatically; exposed here for startup validation.
CF_LICENSE_KEY: str | None = os.environ.get("CF_LICENSE_KEY")
# E2E test account — analytics logging is suppressed for this user_id so test
# runs don't pollute session counts. Set to the Directus UUID of the test user.
E2E_TEST_USER_ID: str | None = os.environ.get("E2E_TEST_USER_ID") or None
# Feature flags
ENABLE_OCR: bool = os.environ.get("ENABLE_OCR", "false").lower() in ("1", "true", "yes")
# Use OrchestratedScheduler (coordinator-aware, multi-GPU fan-out) instead of
# LocalScheduler. Defaults to true in CLOUD_MODE; can be set independently
# for multi-GPU local rigs that don't need full cloud auth.
USE_ORCH_SCHEDULER: bool | None = (
None if os.environ.get("USE_ORCH_SCHEDULER") is None
else os.environ.get("USE_ORCH_SCHEDULER", "").lower() in ("1", "true", "yes")
)
# Runtime
DEBUG: bool = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")

View file

@ -0,0 +1,5 @@
-- Migration 022: Add is_generic flag to recipes
-- Generic recipes are catch-all/dump recipes with loose ingredient lists
-- that should not appear in Level 1 (deterministic "use what I have") results.
-- Admins can mark recipes via the recipe editor or a bulk backfill script.
ALTER TABLE recipes ADD COLUMN is_generic INTEGER NOT NULL DEFAULT 0;

View file

@ -0,0 +1,21 @@
-- 028_community_pseudonyms.sql
-- Per-user pseudonym store: maps the user's chosen community display name
-- to their Directus user ID. This table lives in per-user kiwi.db only.
-- It is NEVER replicated to the community PostgreSQL — pseudonym isolation is by design.
--
-- A user may have one active pseudonym. Old pseudonyms are retained for reference
-- (posts published under them keep their pseudonym attribution) but only one is
-- flagged as current (is_current = 1).
CREATE TABLE IF NOT EXISTS community_pseudonyms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pseudonym TEXT NOT NULL,
directus_user_id TEXT NOT NULL,
is_current INTEGER NOT NULL DEFAULT 1 CHECK (is_current IN (0, 1)),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Only one pseudonym can be current at a time per user
CREATE UNIQUE INDEX IF NOT EXISTS idx_community_pseudonyms_current
ON community_pseudonyms (directus_user_id)
WHERE is_current = 1;

View file

@ -0,0 +1,49 @@
-- Migration 029: Add inferred_tags column and update FTS index to include it.
--
-- inferred_tags holds a JSON array of normalized tag strings derived by
-- scripts/pipeline/infer_recipe_tags.py (e.g. ["cuisine:Italian",
-- "dietary:Low-Carb", "flavor:Umami", "can_be:Gluten-Free"]).
--
-- The FTS5 browser table is rebuilt to index inferred_tags alongside
-- category and keywords so browse domain queries match against all signals.
-- 1. Add inferred_tags column (empty array default; populated by pipeline run)
ALTER TABLE recipes ADD COLUMN inferred_tags TEXT NOT NULL DEFAULT '[]';
-- 2. Drop old FTS table and triggers that only covered category + keywords
DROP TRIGGER IF EXISTS recipes_ai;
DROP TRIGGER IF EXISTS recipes_ad;
DROP TRIGGER IF EXISTS recipes_au;
DROP TABLE IF EXISTS recipe_browser_fts;
-- 3. Recreate FTS5 table: now indexes category, keywords, AND inferred_tags
CREATE VIRTUAL TABLE recipe_browser_fts USING fts5(
category,
keywords,
inferred_tags,
content=recipes,
content_rowid=id
);
-- 4. Triggers to keep FTS in sync with recipes table changes
CREATE TRIGGER recipes_ai AFTER INSERT ON recipes BEGIN
INSERT INTO recipe_browser_fts(rowid, category, keywords, inferred_tags)
VALUES (new.id, new.category, new.keywords, new.inferred_tags);
END;
CREATE TRIGGER recipes_ad AFTER DELETE ON recipes BEGIN
INSERT INTO recipe_browser_fts(recipe_browser_fts, rowid, category, keywords, inferred_tags)
VALUES ('delete', old.id, old.category, old.keywords, old.inferred_tags);
END;
CREATE TRIGGER recipes_au AFTER UPDATE ON recipes BEGIN
INSERT INTO recipe_browser_fts(recipe_browser_fts, rowid, category, keywords, inferred_tags)
VALUES ('delete', old.id, old.category, old.keywords, old.inferred_tags);
INSERT INTO recipe_browser_fts(rowid, category, keywords, inferred_tags)
VALUES (new.id, new.category, new.keywords, new.inferred_tags);
END;
-- 5. Populate FTS from current table state
-- (inferred_tags is '[]' for all rows at this point; run infer_recipe_tags.py
-- to populate, then the FTS will be rebuilt as part of that script.)
INSERT INTO recipe_browser_fts(recipe_browser_fts) VALUES('rebuild');

View file

@ -0,0 +1,5 @@
-- Migration 030: open-package tracking
-- Adds opened_date to track when a multi-use item was first opened,
-- enabling secondary shelf-life windows (e.g. salsa: 1 year sealed → 2 weeks opened).
ALTER TABLE inventory_items ADD COLUMN opened_date TEXT;

View file

@ -0,0 +1,4 @@
-- Migration 031: add disposal_reason for waste logging (#60)
-- status='discarded' already exists in the CHECK constraint from migration 002.
-- This column stores free-text reason (optional) and calm-framing presets.
ALTER TABLE inventory_items ADD COLUMN disposal_reason TEXT;

View file

@ -0,0 +1,4 @@
-- 032_meal_plan_unique_week.sql
-- Prevent duplicate plans for the same week.
-- Existing duplicates must be resolved before applying (keep MIN(id) per week_start).
CREATE UNIQUE INDEX IF NOT EXISTS idx_meal_plans_week_start ON meal_plans (week_start);

View file

@ -0,0 +1,21 @@
-- Migration 033: standalone shopping list
-- Items can be added manually, from recipe gap analysis, or from the recipe browser.
-- Affiliate links are computed at query time by the API layer (never stored).
CREATE TABLE IF NOT EXISTS shopping_list_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
quantity REAL,
unit TEXT,
category TEXT,
checked INTEGER NOT NULL DEFAULT 0, -- 0=want, 1=in-cart/checked off
notes TEXT,
source TEXT NOT NULL DEFAULT 'manual', -- manual | recipe | meal_plan
recipe_id INTEGER REFERENCES recipes(id) ON DELETE SET NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_shopping_list_checked
ON shopping_list_items (checked, sort_order);

View file

@ -0,0 +1,14 @@
-- Migration 034: async recipe generation job queue
CREATE TABLE IF NOT EXISTS recipe_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
request TEXT NOT NULL,
result TEXT,
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_recipe_jobs_job_id ON recipe_jobs (job_id);
CREATE INDEX IF NOT EXISTS idx_recipe_jobs_user_id ON recipe_jobs (user_id, created_at DESC);

View file

@ -23,12 +23,25 @@ _COUNT_CACHE: dict[tuple[str, ...], int] = {}
class Store:
def __init__(self, db_path: Path, key: str = "") -> None:
import os
self._db_path = str(db_path)
self.conn: sqlite3.Connection = get_connection(db_path, key)
self.conn.execute("PRAGMA journal_mode=WAL")
self.conn.execute("PRAGMA foreign_keys=ON")
run_migrations(self.conn, MIGRATIONS_DIR)
# When RECIPE_DB_PATH is set (cloud mode), attach the shared read-only
# corpus DB as the "corpus" schema so per-user DBs can access recipe data.
# _cp (corpus prefix) is "corpus." in cloud mode, "" in local mode.
corpus_path = os.environ.get("RECIPE_DB_PATH", "")
if corpus_path:
self.conn.execute("ATTACH DATABASE ? AS corpus", (corpus_path,))
self._cp = "corpus."
self._corpus_path = corpus_path
else:
self._cp = ""
self._corpus_path = self._db_path
def close(self) -> None:
self.conn.close()
@ -218,7 +231,8 @@ class Store:
def update_inventory_item(self, item_id: int, **kwargs) -> dict[str, Any] | None:
allowed = {"quantity", "unit", "location", "sublocation",
"expiration_date", "status", "notes", "consumed_at"}
"purchase_date", "expiration_date", "opened_date",
"status", "notes", "consumed_at", "disposal_reason"}
updates = {k: v for k, v in kwargs.items() if k in allowed}
if not updates:
return self.get_inventory_item(item_id)
@ -231,6 +245,32 @@ class Store:
self.conn.commit()
return self.get_inventory_item(item_id)
def partial_consume_item(
self,
item_id: int,
consume_qty: float,
consumed_at: str,
) -> dict[str, Any] | None:
"""Decrement quantity by consume_qty. Mark consumed when quantity reaches 0."""
row = self.get_inventory_item(item_id)
if row is None:
return None
remaining = max(0.0, round(row["quantity"] - consume_qty, 6))
if remaining <= 0:
self.conn.execute(
"UPDATE inventory_items SET quantity = 0, status = 'consumed',"
" consumed_at = ?, updated_at = datetime('now') WHERE id = ?",
(consumed_at, item_id),
)
else:
self.conn.execute(
"UPDATE inventory_items SET quantity = ?, updated_at = datetime('now')"
" WHERE id = ?",
(remaining, item_id),
)
self.conn.commit()
return self.get_inventory_item(item_id)
def expiring_soon(self, days: int = 7) -> list[dict[str, Any]]:
return self._fetch_all(
"""SELECT i.*, p.name as product_name, p.category
@ -345,8 +385,9 @@ class Store:
def _fts_ready(self) -> bool:
"""Return True if the recipes_fts virtual table exists."""
schema = "corpus" if self._cp else "main"
row = self._fetch_one(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='recipes_fts'"
f"SELECT 1 FROM {schema}.sqlite_master WHERE type='table' AND name='recipes_fts'"
)
return row is not None
@ -573,6 +614,7 @@ class Store:
max_carbs_g: float | None = None,
max_sodium_mg: float | None = None,
excluded_ids: list[int] | None = None,
exclude_generic: bool = False,
) -> list[dict]:
"""Find recipes containing any of the given ingredient names.
Scores by match count and returns highest-scoring first.
@ -582,6 +624,9 @@ class Store:
Nutrition filters use NULL-passthrough: rows without nutrition data
always pass (they may be estimated or absent entirely).
exclude_generic: when True, skips recipes marked is_generic=1.
Pass True for Level 1 ("Use What I Have") to suppress catch-all recipes.
"""
if not ingredient_names:
return []
@ -607,6 +652,8 @@ class Store:
placeholders = ",".join("?" * len(excluded_ids))
extra_clauses.append(f"r.id NOT IN ({placeholders})")
extra_params.extend(excluded_ids)
if exclude_generic:
extra_clauses.append("r.is_generic = 0")
where_extra = (" AND " + " AND ".join(extra_clauses)) if extra_clauses else ""
if self._fts_ready():
@ -631,10 +678,12 @@ class Store:
return []
# Pull up to 10× limit candidates so ranking has enough headroom.
# FTS5 pseudo-column in WHERE uses bare table name, not schema-qualified.
c = self._cp
sql = f"""
SELECT r.*
FROM recipes_fts
JOIN recipes r ON r.id = recipes_fts.rowid
FROM {c}recipes_fts
JOIN {c}recipes r ON r.id = {c}recipes_fts.rowid
WHERE recipes_fts MATCH ?
{where_extra}
LIMIT ?
@ -668,9 +717,10 @@ class Store:
"CASE WHEN r.ingredient_names LIKE ? THEN 1 ELSE 0 END"
for _ in ingredient_names
)
c = self._cp
sql = f"""
SELECT r.*, ({match_score}) AS match_count
FROM recipes r
FROM {c}recipes r
WHERE ({like_clauses})
{where_extra}
ORDER BY match_count DESC, r.id ASC
@ -680,7 +730,107 @@ class Store:
return self._fetch_all(sql, tuple(all_params))
def get_recipe(self, recipe_id: int) -> dict | None:
return self._fetch_one("SELECT * FROM recipes WHERE id = ?", (recipe_id,))
row = self._fetch_one(f"SELECT * FROM {self._cp}recipes WHERE id = ?", (recipe_id,))
if row is None and self._cp:
# Fall back to user's own assembled recipes in main schema
row = self._fetch_one("SELECT * FROM recipes WHERE id = ?", (recipe_id,))
return row
# --- Async recipe jobs ---
def create_recipe_job(self, job_id: str, user_id: str, request_json: str) -> sqlite3.Row:
return self._insert_returning(
"INSERT INTO recipe_jobs (job_id, user_id, status, request) VALUES (?,?,?,?) RETURNING *",
(job_id, user_id, "queued", request_json),
)
def get_recipe_job(self, job_id: str, user_id: str) -> sqlite3.Row | None:
return self._fetch_one(
"SELECT * FROM recipe_jobs WHERE job_id=? AND user_id=?",
(job_id, user_id),
)
def update_recipe_job_running(self, job_id: str) -> None:
self.conn.execute(
"UPDATE recipe_jobs SET status='running', updated_at=datetime('now') WHERE job_id=?",
(job_id,),
)
self.conn.commit()
def complete_recipe_job(self, job_id: str, result_json: str) -> None:
self.conn.execute(
"UPDATE recipe_jobs SET status='done', result=?, updated_at=datetime('now') WHERE job_id=?",
(result_json, job_id),
)
self.conn.commit()
def fail_recipe_job(self, job_id: str, error: str) -> None:
self.conn.execute(
"UPDATE recipe_jobs SET status='failed', error=?, updated_at=datetime('now') WHERE job_id=?",
(error, job_id),
)
self.conn.commit()
def upsert_built_recipe(
self,
external_id: str,
title: str,
ingredients: list[str],
directions: list[str],
) -> int:
"""Persist an assembly-built recipe and return its DB id.
Uses external_id as a stable dedup key so the same build slug doesn't
accumulate duplicate rows across multiple user sessions.
"""
import json as _json
self.conn.execute(
"""
INSERT OR IGNORE INTO recipes
(external_id, title, ingredients, ingredient_names, directions, source)
VALUES (?, ?, ?, ?, ?, 'assembly')
""",
(
external_id,
title,
_json.dumps(ingredients),
_json.dumps(ingredients),
_json.dumps(directions),
),
)
# Update title in case the build was re-run with tweaked selections
self.conn.execute(
"UPDATE recipes SET title = ? WHERE external_id = ?",
(title, external_id),
)
self.conn.commit()
row = self._fetch_one(
"SELECT id FROM recipes WHERE external_id = ?", (external_id,)
)
return row["id"] # type: ignore[index]
def get_element_profiles(self, names: list[str]) -> dict[str, list[str]]:
"""Return {ingredient_name: [element_tag, ...]} for the given names.
Only names present in ingredient_profiles are returned -- missing names
are silently omitted so callers can distinguish "no profile" from "empty
elements list".
"""
if not names:
return {}
placeholders = ",".join("?" * len(names))
rows = self._fetch_all(
f"SELECT name, elements FROM {self._cp}ingredient_profiles WHERE name IN ({placeholders})",
tuple(names),
)
result: dict[str, list[str]] = {}
for row in rows:
try:
elements = json.loads(row["elements"]) if row["elements"] else []
except (json.JSONDecodeError, TypeError):
elements = []
result[row["name"]] = elements
return result
# ── rate limits ───────────────────────────────────────────────────────
@ -811,12 +961,25 @@ class Store:
"title": "r.title ASC",
}.get(sort_by, "sr.saved_at DESC")
c = self._cp
# In corpus-attached (cloud) mode: try corpus recipes first, fall back
# to user's own assembled recipes. In local mode: single join suffices.
if c:
recipe_join = (
f"LEFT JOIN {c}recipes rc ON rc.id = sr.recipe_id "
"LEFT JOIN recipes rm ON rm.id = sr.recipe_id"
)
title_col = "COALESCE(rc.title, rm.title) AS title"
else:
recipe_join = "JOIN recipes rc ON rc.id = sr.recipe_id"
title_col = "rc.title"
if collection_id is not None:
return self._fetch_all(
f"""
SELECT sr.*, r.title
SELECT sr.*, {title_col}
FROM saved_recipes sr
JOIN recipes r ON r.id = sr.recipe_id
{recipe_join}
JOIN recipe_collection_members rcm ON rcm.saved_recipe_id = sr.id
WHERE rcm.collection_id = ?
ORDER BY {order}
@ -825,9 +988,9 @@ class Store:
)
return self._fetch_all(
f"""
SELECT sr.*, r.title
SELECT sr.*, {title_col}
FROM saved_recipes sr
JOIN recipes r ON r.id = sr.recipe_id
{recipe_join}
ORDER BY {order}
""",
)
@ -842,10 +1005,26 @@ class Store:
# ── recipe collections ────────────────────────────────────────────────
def create_collection(self, name: str, description: str | None) -> dict:
return self._insert_returning(
"INSERT INTO recipe_collections (name, description) VALUES (?, ?) RETURNING *",
# INSERT RETURNING * omits aggregate columns (e.g. member_count); re-query
# with the same SELECT used by get_collections() so the response shape is consistent.
cur = self.conn.execute(
"INSERT INTO recipe_collections (name, description) VALUES (?, ?)",
(name, description),
)
self.conn.commit()
new_id = cur.lastrowid
row = self._fetch_one(
"""
SELECT rc.*,
COUNT(rcm.saved_recipe_id) AS member_count
FROM recipe_collections rc
LEFT JOIN recipe_collection_members rcm ON rcm.collection_id = rc.id
WHERE rc.id = ?
GROUP BY rc.id
""",
(new_id,),
)
return row # type: ignore[return-value]
def delete_collection(self, collection_id: int) -> None:
self.conn.execute(
@ -907,17 +1086,38 @@ class Store:
# ── recipe browser ────────────────────────────────────────────────────
def get_browser_categories(
self, domain: str, keywords_by_category: dict[str, list[str]]
self,
domain: str,
keywords_by_category: dict[str, list[str]],
has_subcategories_by_category: dict[str, bool] | None = None,
) -> list[dict]:
"""Return [{category, recipe_count}] for each category in the domain.
"""Return [{category, recipe_count, has_subcategories}] for each category.
keywords_by_category maps category name to the keyword list used to
match against recipes.category and recipes.keywords.
keywords_by_category maps category name keyword list for counting.
has_subcategories_by_category maps category name bool (optional;
defaults to False for all categories when omitted).
"""
results = []
for category, keywords in keywords_by_category.items():
count = self._count_recipes_for_keywords(keywords)
results.append({"category": category, "recipe_count": count})
results.append({
"category": category,
"recipe_count": count,
"has_subcategories": (has_subcategories_by_category or {}).get(category, False),
})
return results
def get_browser_subcategories(
self, domain: str, keywords_by_subcategory: dict[str, list[str]]
) -> list[dict]:
"""Return [{subcategory, recipe_count}] for each subcategory.
Mirrors get_browser_categories but for the second level.
"""
results = []
for subcat, keywords in keywords_by_subcategory.items():
count = self._count_recipes_for_keywords(keywords)
results.append({"subcategory": subcat, "recipe_count": count})
return results
@staticmethod
@ -929,12 +1129,16 @@ class Store:
def _count_recipes_for_keywords(self, keywords: list[str]) -> int:
if not keywords:
return 0
cache_key = (self._db_path, *sorted(keywords))
# Use corpus path as cache key so all cloud users share the same counts.
cache_key = (self._corpus_path, *sorted(keywords))
if cache_key in _COUNT_CACHE:
return _COUNT_CACHE[cache_key]
match_expr = self._browser_fts_query(keywords)
c = self._cp
# FTS5 pseudo-column in WHERE is always the bare (unqualified) table name,
# even when the table is accessed through an ATTACHed schema.
row = self.conn.execute(
"SELECT count(*) FROM recipe_browser_fts WHERE recipe_browser_fts MATCH ?",
f"SELECT count(*) FROM {c}recipe_browser_fts WHERE recipe_browser_fts MATCH ?",
(match_expr,),
).fetchone()
count = row[0] if row else 0
@ -943,41 +1147,76 @@ class Store:
def browse_recipes(
self,
keywords: list[str],
keywords: list[str] | None,
page: int,
page_size: int,
pantry_items: list[str] | None = None,
q: str | None = None,
sort: str = "default",
) -> dict:
"""Return a page of recipes matching the keyword set.
Pass keywords=None to browse all recipes without category filtering.
Each recipe row includes match_pct (float | None) when pantry_items
is provided. match_pct is the fraction of ingredient_names covered by
the pantry set computed deterministically, no LLM needed.
q: optional title substring filter (case-insensitive LIKE).
sort: "default" (corpus order) | "alpha" (AZ) | "alpha_desc" (ZA).
"""
if not keywords:
if keywords is not None and not keywords:
return {"recipes": [], "total": 0, "page": page}
match_expr = self._browser_fts_query(keywords)
offset = (page - 1) * page_size
c = self._cp
# Reuse cached count — avoids a second index scan on every page turn.
total = self._count_recipes_for_keywords(keywords)
order_clause = {
"alpha": "ORDER BY title ASC",
"alpha_desc": "ORDER BY title DESC",
}.get(sort, "ORDER BY id ASC")
rows = self._fetch_all(
"""
SELECT id, title, category, keywords, ingredient_names,
calories, fat_g, protein_g, sodium_mg
FROM recipes
WHERE id IN (
SELECT rowid FROM recipe_browser_fts
WHERE recipe_browser_fts MATCH ?
)
ORDER BY id ASC
LIMIT ? OFFSET ?
""",
(match_expr, page_size, offset),
q_param = f"%{q.strip()}%" if q and q.strip() else None
cols = (
f"SELECT id, title, category, keywords, ingredient_names,"
f" calories, fat_g, protein_g, sodium_mg FROM {c}recipes"
)
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._fetch_all(
f"{cols} WHERE LOWER(title) LIKE LOWER(?) {order_clause} LIMIT ? OFFSET ?",
(q_param, page_size, offset),
)
else:
total = self.conn.execute(f"SELECT COUNT(*) FROM {c}recipes").fetchone()[0]
rows = self._fetch_all(
f"{cols} {order_clause} LIMIT ? OFFSET ?",
(page_size, offset),
)
else:
match_expr = self._browser_fts_query(keywords)
fts_sub = f"id IN (SELECT rowid FROM {c}recipe_browser_fts WHERE recipe_browser_fts MATCH ?)"
if q_param:
total = self.conn.execute(
f"SELECT COUNT(*) FROM {c}recipes WHERE {fts_sub} AND LOWER(title) LIKE LOWER(?)",
(match_expr, q_param),
).fetchone()[0]
rows = self._fetch_all(
f"{cols} WHERE {fts_sub} AND LOWER(title) LIKE LOWER(?) {order_clause} LIMIT ? OFFSET ?",
(match_expr, q_param, page_size, offset),
)
else:
# Reuse cached count — avoids a second index scan on every page turn.
total = self._count_recipes_for_keywords(keywords)
rows = self._fetch_all(
f"{cols} WHERE {fts_sub} {order_clause} LIMIT ? OFFSET ?",
(match_expr, page_size, offset),
)
pantry_set = {p.lower() for p in pantry_items} if pantry_items else None
recipes = []
for r in rows:
@ -1025,6 +1264,12 @@ class Store:
def get_meal_plan(self, plan_id: int) -> dict | None:
return self._fetch_one("SELECT * FROM meal_plans WHERE id = ?", (plan_id,))
def update_meal_plan_types(self, plan_id: int, meal_types: list[str]) -> dict | None:
return self._fetch_one(
"UPDATE meal_plans SET meal_types = ? WHERE id = ? RETURNING *",
(json.dumps(meal_types), plan_id),
)
def list_meal_plans(self) -> list[dict]:
return self._fetch_all("SELECT * FROM meal_plans ORDER BY week_start DESC")
@ -1054,10 +1299,11 @@ class Store:
self.conn.commit()
def get_plan_slots(self, plan_id: int) -> list[dict]:
c = self._cp
return self._fetch_all(
"""SELECT s.*, r.name AS recipe_title
f"""SELECT s.*, r.title AS recipe_title
FROM meal_plan_slots s
LEFT JOIN recipes r ON r.id = s.recipe_id
LEFT JOIN {c}recipes r ON r.id = s.recipe_id
WHERE s.plan_id = ?
ORDER BY s.day_of_week, s.meal_type""",
(plan_id,),
@ -1065,10 +1311,11 @@ class Store:
def get_plan_recipes(self, plan_id: int) -> list[dict]:
"""Return full recipe rows for all recipes assigned to a plan."""
c = self._cp
return self._fetch_all(
"""SELECT DISTINCT r.*
f"""SELECT DISTINCT r.*
FROM meal_plan_slots s
JOIN recipes r ON r.id = s.recipe_id
JOIN {c}recipes r ON r.id = s.recipe_id
WHERE s.plan_id = ? AND s.recipe_id IS NOT NULL""",
(plan_id,),
)
@ -1128,3 +1375,99 @@ class Store:
)
self.conn.commit()
return self._fetch_one("SELECT * FROM prep_tasks WHERE id = ?", (task_id,))
# ── community ─────────────────────────────────────────────────────────
def get_current_pseudonym(self, directus_user_id: str) -> str | None:
"""Return the current community pseudonym for this user, or None if not set."""
cur = self.conn.execute(
"SELECT pseudonym FROM community_pseudonyms "
"WHERE directus_user_id = ? AND is_current = 1 LIMIT 1",
(directus_user_id,),
)
row = cur.fetchone()
return row["pseudonym"] if row else None
def set_pseudonym(self, directus_user_id: str, pseudonym: str) -> None:
"""Set the current community pseudonym for this user.
Marks any previous pseudonym as non-current (retains history for attribution).
"""
self.conn.execute(
"UPDATE community_pseudonyms SET is_current = 0 WHERE directus_user_id = ?",
(directus_user_id,),
)
self.conn.execute(
"INSERT INTO community_pseudonyms (pseudonym, directus_user_id, is_current) "
"VALUES (?, ?, 1)",
(pseudonym, directus_user_id),
)
self.conn.commit()
# ── Shopping list ─────────────────────────────────────────────────────────
def add_shopping_item(
self,
name: str,
quantity: float | None = None,
unit: str | None = None,
category: str | None = None,
notes: str | None = None,
source: str = "manual",
recipe_id: int | None = None,
sort_order: int = 0,
) -> dict:
return self._insert_returning(
"""INSERT INTO shopping_list_items
(name, quantity, unit, category, notes, source, recipe_id, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *""",
(name, quantity, unit, category, notes, source, recipe_id, sort_order),
)
def list_shopping_items(self, include_checked: bool = True) -> list[dict]:
where = "" if include_checked else "WHERE checked = 0"
self.conn.row_factory = sqlite3.Row
rows = self.conn.execute(
f"SELECT * FROM shopping_list_items {where} ORDER BY checked, sort_order, id",
).fetchall()
return [self._row_to_dict(r) for r in rows]
def get_shopping_item(self, item_id: int) -> dict | None:
self.conn.row_factory = sqlite3.Row
row = self.conn.execute(
"SELECT * FROM shopping_list_items WHERE id = ?", (item_id,)
).fetchone()
return self._row_to_dict(row) if row else None
def update_shopping_item(self, item_id: int, **kwargs) -> dict | None:
allowed = {"name", "quantity", "unit", "category", "checked", "notes", "sort_order"}
fields = {k: v for k, v in kwargs.items() if k in allowed and v is not None}
if not fields:
return self.get_shopping_item(item_id)
if "checked" in fields:
fields["checked"] = 1 if fields["checked"] else 0
set_clause = ", ".join(f"{k} = ?" for k in fields)
values = list(fields.values()) + [item_id]
self.conn.execute(
f"UPDATE shopping_list_items SET {set_clause}, updated_at = datetime('now') WHERE id = ?",
values,
)
self.conn.commit()
return self.get_shopping_item(item_id)
def delete_shopping_item(self, item_id: int) -> bool:
cur = self.conn.execute(
"DELETE FROM shopping_list_items WHERE id = ?", (item_id,)
)
self.conn.commit()
return cur.rowcount > 0
def clear_checked_shopping_items(self) -> int:
cur = self.conn.execute("DELETE FROM shopping_list_items WHERE checked = 1")
self.conn.commit()
return cur.rowcount
def clear_all_shopping_items(self) -> int:
cur = self.conn.execute("DELETE FROM shopping_list_items")
self.conn.commit()
return cur.rowcount

View file

@ -11,6 +11,9 @@ from app.api.routes import api_router
from app.core.config import settings
from app.services.meal_plan.affiliates import register_kiwi_programs
# Structured key=value log lines — grep/awk-friendly for log-based analytics.
# Without basicConfig, app-level INFO logs are silently dropped.
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(message)s")
logger = logging.getLogger(__name__)
@ -25,6 +28,10 @@ async def lifespan(app: FastAPI):
get_scheduler(settings.DB_PATH)
logger.info("Task scheduler started.")
# Initialize community store (no-op if COMMUNITY_DB_URL is not set)
from app.api.endpoints.community import init_community_store
init_community_store(settings.COMMUNITY_DB_URL)
yield
# Graceful scheduler shutdown

View file

@ -89,9 +89,20 @@ class InventoryItemUpdate(BaseModel):
unit: Optional[str] = None
location: Optional[str] = None
sublocation: Optional[str] = None
purchase_date: Optional[date] = None
expiration_date: Optional[date] = None
opened_date: Optional[date] = None
status: Optional[str] = None
notes: Optional[str] = None
disposal_reason: Optional[str] = None
class PartialConsumeRequest(BaseModel):
quantity: float = Field(..., gt=0, description="Amount to consume from this item")
class DiscardRequest(BaseModel):
reason: Optional[str] = Field(None, max_length=200)
class InventoryItemResponse(BaseModel):
@ -106,8 +117,14 @@ class InventoryItemResponse(BaseModel):
sublocation: Optional[str]
purchase_date: Optional[str]
expiration_date: Optional[str]
opened_date: Optional[str] = None
opened_expiry_date: Optional[str] = None
secondary_state: Optional[str] = None
secondary_uses: Optional[List[str]] = None
secondary_warning: Optional[str] = None
status: str
notes: Optional[str]
disposal_reason: Optional[str] = None
source: str
created_at: str
updated_at: str
@ -123,6 +140,7 @@ class BarcodeScanResult(BaseModel):
product: Optional[ProductResponse]
inventory_item: Optional[InventoryItemResponse]
added_to_inventory: bool
needs_manual_entry: bool = False
message: str

View file

@ -22,6 +22,10 @@ class CreatePlanRequest(BaseModel):
return v
class UpdatePlanRequest(BaseModel):
meal_types: list[str]
class UpsertSlotRequest(BaseModel):
recipe_id: int | None = None
servings: float = Field(2.0, gt=0)

View file

@ -41,6 +41,8 @@ class RecipeSuggestion(BaseModel):
is_wildcard: bool = False
nutrition: NutritionPanel | None = None
source_url: str | None = None
complexity: str | None = None # 'easy' | 'moderate' | 'involved'
estimated_time_min: int | None = None # derived from step count + method signals
class GroceryLink(BaseModel):
@ -56,6 +58,19 @@ class RecipeResult(BaseModel):
grocery_links: list[GroceryLink] = Field(default_factory=list)
rate_limited: bool = False
rate_limit_count: int = 0
orch_fallback: bool = False # True when orch budget exhausted; fell back to local LLM
class RecipeJobQueued(BaseModel):
job_id: str
status: str = "queued"
class RecipeJobStatus(BaseModel):
job_id: str
status: str
result: RecipeResult | None = None
error: str | None = None
class NutritionFilters(BaseModel):
@ -68,6 +83,10 @@ class NutritionFilters(BaseModel):
class RecipeRequest(BaseModel):
pantry_items: list[str]
# Maps product name → secondary state label for items past nominal expiry
# but still within their secondary use window (e.g. {"Bread": "stale"}).
# Used by the recipe engine to boost recipes suited to those specific states.
secondary_pantry_items: dict[str, str] = Field(default_factory=dict)
level: int = Field(default=1, ge=1, le=4)
constraints: list[str] = Field(default_factory=list)
expiry_first: bool = False
@ -82,3 +101,52 @@ class RecipeRequest(BaseModel):
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
excluded_ids: list[int] = 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
unit_system: str = "metric" # "metric" | "imperial"
# ── Build Your Own schemas ──────────────────────────────────────────────────
class AssemblyRoleOut(BaseModel):
"""One role slot in a template, as returned by GET /api/recipes/templates."""
display: str
required: bool
keywords: list[str]
hint: str = ""
class AssemblyTemplateOut(BaseModel):
"""One assembly template, as returned by GET /api/recipes/templates."""
id: str # slug, e.g. "burrito_taco"
title: str
icon: str
descriptor: str
role_sequence: list[AssemblyRoleOut]
class RoleCandidateItem(BaseModel):
"""One candidate ingredient for a wizard picker step."""
name: str
in_pantry: bool
tags: list[str] = Field(default_factory=list)
class RoleCandidatesResponse(BaseModel):
"""Response from GET /api/recipes/template-candidates."""
compatible: list[RoleCandidateItem] = Field(default_factory=list)
other: list[RoleCandidateItem] = Field(default_factory=list)
available_tags: list[str] = Field(default_factory=list)
class BuildRequest(BaseModel):
"""Request body for POST /api/recipes/build."""
template_id: str
role_overrides: dict[str, str] = Field(default_factory=dict)

View file

@ -0,0 +1,60 @@
"""Pydantic schemas for the shopping list endpoints."""
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, Field
class ShoppingItemCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
quantity: Optional[float] = None
unit: Optional[str] = None
category: Optional[str] = None
notes: Optional[str] = None
source: str = "manual"
recipe_id: Optional[int] = None
sort_order: int = 0
class ShoppingItemUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=200)
quantity: Optional[float] = None
unit: Optional[str] = None
category: Optional[str] = None
checked: Optional[bool] = None
notes: Optional[str] = None
sort_order: Optional[int] = None
class GroceryLinkOut(BaseModel):
ingredient: str
retailer: str
url: str
class ShoppingItemResponse(BaseModel):
id: int
name: str
quantity: Optional[float]
unit: Optional[str]
category: Optional[str]
checked: bool
notes: Optional[str]
source: str
recipe_id: Optional[int]
sort_order: int
created_at: str
updated_at: str
grocery_links: list[GroceryLinkOut] = []
class BulkAddFromRecipeRequest(BaseModel):
recipe_id: int
include_covered: bool = False # if True, add pantry-covered items too
class ConfirmPurchaseRequest(BaseModel):
"""Move a checked item into pantry inventory."""
location: str = "pantry"
quantity: Optional[float] = None # override the list quantity
unit: Optional[str] = None

View file

View file

@ -0,0 +1,44 @@
# app/services/community/ap_compat.py
# MIT License — AP scaffold only (no actor, inbox, outbox)
from __future__ import annotations
from datetime import datetime, timezone
def post_to_ap_json_ld(post: dict, base_url: str) -> dict:
"""Serialize a community post dict to an ActivityPub-compatible JSON-LD Note.
This is a read-only scaffold. No AP actor, inbox, or outbox.
The slug URI is stable so a future full AP implementation can reuse posts
without a DB migration.
"""
slug = post["slug"]
published = post.get("published")
if isinstance(published, datetime):
published_str = published.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
else:
published_str = str(published)
dietary_tags: list[str] = post.get("dietary_tags") or []
tags = [{"type": "Hashtag", "name": "#kiwi"}]
for tag in dietary_tags:
tags.append({"type": "Hashtag", "name": f"#{tag.replace('-', '').replace(' ', '')}"})
return {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Note",
"id": f"{base_url}/api/v1/community/posts/{slug}",
"attributedTo": post.get("pseudonym", "anonymous"),
"content": _build_content(post),
"published": published_str,
"tag": tags,
}
def _build_content(post: dict) -> str:
title = post.get("title") or "Untitled"
desc = post.get("description")
if desc:
return f"{title}{desc}"
return title

View file

@ -0,0 +1,90 @@
# app/services/community/community_store.py
# MIT License
from __future__ import annotations
import logging
from circuitforge_core.community import CommunityPost, SharedStore
logger = logging.getLogger(__name__)
class KiwiCommunityStore(SharedStore):
"""Kiwi-specific community store: adds kiwi-domain query methods on top of SharedStore."""
def list_meal_plans(
self,
limit: int = 20,
offset: int = 0,
dietary_tags: list[str] | None = None,
allergen_exclude: list[str] | None = None,
) -> list[CommunityPost]:
return self.list_posts(
limit=limit,
offset=offset,
post_type="plan",
dietary_tags=dietary_tags,
allergen_exclude=allergen_exclude,
source_product="kiwi",
)
def list_outcomes(
self,
limit: int = 20,
offset: int = 0,
post_type: str | None = None,
) -> list[CommunityPost]:
if post_type in ("recipe_success", "recipe_blooper"):
return self.list_posts(
limit=limit,
offset=offset,
post_type=post_type,
source_product="kiwi",
)
success = self.list_posts(
limit=limit,
offset=0,
post_type="recipe_success",
source_product="kiwi",
)
bloopers = self.list_posts(
limit=limit,
offset=0,
post_type="recipe_blooper",
source_product="kiwi",
)
merged = sorted(success + bloopers, key=lambda p: p.published, reverse=True)
return merged[:limit]
def get_or_create_pseudonym(
store,
directus_user_id: str,
requested_name: str | None,
) -> str:
"""Return the user's current pseudonym, creating it if it doesn't exist.
If the user has an existing pseudonym, return it (ignore requested_name).
If not, create using requested_name (must be provided for first-time setup).
Raises ValueError if no existing pseudonym and requested_name is None or blank.
"""
existing = store.get_current_pseudonym(directus_user_id)
if existing:
return existing
if not requested_name or not requested_name.strip():
raise ValueError(
"A pseudonym is required for first publish. "
"Pass requested_name with the user's chosen display name."
)
name = requested_name.strip()
if "@" in name:
raise ValueError(
"Pseudonym must not contain '@' — use a display name, not an email address."
)
store.set_pseudonym(directus_user_id, name)
return name

View file

@ -0,0 +1,138 @@
# app/services/community/element_snapshot.py
# MIT License
from __future__ import annotations
from dataclasses import dataclass
# Ingredient name substrings → allergen flag
_ALLERGEN_MAP: dict[str, str] = {
"milk": "dairy", "cream": "dairy", "cheese": "dairy", "butter": "dairy",
"yogurt": "dairy", "whey": "dairy",
"egg": "eggs",
"wheat": "gluten", "pasta": "gluten", "flour": "gluten", "bread": "gluten",
"barley": "gluten", "rye": "gluten",
"peanut": "nuts", "almond": "nuts", "cashew": "nuts", "walnut": "nuts",
"pecan": "nuts", "hazelnut": "nuts", "pistachio": "nuts", "macadamia": "nuts",
"soy": "soy", "tofu": "soy", "edamame": "soy", "miso": "soy", "tempeh": "soy",
"shrimp": "shellfish", "crab": "shellfish", "lobster": "shellfish",
"clam": "shellfish", "mussel": "shellfish", "scallop": "shellfish",
"fish": "fish", "salmon": "fish", "tuna": "fish", "cod": "fish",
"tilapia": "fish", "halibut": "fish",
"sesame": "sesame",
}
_MEAT_KEYWORDS = frozenset([
"chicken", "beef", "pork", "lamb", "turkey", "bacon", "ham", "sausage",
"salami", "prosciutto", "guanciale", "pancetta", "steak", "ground meat",
"mince", "veal", "duck", "venison", "bison", "lard",
])
_SEAFOOD_KEYWORDS = frozenset([
"fish", "shrimp", "crab", "lobster", "tuna", "salmon", "clam", "mussel",
"scallop", "anchovy", "sardine", "cod", "tilapia",
])
_ANIMAL_PRODUCT_KEYWORDS = frozenset([
"milk", "cream", "cheese", "butter", "egg", "honey", "yogurt", "whey",
])
def _detect_allergens(ingredient_names: list[str]) -> list[str]:
found: set[str] = set()
lowered = [n.lower() for n in ingredient_names]
for ingredient in lowered:
for keyword, flag in _ALLERGEN_MAP.items():
if keyword in ingredient:
found.add(flag)
return sorted(found)
def _detect_dietary_tags(ingredient_names: list[str]) -> list[str]:
lowered = [n.lower() for n in ingredient_names]
all_text = " ".join(lowered)
has_meat = any(k in all_text for k in _MEAT_KEYWORDS)
has_seafood = any(k in all_text for k in _SEAFOOD_KEYWORDS)
has_animal_products = any(k in all_text for k in _ANIMAL_PRODUCT_KEYWORDS)
tags: list[str] = []
if not has_meat and not has_seafood:
tags.append("vegetarian")
if not has_meat and not has_seafood and not has_animal_products:
tags.append("vegan")
return tags
@dataclass(frozen=True)
class ElementSnapshot:
seasoning_score: float
richness_score: float
brightness_score: float
depth_score: float
aroma_score: float
structure_score: float
texture_profile: str
dietary_tags: tuple
allergen_flags: tuple
flavor_molecules: tuple
fat_pct: float | None
protein_pct: float | None
moisture_pct: float | None
def compute_snapshot(recipe_ids: list[int], store) -> ElementSnapshot:
"""Compute an element snapshot from a list of recipe IDs.
Pulls SFAH scores, ingredient lists, and USDA FDC macros from the corpus.
Averages numeric scores across all recipes. Unions allergen flags and dietary tags.
Call at publish time only snapshot is stored denormalized in community_posts.
"""
if not recipe_ids:
return ElementSnapshot(
seasoning_score=0.0, richness_score=0.0, brightness_score=0.0,
depth_score=0.0, aroma_score=0.0, structure_score=0.0,
texture_profile="", dietary_tags=(), allergen_flags=(),
flavor_molecules=(), fat_pct=None, protein_pct=None, moisture_pct=None,
)
rows = store.get_recipes_by_ids(recipe_ids)
if not rows:
return ElementSnapshot(
seasoning_score=0.0, richness_score=0.0, brightness_score=0.0,
depth_score=0.0, aroma_score=0.0, structure_score=0.0,
texture_profile="", dietary_tags=(), allergen_flags=(),
flavor_molecules=(), fat_pct=None, protein_pct=None, moisture_pct=None,
)
def _avg(field: str) -> float:
vals = [r.get(field) or 0.0 for r in rows]
return sum(vals) / len(vals)
all_ingredients: list[str] = []
for r in rows:
names = r.get("ingredient_names") or []
all_ingredients.extend(names if isinstance(names, list) else [])
allergens = _detect_allergens(all_ingredients)
dietary = _detect_dietary_tags(all_ingredients)
texture = rows[0].get("texture_profile") or ""
fat_vals = [r.get("fat") for r in rows if r.get("fat") is not None]
prot_vals = [r.get("protein") for r in rows if r.get("protein") is not None]
moist_vals = [r.get("moisture") for r in rows if r.get("moisture") is not None]
return ElementSnapshot(
seasoning_score=_avg("seasoning_score"),
richness_score=_avg("richness_score"),
brightness_score=_avg("brightness_score"),
depth_score=_avg("depth_score"),
aroma_score=_avg("aroma_score"),
structure_score=_avg("structure_score"),
texture_profile=texture,
dietary_tags=tuple(dietary),
allergen_flags=tuple(allergens),
flavor_molecules=(),
fat_pct=(sum(fat_vals) / len(fat_vals)) if fat_vals else None,
protein_pct=(sum(prot_vals) / len(prot_vals)) if prot_vals else None,
moisture_pct=(sum(moist_vals) / len(moist_vals)) if moist_vals else None,
)

View file

@ -0,0 +1,43 @@
# app/services/community/feed.py
# MIT License
from __future__ import annotations
from datetime import datetime, timezone
from email.utils import format_datetime
from xml.etree.ElementTree import Element, SubElement, tostring
def posts_to_rss(posts: list[dict], base_url: str) -> str:
"""Generate an RSS 2.0 feed from a list of community post dicts.
base_url: the root URL of this Kiwi instance (no trailing slash).
Returns UTF-8 XML string.
"""
rss = Element("rss", version="2.0")
channel = SubElement(rss, "channel")
_sub(channel, "title", "Kiwi Community Feed")
_sub(channel, "link", f"{base_url}/community")
_sub(channel, "description", "Meal plans and recipe outcomes from the Kiwi community")
_sub(channel, "language", "en")
_sub(channel, "lastBuildDate", format_datetime(datetime.now(timezone.utc)))
for post in posts:
item = SubElement(channel, "item")
_sub(item, "title", post.get("title") or "Untitled")
_sub(item, "link", f"{base_url}/api/v1/community/posts/{post['slug']}")
_sub(item, "guid", f"{base_url}/api/v1/community/posts/{post['slug']}")
if post.get("description"):
_sub(item, "description", post["description"])
published = post.get("published")
if isinstance(published, datetime):
_sub(item, "pubDate", format_datetime(published))
return '<?xml version="1.0" encoding="UTF-8"?>\n' + tostring(rss, encoding="unicode")
def _sub(parent: Element, tag: str, text: str) -> Element:
el = SubElement(parent, tag)
el.text = text
return el

View file

@ -0,0 +1,72 @@
# app/services/community/mdns.py
# MIT License
from __future__ import annotations
import logging
import socket
logger = logging.getLogger(__name__)
# Import deferred to avoid hard failure when zeroconf is not installed
try:
from zeroconf import ServiceInfo, Zeroconf
_ZEROCONF_AVAILABLE = True
except ImportError:
_ZEROCONF_AVAILABLE = False
class KiwiMDNS:
"""Advertise this Kiwi instance on the LAN via mDNS (_kiwi._tcp.local).
Defaults to disabled (enabled=False). User must explicitly opt in via the
Settings page. This matches the CF a11y requirement: no surprise broadcasting.
Usage:
mdns = KiwiMDNS(enabled=settings.MDNS_ENABLED, port=settings.PORT,
feed_url=f"http://{hostname}:{settings.PORT}/api/v1/community/local-feed")
mdns.start() # in lifespan startup
mdns.stop() # in lifespan shutdown
"""
SERVICE_TYPE = "_kiwi._tcp.local."
def __init__(self, enabled: bool, port: int, feed_url: str) -> None:
self._enabled = enabled
self._port = port
self._feed_url = feed_url
self._zc: "Zeroconf | None" = None
self._info: "ServiceInfo | None" = None
def start(self) -> None:
if not self._enabled:
logger.debug("mDNS advertisement disabled (user has not opted in)")
return
if not _ZEROCONF_AVAILABLE:
logger.warning("zeroconf package not installed — mDNS advertisement unavailable")
return
hostname = socket.gethostname()
service_name = f"kiwi-{hostname}.{self.SERVICE_TYPE}"
self._info = ServiceInfo(
type_=self.SERVICE_TYPE,
name=service_name,
port=self._port,
properties={
b"feed_url": self._feed_url.encode(),
b"version": b"1",
},
addresses=[socket.inet_aton("127.0.0.1")],
)
self._zc = Zeroconf()
self._zc.register_service(self._info)
logger.info("mDNS: advertising %s on port %d", service_name, self._port)
def stop(self) -> None:
if self._zc is None or self._info is None:
return
self._zc.unregister_service(self._info)
self._zc.close()
self._zc = None
self._info = None
logger.info("mDNS: advertisement stopped")

View file

@ -116,6 +116,140 @@ class ExpirationPredictor:
'prepared_foods': {'fridge': 4, 'freezer': 90},
}
# Secondary shelf life in days after a package is opened.
# Sources: USDA FoodKeeper app, FDA consumer guides.
# Only categories where opening significantly shortens shelf life are listed.
# Items not listed default to None (no secondary window tracked).
SHELF_LIFE_AFTER_OPENING: dict[str, int] = {
# Dairy — once opened, clock ticks fast
'dairy': 5,
'milk': 5,
'cream': 3,
'yogurt': 7,
'cheese': 14,
'butter': 30,
# Condiments — refrigerated after opening
'condiments': 30,
'ketchup': 30,
'mustard': 30,
'mayo': 14,
'salad_dressing': 30,
'soy_sauce': 90,
# Canned goods — once opened, very short
'canned_goods': 4,
# Beverages
'juice': 7,
'soda': 4,
# Bread / Bakery
'bread': 5,
'bakery': 3,
# Produce
'leafy_greens': 3,
'berries': 3,
# Pantry staples (open bag)
'chips': 14,
'cookies': 14,
'cereal': 30,
'flour': 90,
}
# Post-expiry secondary use window.
# 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.
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.',
},
'bakery': {
'window_days': 3,
'label': 'day-old',
'uses': ['French toast', 'bread pudding', 'crumbles'],
'warning': 'Check for mold before use — discard if any is visible.',
},
'bananas': {
'window_days': 5,
'label': 'overripe',
'uses': ['banana bread', 'smoothies', 'pancakes', 'muffins'],
'warning': None,
},
'milk': {
'window_days': 3,
'label': 'sour',
'uses': ['pancakes', 'quick breads', 'baking', 'sauces'],
'warning': 'Use only in cooked recipes — do not drink.',
},
'dairy': {
'window_days': 2,
'label': 'sour',
'uses': ['pancakes', 'quick breads', 'baking'],
'warning': 'Use only in cooked recipes — do not drink.',
},
'cheese': {
'window_days': 14,
'label': 'well-aged',
'uses': ['broth', 'soups', 'risotto', 'gratins'],
'warning': None,
},
'rice': {
'window_days': 2,
'label': 'day-old',
'uses': ['fried rice', 'rice bowls', 'rice porridge'],
'warning': 'Refrigerate immediately after cooking — do not leave at room temp.',
},
'tortillas': {
'window_days': 5,
'label': 'stale',
'uses': ['chilaquiles', 'migas', 'tortilla soup', 'casserole'],
'warning': None,
},
}
def days_after_opening(self, category: str | None) -> int | None:
"""Return days of shelf life remaining once a package is opened.
Returns None if the category is unknown or not tracked after opening
(e.g. frozen items, raw meat category check irrelevant once opened).
"""
if not category:
return None
return self.SHELF_LIFE_AFTER_OPENING.get(category.lower())
def secondary_state(
self, category: str | None, expiry_date: str | None
) -> 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 None in all other cases (unknown category, no window defined, not yet
expired, or past the secondary window).
"""
if not category or not expiry_date:
return None
entry = self.SECONDARY_WINDOW.get(category.lower())
if not entry:
return None
try:
from datetime import date
today = date.today()
exp = date.fromisoformat(expiry_date)
days_past = (today - exp).days
if 0 <= days_past <= entry['window_days']:
return {
'label': entry['label'],
'uses': list(entry['uses']),
'warning': entry['warning'],
'days_past': days_past,
'window_days': entry['window_days'],
}
except ValueError:
pass
return None
# 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,80 @@
"""Heimdall cf-orch budget client.
Calls Heimdall's /orch/* endpoints to gate and record cf-orch usage for
lifetime/founders license holders. Always fails open on network errors
a Heimdall outage should never block the user.
"""
from __future__ import annotations
import logging
import os
import requests
log = logging.getLogger(__name__)
HEIMDALL_URL: str = os.environ.get("HEIMDALL_URL", "https://license.circuitforge.tech")
HEIMDALL_ADMIN_TOKEN: str = os.environ.get("HEIMDALL_ADMIN_TOKEN", "")
def _headers() -> dict[str, str]:
if HEIMDALL_ADMIN_TOKEN:
return {"Authorization": f"Bearer {HEIMDALL_ADMIN_TOKEN}"}
return {}
def check_orch_budget(key_display: str, product: str) -> dict:
"""Call POST /orch/check and return the response dict.
On any error (network, auth, etc.) returns a permissive dict so the
caller can proceed without blocking the user.
"""
try:
resp = requests.post(
f"{HEIMDALL_URL}/orch/check",
json={"key_display": key_display, "product": product},
headers=_headers(),
timeout=5,
)
if resp.ok:
return resp.json()
log.warning("Heimdall orch/check returned %s for key %s", resp.status_code, key_display[:12])
except Exception as exc:
log.warning("Heimdall orch/check failed (fail-open): %s", exc)
# Fail open — Heimdall outage must never block the user
return {
"allowed": True,
"calls_used": 0,
"calls_total": 0,
"topup_calls": 0,
"period_start": "",
"resets_on": "",
}
def get_orch_usage(key_display: str, product: str) -> dict:
"""Call GET /orch/usage and return the response dict.
Returns zeros on error (non-blocking).
"""
try:
resp = requests.get(
f"{HEIMDALL_URL}/orch/usage",
params={"key_display": key_display, "product": product},
headers=_headers(),
timeout=5,
)
if resp.ok:
return resp.json()
log.warning("Heimdall orch/usage returned %s", resp.status_code)
except Exception as exc:
log.warning("Heimdall orch/usage failed: %s", exc)
return {
"calls_used": 0,
"topup_calls": 0,
"calls_total": 0,
"period_start": "",
"resets_on": "",
}

View file

@ -15,64 +15,73 @@ logger = logging.getLogger(__name__)
class OpenFoodFactsService:
"""
Service for interacting with the OpenFoodFacts API.
Service for interacting with the Open*Facts family of databases.
OpenFoodFacts is a free, open database of food products with
ingredients, allergens, and nutrition facts.
Primary: OpenFoodFacts (food products).
Fallback chain: Open Beauty Facts (personal care) Open Products Facts (household).
All three databases share the same API path and JSON format.
"""
BASE_URL = "https://world.openfoodfacts.org/api/v2"
USER_AGENT = "Kiwi/0.1.0 (https://circuitforge.tech)"
# Fallback databases tried in order when OFFs returns no match.
# Same API format as OFFs — only the host differs.
_FALLBACK_DATABASES = [
"https://world.openbeautyfacts.org/api/v2",
"https://world.openproductsfacts.org/api/v2",
]
async def _lookup_in_database(
self, barcode: str, base_url: str, client: httpx.AsyncClient
) -> Optional[Dict[str, Any]]:
"""Try one Open*Facts database using an existing client. Returns parsed product dict or None."""
try:
response = await client.get(
f"{base_url}/product/{barcode}.json",
headers={"User-Agent": self.USER_AGENT},
timeout=10.0,
)
if response.status_code == 404:
return None
response.raise_for_status()
data = response.json()
if data.get("status") != 1:
return None
return self._parse_product_data(data, barcode)
except httpx.HTTPError as e:
logger.debug("HTTP error for %s at %s: %s", barcode, base_url, e)
return None
except Exception as e:
logger.debug("Lookup failed for %s at %s: %s", barcode, base_url, e)
return None
async def lookup_product(self, barcode: str) -> Optional[Dict[str, Any]]:
"""
Look up a product by barcode in the OpenFoodFacts database.
Look up a product by barcode, trying OFFs then fallback databases.
A single httpx.AsyncClient is created for the whole lookup chain so that
connection pooling and TLS session reuse apply across all database attempts.
Args:
barcode: UPC/EAN barcode (8-13 digits)
Returns:
Dictionary with product information, or None if not found
Example response:
{
"name": "Organic Milk",
"brand": "Horizon",
"categories": ["Dairy", "Milk"],
"image_url": "https://...",
"nutrition_data": {...},
"raw_data": {...} # Full API response
}
Dictionary with product information, or None if not found in any database.
"""
try:
async with httpx.AsyncClient() as client:
url = f"{self.BASE_URL}/product/{barcode}.json"
async with httpx.AsyncClient() as client:
result = await self._lookup_in_database(barcode, self.BASE_URL, client)
if result:
return result
response = await client.get(
url,
headers={"User-Agent": self.USER_AGENT},
timeout=10.0,
)
for db_url in self._FALLBACK_DATABASES:
result = await self._lookup_in_database(barcode, db_url, client)
if result:
logger.info("Barcode %s found in fallback database: %s", barcode, db_url)
return result
if response.status_code == 404:
logger.info(f"Product not found in OpenFoodFacts: {barcode}")
return None
response.raise_for_status()
data = response.json()
if data.get("status") != 1:
logger.info(f"Product not found in OpenFoodFacts: {barcode}")
return None
return self._parse_product_data(data, barcode)
except httpx.HTTPError as e:
logger.error(f"HTTP error looking up barcode {barcode}: {e}")
return None
except Exception as e:
logger.error(f"Error looking up barcode {barcode}: {e}")
return None
logger.info("Barcode %s not found in any Open*Facts database", barcode)
return None
def _parse_product_data(self, data: Dict[str, Any], barcode: str) -> Dict[str, Any]:
"""
@ -114,6 +123,9 @@ class OpenFoodFactsService:
allergens = product.get("allergens_tags", [])
labels = product.get("labels_tags", [])
# Pack size detection: prefer explicit unit_count, fall back to serving count
pack_quantity, pack_unit = self._extract_pack_size(product)
return {
"name": name,
"brand": brand,
@ -124,9 +136,47 @@ class OpenFoodFactsService:
"nutrition_data": nutrition_data,
"allergens": allergens,
"labels": labels,
"pack_quantity": pack_quantity,
"pack_unit": pack_unit,
"raw_data": product, # Store full response for debugging
}
def _extract_pack_size(self, product: Dict[str, Any]) -> tuple[float | None, str | None]:
"""Return (quantity, unit) for multi-pack products, or (None, None).
OFFs fields tried in order:
1. `number_of_units` (explicit count, highest confidence)
2. `serving_quantity` + `product_quantity_unit` (e.g. 6 x 150g yoghurt)
3. Parse `quantity` string like "4 x 113 g" or "6 pack"
Returns None, None when data is absent, ambiguous, or single-unit.
"""
import re
# Field 1: explicit unit count
unit_count = product.get("number_of_units")
if unit_count:
try:
n = float(unit_count)
if n > 1:
return n, product.get("serving_size_unit") or "unit"
except (ValueError, TypeError):
pass
# Field 2: parse quantity string for "N x ..." pattern
qty_str = product.get("quantity", "")
if qty_str:
m = re.match(r"^(\d+(?:\.\d+)?)\s*[xX×]\s*", qty_str.strip())
if m:
n = float(m.group(1))
if n > 1:
# Try to get a sensible sub-unit label from the rest
rest = qty_str[m.end():].strip()
unit_label = re.sub(r"[\d.,\s]+", "", rest).strip()[:20] or "unit"
return n, unit_label
return None, None
def _extract_nutrition_data(self, product: Dict[str, Any]) -> Dict[str, Any]:
"""
Extract nutrition facts from product data.

View file

@ -42,11 +42,21 @@ class AssemblyRole:
class AssemblyTemplate:
"""A template assembly dish."""
id: int
slug: str # URL-safe identifier, e.g. "burrito_taco"
icon: str # emoji
descriptor: str # one-line description shown in template grid
title: str
required: list[AssemblyRole]
optional: list[AssemblyRole]
directions: list[str]
notes: str = ""
# Per-role hints shown in the wizard picker header
# keys match role.display values; missing keys fall back to ""
role_hints: dict[str, str] = None # type: ignore[assignment]
def __post_init__(self) -> None:
if self.role_hints is None:
self.role_hints = {}
def _matches_role(role: AssemblyRole, pantry_set: set[str]) -> list[str]:
@ -138,6 +148,9 @@ def _personalized_title(tmpl: AssemblyTemplate, pantry_set: set[str], seed: int)
ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
AssemblyTemplate(
id=-1,
slug="burrito_taco",
icon="🌯",
descriptor="Protein, veg, and sauce in a tortilla or over rice",
title="Burrito / Taco",
required=[
AssemblyRole("tortilla or wrap", [
@ -170,9 +183,21 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"Fold in the sides and roll tightly. Optionally toast seam-side down 1-2 minutes.",
],
notes="Works as a burrito (rolled), taco (folded), or quesadilla (cheese only, pressed flat).",
role_hints={
"tortilla or wrap": "The foundation -- what holds everything",
"protein": "The main filling",
"rice or starch": "Optional base layer",
"cheese": "Optional -- melts into the filling",
"salsa or sauce": "Optional -- adds moisture and heat",
"sour cream or yogurt": "Optional -- cool contrast to heat",
"vegetables": "Optional -- adds texture and colour",
},
),
AssemblyTemplate(
id=-2,
slug="fried_rice",
icon="🍳",
descriptor="Rice + egg + whatever's in the fridge",
title="Fried Rice",
required=[
AssemblyRole("cooked rice", [
@ -205,9 +230,21 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"Season with soy sauce and any other sauces. Toss to combine.",
],
notes="Add a fried egg on top. A drizzle of sesame oil at the end adds a lot.",
role_hints={
"cooked rice": "Day-old cold rice works best",
"protein": "Pre-cooked or raw -- cook before adding rice",
"soy sauce or seasoning": "The primary flavour driver",
"oil": "High smoke-point oil for high heat",
"egg": "Scrambled in the same pan",
"vegetables": "Add crunch and colour",
"garlic or ginger": "Aromatic base -- add first",
},
),
AssemblyTemplate(
id=-3,
slug="omelette_scramble",
icon="🥚",
descriptor="Eggs with fillings, pan-cooked",
title="Omelette / Scramble",
required=[
AssemblyRole("eggs", ["egg"]),
@ -238,9 +275,19 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"Season and serve immediately.",
],
notes="Works for breakfast, lunch, or a quick dinner. Any leftover vegetables work well.",
role_hints={
"eggs": "The base -- beat with a splash of water",
"cheese": "Fold in just before serving",
"vegetables": "Saute first, then add eggs",
"protein": "Cook through before adding eggs",
"herbs or seasoning": "Season at the end",
},
),
AssemblyTemplate(
id=-4,
slug="stir_fry",
icon="🥢",
descriptor="High-heat protein + veg in sauce",
title="Stir Fry",
required=[
AssemblyRole("vegetables", [
@ -271,9 +318,20 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"Serve over rice or noodles.",
],
notes="High heat is the key. Do not crowd the pan -- cook in batches if needed.",
role_hints={
"vegetables": "Cut to similar size for even cooking",
"starch base": "Serve under or toss with the stir fry",
"protein": "Cook first, remove, add back at end",
"sauce": "Add last -- toss for 1-2 minutes only",
"garlic or ginger": "Add early for aromatic base",
"oil": "High smoke-point oil only",
},
),
AssemblyTemplate(
id=-5,
slug="pasta",
icon="🍝",
descriptor="Pantry pasta with flexible sauce",
title="Pasta with Whatever You Have",
required=[
AssemblyRole("pasta", [
@ -307,9 +365,20 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"Toss cooked pasta with sauce. Finish with cheese if using.",
],
notes="Pasta water is the secret -- the starch thickens and binds any sauce.",
role_hints={
"pasta": "The base -- cook al dente, reserve pasta water",
"sauce base": "Simmer 5 min; pasta water loosens it",
"protein": "Cook through before adding sauce",
"cheese": "Finish off heat to avoid graininess",
"vegetables": "Saute until tender before adding sauce",
"garlic": "Saute in oil first -- the flavour foundation",
},
),
AssemblyTemplate(
id=-6,
slug="sandwich_wrap",
icon="🥪",
descriptor="Protein + veg between bread or in a wrap",
title="Sandwich / Wrap",
required=[
AssemblyRole("bread or wrap", [
@ -341,9 +410,19 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"Press together and cut diagonally.",
],
notes="Leftovers, deli meat, canned fish -- nearly anything works between bread.",
role_hints={
"bread or wrap": "Toast for better texture",
"protein": "Layer on first after condiments",
"cheese": "Goes on top of protein",
"condiment": "Spread on both inner surfaces",
"vegetables": "Top layer -- keeps bread from getting soggy",
},
),
AssemblyTemplate(
id=-7,
slug="grain_bowl",
icon="🥗",
descriptor="Grain base + protein + toppings + dressing",
title="Grain Bowl",
required=[
AssemblyRole("grain base", [
@ -377,9 +456,19 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"Drizzle with dressing and add toppings.",
],
notes="Great for meal prep -- cook grains and proteins in bulk, assemble bowls all week.",
role_hints={
"grain base": "Season while cooking -- bland grains sink the bowl",
"protein": "Slice or shred; arrange on top",
"vegetables": "Roast or saute for best flavour",
"dressing or sauce": "Drizzle last -- ties everything together",
"toppings": "Add crunch and contrast",
},
),
AssemblyTemplate(
id=-8,
slug="soup_stew",
icon="🥣",
descriptor="Liquid-based, flexible ingredients",
title="Soup / Stew",
required=[
# Narrow to dedicated soup bases — tomato sauce and coconut milk are
@ -415,9 +504,19 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"Season to taste and simmer at least 20 minutes for flavors to develop.",
],
notes="Soups and stews improve overnight in the fridge. Almost any combination works.",
role_hints={
"broth or stock": "The liquid base -- determines overall flavour",
"protein": "Brown first for deeper flavour",
"vegetables": "Dense veg first; quick-cooking veg last",
"starch thickener": "Adds body and turns soup into stew",
"seasoning": "Taste and adjust after 20 min simmer",
},
),
AssemblyTemplate(
id=-9,
slug="casserole_bake",
icon="🫙",
descriptor="Oven bake with protein, veg, starch",
title="Casserole / Bake",
required=[
AssemblyRole("starch or base", [
@ -457,9 +556,20 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"Bake covered 25 minutes, then uncovered 15 minutes until golden and bubbly.",
],
notes="Classic pantry dump dinner. Cream of anything soup is the universal binder.",
role_hints={
"starch or base": "Cook slightly underdone -- finishes in oven",
"binder or sauce": "Coats everything and holds the bake together",
"protein": "Pre-cook before mixing in",
"vegetables": "Chop small for even distribution",
"cheese topping": "Goes on last -- browns in the final 15 min",
"seasoning": "Casseroles need more salt than you think",
},
),
AssemblyTemplate(
id=-10,
slug="pancakes_quickbread",
icon="🥞",
descriptor="Batter-based; sweet or savory",
title="Pancakes / Waffles / Quick Bread",
required=[
AssemblyRole("flour or baking mix", [
@ -495,9 +605,20 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"For muffins or quick bread: pour into greased pan, bake at 375 F until a toothpick comes out clean.",
],
notes="Overmixing develops gluten and makes pancakes tough. Stop when just combined.",
role_hints={
"flour or baking mix": "Whisk dry ingredients together first",
"leavening or egg": "Activates rise -- don't skip",
"liquid": "Add to dry ingredients; lumps are fine",
"fat": "Adds richness and prevents sticking",
"sweetener": "Mix into wet ingredients",
"mix-ins": "Fold in last -- gently",
},
),
AssemblyTemplate(
id=-11,
slug="porridge_oatmeal",
icon="🌾",
descriptor="Oat or grain base with toppings",
title="Porridge / Oatmeal",
required=[
AssemblyRole("oats or grain porridge", [
@ -520,9 +641,20 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"Top with fruit, nuts, or seeds and serve immediately.",
],
notes="Overnight oats: skip cooking — soak oats in cold milk overnight in the fridge.",
role_hints={
"oats or grain porridge": "1 part oats to 2 parts liquid",
"liquid": "Use milk for creamier result",
"sweetener": "Stir in after cooking",
"fruit": "Add fresh on top or simmer dried fruit in",
"toppings": "Add last for crunch",
"spice": "Stir in with sweetener",
},
),
AssemblyTemplate(
id=-12,
slug="pie_pot_pie",
icon="🥧",
descriptor="Pastry or biscuit crust with filling",
title="Pie / Pot Pie",
required=[
AssemblyRole("pastry or crust", [
@ -561,9 +693,20 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"For sweet pie: fill unbaked crust with fruit filling, top with second crust or crumble, bake similarly.",
],
notes="Puff pastry from the freezer is the shortcut to impressive pot pies. Thaw in the fridge overnight.",
role_hints={
"pastry or crust": "Thaw puff pastry overnight in fridge",
"protein filling": "Cook through before adding to filling",
"vegetables": "Chop small; cook until just tender",
"sauce or binder": "Holds the filling together in the crust",
"seasoning": "Fillings need generous seasoning",
"sweet filling": "For dessert pies -- fruit + sugar",
},
),
AssemblyTemplate(
id=-13,
slug="pudding_custard",
icon="🍮",
descriptor="Dairy-based set dessert",
title="Pudding / Custard",
required=[
AssemblyRole("dairy or dairy-free milk", [
@ -601,10 +744,58 @@ ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
"Pour into dishes and refrigerate at least 2 hours to set.",
],
notes="UK-style pudding is broad — bread pudding, rice pudding, spotted dick, treacle sponge all count.",
role_hints={
"dairy or dairy-free milk": "Heat until steaming before adding to eggs",
"thickener or set": "Cornstarch for stovetop; eggs for baked custard",
"sweetener or flavouring": "Signals dessert intent -- required",
"sweetener": "Adjust to taste",
"flavouring": "Add off-heat to preserve aroma",
"starchy base": "For bread pudding or rice pudding",
"fruit": "Layer in or fold through before setting",
},
),
]
# Slug to template lookup (built once at import time)
_TEMPLATE_BY_SLUG: dict[str, AssemblyTemplate] = {
t.slug: t for t in ASSEMBLY_TEMPLATES
}
def get_templates_for_api() -> list[dict]:
"""Serialise all 13 templates for GET /api/recipes/templates.
Combines required and optional roles into a single ordered role_sequence
with required roles first.
"""
out = []
for tmpl in ASSEMBLY_TEMPLATES:
roles = []
for role in tmpl.required:
roles.append({
"display": role.display,
"required": True,
"keywords": role.keywords,
"hint": tmpl.role_hints.get(role.display, ""),
})
for role in tmpl.optional:
roles.append({
"display": role.display,
"required": False,
"keywords": role.keywords,
"hint": tmpl.role_hints.get(role.display, ""),
})
out.append({
"id": tmpl.slug,
"title": tmpl.title,
"icon": tmpl.icon,
"descriptor": tmpl.descriptor,
"role_sequence": roles,
})
return out
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
@ -679,3 +870,148 @@ def match_assembly_templates(
# Sort by optional coverage descending — best-matched templates first
results.sort(key=lambda s: s.match_count, reverse=True)
return results
def get_role_candidates(
template_slug: str,
role_display: str,
pantry_set: set[str],
prior_picks: list[str],
profile_index: dict[str, list[str]],
) -> dict:
"""Return ingredient candidates for one wizard step.
Splits candidates into 'compatible' (element overlap with prior picks)
and 'other' (valid for role but no overlap).
profile_index: {ingredient_name: [element_tag, ...]} -- pre-loaded from
Store.get_element_profiles() by the caller so this function stays DB-free.
Returns {"compatible": [...], "other": [...], "available_tags": [...]}
where each item is {"name": str, "in_pantry": bool, "tags": [str]}.
"""
tmpl = _TEMPLATE_BY_SLUG.get(template_slug)
if tmpl is None:
return {"compatible": [], "other": [], "available_tags": []}
# Find the AssemblyRole for this display name
target_role: AssemblyRole | None = None
for role in tmpl.required + tmpl.optional:
if role.display == role_display:
target_role = role
break
if target_role is None:
return {"compatible": [], "other": [], "available_tags": []}
# Build prior-pick element set for compatibility scoring
prior_elements: set[str] = set()
for pick in prior_picks:
prior_elements.update(profile_index.get(pick, []))
# Find pantry items that match this role
pantry_matches = _matches_role(target_role, pantry_set)
# Build keyword-based "other" candidates from role keywords not in pantry
pantry_lower = {p.lower() for p in pantry_set}
other_names: list[str] = []
for kw in target_role.keywords:
if not any(kw in item.lower() for item in pantry_lower):
if len(kw) >= 4:
other_names.append(kw.title())
def _make_item(name: str, in_pantry: bool) -> dict:
tags = profile_index.get(name, profile_index.get(name.lower(), []))
return {"name": name, "in_pantry": in_pantry, "tags": tags}
# Score: compatible if shares any element with prior picks (or no prior picks yet)
compatible: list[dict] = []
other: list[dict] = []
for name in pantry_matches:
item_elements = set(profile_index.get(name, []))
item = _make_item(name, in_pantry=True)
if not prior_elements or item_elements & prior_elements:
compatible.append(item)
else:
other.append(item)
for name in other_names:
other.append(_make_item(name, in_pantry=False))
# available_tags: union of all tags in the full candidate set
all_tags: set[str] = set()
for item in compatible + other:
all_tags.update(item["tags"])
return {
"compatible": compatible,
"other": other,
"available_tags": sorted(all_tags),
}
def build_from_selection(
template_slug: str,
role_overrides: dict[str, str],
pantry_set: set[str],
) -> "RecipeSuggestion | None":
"""Build a RecipeSuggestion from explicit role selections.
role_overrides: {role.display -> chosen pantry item name}
Returns None if template not found or any required role is uncovered.
"""
tmpl = _TEMPLATE_BY_SLUG.get(template_slug)
if tmpl is None:
return None
seed = _pantry_hash(pantry_set)
# Validate required roles: covered by override OR pantry match
matched_required: list[str] = []
for role in tmpl.required:
chosen = role_overrides.get(role.display)
if chosen:
matched_required.append(chosen)
else:
hits = _matches_role(role, pantry_set)
if not hits:
return None
matched_required.append(_pick_one(hits, seed + tmpl.id))
# Collect optional matches (override preferred, then pantry match)
matched_optional: list[str] = []
for role in tmpl.optional:
chosen = role_overrides.get(role.display)
if chosen:
matched_optional.append(chosen)
else:
hits = _matches_role(role, pantry_set)
if hits:
matched_optional.append(_pick_one(hits, seed + tmpl.id))
all_matched = matched_required + matched_optional
# Build title: prefer override items for personalisation
effective_pantry = pantry_set | set(role_overrides.values())
title = _personalized_title(tmpl, effective_pantry, seed + tmpl.id)
# Items in role_overrides that aren't in the user's pantry = shopping list
missing = [
item for item in role_overrides.values()
if item and item not in pantry_set
]
return RecipeSuggestion(
id=tmpl.id,
title=title,
match_count=len(all_matched),
element_coverage={},
swap_candidates=[],
matched_ingredients=all_matched,
missing_ingredients=missing,
directions=tmpl.directions,
notes=tmpl.notes,
level=1,
is_wildcard=False,
nutrition=None,
)

View file

@ -5,6 +5,12 @@ Each domain provides a two-level category hierarchy for browsing the recipe corp
Keyword matching is case-insensitive against the recipes.category column and the
recipes.keywords JSON array. A recipe may appear in multiple categories (correct).
Category values are either:
- list[str] flat keyword list (no subcategories)
- dict {"keywords": list[str], "subcategories": {name: list[str]}}
keywords covers the whole category (used for "All X" browse);
subcategories each have their own narrower keyword list.
These are starter mappings based on the food.com dataset structure. Run:
SELECT category, count(*) FROM recipes
@ -19,26 +25,467 @@ DOMAINS: dict[str, dict] = {
"cuisine": {
"label": "Cuisine",
"categories": {
"Italian": ["italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
"Mexican": ["mexican", "tex-mex", "taco", "enchilada", "burrito", "salsa", "guacamole"],
"Asian": ["asian", "chinese", "japanese", "thai", "korean", "vietnamese", "stir fry", "stir-fry", "ramen", "sushi"],
"American": ["american", "southern", "bbq", "barbecue", "comfort food", "cajun", "creole"],
"Mediterranean": ["mediterranean", "greek", "middle eastern", "turkish", "moroccan", "lebanese"],
"Indian": ["indian", "curry", "lentil", "dal", "tikka", "masala", "biryani"],
"European": ["french", "german", "spanish", "british", "irish", "scandinavian"],
"Latin American": ["latin american", "peruvian", "argentinian", "colombian", "cuban", "caribbean"],
"Italian": {
"keywords": ["italian", "pasta", "pizza", "risotto", "lasagna", "carbonara"],
"subcategories": {
"Sicilian": ["sicilian", "sicily", "arancini", "caponata",
"involtini", "cannoli"],
"Neapolitan": ["neapolitan", "naples", "pizza napoletana",
"sfogliatelle", "ragù"],
"Tuscan": ["tuscan", "tuscany", "ribollita", "bistecca",
"pappardelle", "crostini"],
"Roman": ["roman", "rome", "cacio e pepe", "carbonara",
"amatriciana", "gricia", "supplì"],
"Venetian": ["venetian", "venice", "risotto", "bigoli",
"baccalà", "sarde in saor"],
"Ligurian": ["ligurian", "liguria", "pesto", "focaccia",
"trofie", "farinata"],
},
},
"Mexican": {
"keywords": ["mexican", "taco", "enchilada", "burrito", "salsa",
"guacamole", "mole", "tamale"],
"subcategories": {
"Oaxacan": ["oaxacan", "oaxaca", "mole negro", "tlayuda",
"chapulines", "mezcal", "tasajo", "memelas"],
"Yucatecan": ["yucatecan", "yucatan", "cochinita pibil", "poc chuc",
"sopa de lima", "panuchos", "papadzules"],
"Veracruz": ["veracruz", "veracruzana", "huachinango",
"picadas", "enfrijoladas", "caldo de mariscos"],
"Street Food": ["taco", "elote", "tlacoyos", "torta", "tamale",
"quesadilla", "tostada", "sope", "gordita"],
"Mole": ["mole", "mole negro", "mole rojo", "mole verde",
"mole poblano", "mole amarillo", "pipián"],
"Baja / Cal-Mex": ["baja", "baja california", "cal-mex", "baja fish taco",
"fish taco", "carne asada fries", "california burrito",
"birria", "birria tacos", "quesabirria",
"lobster puerto nuevo", "tijuana", "ensenada",
"agua fresca", "caesar salad tijuana"],
"Mexico City": ["mexico city", "chilaquiles", "tlayuda cdmx",
"tacos de canasta", "torta ahogada", "pozole",
"chiles en nogada"],
},
},
"Asian": {
"keywords": ["asian", "chinese", "japanese", "thai", "korean", "vietnamese",
"stir fry", "stir-fry", "ramen", "sushi", "malaysian",
"taiwanese", "singaporean", "burmese", "cambodian",
"laotian", "mongolian", "hong kong"],
"subcategories": {
"Korean": ["korean", "kimchi", "bibimbap", "bulgogi", "japchae",
"doenjang", "gochujang", "tteokbokki", "sundubu",
"galbi", "jjigae", "kbbq", "korean fried chicken"],
"Japanese": ["japanese", "sushi", "ramen", "tempura", "miso",
"teriyaki", "udon", "soba", "bento", "yakitori",
"tonkatsu", "onigiri", "okonomiyaki", "takoyaki",
"kaiseki", "izakaya"],
"Chinese": ["chinese", "dim sum", "fried rice", "dumplings", "wonton",
"spring roll", "szechuan", "sichuan", "cantonese",
"chow mein", "mapo tofu", "lo mein", "hot pot",
"peking duck", "char siu", "congee"],
"Thai": ["thai", "pad thai", "green curry", "red curry",
"coconut milk", "lemongrass", "satay", "tom yum",
"larb", "khao man gai", "massaman", "pad see ew"],
"Vietnamese": ["vietnamese", "pho", "banh mi", "spring rolls",
"vermicelli", "nuoc cham", "bun bo hue",
"banh xeo", "com tam", "bun cha"],
"Filipino": ["filipino", "adobo", "sinigang", "pancit", "lumpia",
"kare-kare", "lechon", "sisig", "halo-halo",
"dinuguan", "tinola", "bistek"],
"Indonesian": ["indonesian", "rendang", "nasi goreng", "gado-gado",
"tempeh", "sambal", "soto", "opor ayam",
"bakso", "mie goreng", "nasi uduk"],
"Malaysian": ["malaysian", "laksa", "nasi lemak", "char kway teow",
"satay malaysia", "roti canai", "bak kut teh",
"cendol", "mee goreng mamak", "curry laksa"],
"Taiwanese": ["taiwanese", "beef noodle soup", "lu rou fan",
"oyster vermicelli", "scallion pancake taiwan",
"pork chop rice", "three cup chicken",
"bubble tea", "stinky tofu", "ba wan"],
"Singaporean": ["singaporean", "chicken rice", "chili crab",
"singaporean laksa", "bak chor mee", "rojak",
"kaya toast", "nasi padang", "satay singapore"],
"Burmese": ["burmese", "myanmar", "mohinga", "laphet thoke",
"tea leaf salad", "ohn no khao swe",
"mont di", "nangyi thoke"],
"Hong Kong": ["hong kong", "hk style", "pineapple bun",
"wonton noodle soup", "hk milk tea", "egg tart",
"typhoon shelter crab", "char siu bao", "jook",
"congee hk", "silk stocking tea", "dan tat",
"siu mai hk", "cheung fun"],
"Cambodian": ["cambodian", "khmer", "amok", "lok lak",
"kuy teav", "bai sach chrouk", "nom banh chok",
"samlor korko", "beef loc lac"],
"Laotian": ["laotian", "lao", "larb", "tam mak hoong",
"or lam", "khao niaw", "ping kai",
"naem khao", "khao piak sen", "mok pa"],
"Mongolian": ["mongolian", "buuz", "khuushuur", "tsuivan",
"boodog", "airag", "khorkhog", "bansh",
"guriltai shol", "suutei tsai"],
"South Asian Fusion": ["south asian fusion", "indo-chinese",
"hakka chinese", "chilli chicken",
"manchurian", "schezwan"],
},
},
"Indian": {
"keywords": ["indian", "curry", "lentil", "dal", "tikka", "masala",
"biryani", "naan", "chutney", "pakistani", "sri lankan",
"bangladeshi", "nepali"],
"subcategories": {
"North Indian": ["north indian", "punjabi", "mughal", "tikka masala",
"naan", "tandoori", "butter chicken", "palak paneer",
"chole", "rajma", "aloo gobi"],
"South Indian": ["south indian", "tamil", "kerala", "dosa", "idli",
"sambar", "rasam", "coconut chutney", "appam",
"fish curry kerala", "puttu", "payasam"],
"Bengali": ["bengali", "mustard fish", "hilsa", "shorshe ilish",
"mishti doi", "rasgulla", "kosha mangsho"],
"Gujarati": ["gujarati", "dhokla", "thepla", "undhiyu",
"khandvi", "fafda", "gujarati dal"],
"Pakistani": ["pakistani", "nihari", "haleem", "seekh kebab",
"karahi", "biryani karachi", "chapli kebab",
"halwa puri", "paya"],
"Sri Lankan": ["sri lankan", "kottu roti", "hoppers", "pol sambol",
"sri lankan curry", "lamprais", "string hoppers",
"wambatu moju"],
"Bangladeshi": ["bangladeshi", "bangladesh", "dhaka biryani",
"shutki", "pitha", "hilsa curry", "kacchi biryani",
"bhuna khichuri", "doi maach", "rezala"],
"Nepali": ["nepali", "dal bhat", "momos", "sekuwa",
"sel roti", "gundruk", "thukpa"],
},
},
"Mediterranean": {
"keywords": ["mediterranean", "greek", "middle eastern", "turkish",
"lebanese", "jewish", "palestinian", "yemeni", "egyptian",
"syrian", "iraqi", "jordanian"],
"subcategories": {
"Greek": ["greek", "feta", "tzatziki", "moussaka", "spanakopita",
"souvlaki", "dolmades", "spanakopita", "tiropita",
"galaktoboureko"],
"Turkish": ["turkish", "kebab", "borek", "meze", "baklava",
"lahmacun", "menemen", "pide", "iskender",
"kisir", "simit"],
"Syrian": ["syrian", "fattet hummus", "kibbeh syria",
"muhammara", "maklouba syria", "sfeeha",
"halawet el jibn"],
"Lebanese": ["lebanese", "middle eastern", "hummus", "falafel",
"tabbouleh", "kibbeh", "fattoush", "manakish",
"kafta", "sfiha"],
"Jewish": ["jewish", "israeli", "ashkenazi", "sephardic",
"shakshuka", "sabich", "za'atar", "tahini",
"zhug", "zhoug", "s'khug", "z'houg",
"hawaiij", "hawaij", "hawayej",
"matzo", "latke", "rugelach", "babka", "challah",
"cholent", "gefilte fish", "brisket", "kugel",
"new york jewish", "new york deli", "pastrami",
"knish", "lox", "bagel and lox", "jewish deli"],
"Palestinian": ["palestinian", "musakhan", "maqluba", "knafeh",
"maftoul", "freekeh", "sumac chicken"],
"Yemeni": ["yemeni", "saltah", "lahoh", "bint al-sahn",
"zhug", "zhoug", "hulba", "fahsa",
"hawaiij", "hawaij", "hawayej"],
"Egyptian": ["egyptian", "koshari", "molokhia", "mahshi",
"ful medames", "ta'ameya", "feteer meshaltet"],
},
},
"American": {
"keywords": ["american", "southern", "comfort food", "cajun", "creole",
"hawaiian", "tex-mex", "soul food"],
"subcategories": {
"Southern": ["southern", "soul food", "fried chicken",
"collard greens", "cornbread", "biscuits and gravy",
"mac and cheese", "sweet potato pie", "okra"],
"Cajun/Creole": ["cajun", "creole", "new orleans", "gumbo",
"jambalaya", "etouffee", "dirty rice", "po'boy",
"muffuletta", "red beans and rice"],
"Tex-Mex": ["tex-mex", "southwestern", "chili", "fajita",
"queso", "breakfast taco", "chile con carne"],
"New England": ["new england", "chowder", "lobster", "clam",
"maple", "yankee", "boston baked beans",
"johnnycake", "fish and chips"],
"Pacific Northwest": ["pacific northwest", "pnw", "dungeness crab",
"salmon", "cedar plank", "razor clam",
"geoduck", "chanterelle", "marionberry"],
"Hawaiian": ["hawaiian", "hawaii", "plate lunch", "loco moco",
"poke", "spam musubi", "kalua pig", "lau lau",
"haupia", "poi", "manapua", "garlic shrimp",
"saimin", "huli huli", "malasada"],
},
},
"BBQ & Smoke": {
"keywords": ["bbq", "barbecue", "smoked", "pit", "smoke ring",
"low and slow", "brisket", "pulled pork", "ribs"],
"subcategories": {
"Texas BBQ": ["texas bbq", "central texas bbq", "brisket",
"beef ribs", "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"],
"Kansas City BBQ": ["kansas city bbq", "kc bbq", "burnt ends",
"sweet bbq sauce", "tomato molasses sauce",
"baby back ribs kc"],
"Memphis BBQ": ["memphis bbq", "dry rub ribs", "wet ribs",
"memphis style", "dry rub pork"],
"Alabama BBQ": ["alabama bbq", "white sauce", "alabama white sauce",
"smoked chicken alabama"],
"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"],
"Backyard Grill": ["backyard bbq", "cookout", "grilled burgers",
"charcoal grill", "kettle grill", "tailgate"],
},
},
"European": {
"keywords": ["french", "german", "spanish", "british", "irish", "scottish",
"welsh", "scandinavian", "nordic", "eastern european"],
"subcategories": {
"French": ["french", "provencal", "beurre", "crepe",
"ratatouille", "cassoulet", "bouillabaisse"],
"Spanish": ["spanish", "paella", "tapas", "gazpacho",
"tortilla espanola", "chorizo"],
"German": ["german", "bratwurst", "sauerkraut", "schnitzel",
"pretzel", "strudel"],
"British": ["british", "english", "pub food", "cornish",
"shepherd's pie", "bangers", "toad in the hole",
"coronation chicken", "london", "londoner",
"cornish pasty", "ploughman's"],
"Irish": ["irish", "ireland", "colcannon", "coddle",
"irish stew", "soda bread", "boxty", "champ"],
"Scottish": ["scottish", "scotland", "haggis", "cullen skink",
"cranachan", "scotch broth", "glaswegian",
"neeps and tatties", "tablet"],
"Scandinavian": ["scandinavian", "nordic", "swedish", "norwegian",
"danish", "finnish", "gravlax", "swedish meatballs",
"lefse", "smörgåsbord", "fika", "crispbread",
"cardamom bun", "herring", "æbleskiver",
"lingonberry", "lutefisk", "janssons frestelse",
"knäckebröd", "kladdkaka"],
"Eastern European": ["eastern european", "polish", "russian", "ukrainian",
"czech", "hungarian", "pierogi", "borscht",
"goulash", "kielbasa", "varenyky", "pelmeni"],
},
},
"Latin American": {
"keywords": ["latin american", "peruvian", "argentinian", "colombian",
"cuban", "caribbean", "brazilian", "venezuelan", "chilean"],
"subcategories": {
"Peruvian": ["peruvian", "ceviche", "lomo saltado", "anticucho",
"aji amarillo", "causa", "leche de tigre",
"arroz con leche peru", "pollo a la brasa"],
"Brazilian": ["brazilian", "churrasco", "feijoada", "pao de queijo",
"brigadeiro", "coxinha", "moqueca", "vatapa",
"caipirinha", "acai bowl"],
"Colombian": ["colombian", "bandeja paisa", "arepas", "empanadas",
"sancocho", "ajiaco", "buñuelos", "changua"],
"Argentinian": ["argentinian", "asado", "chimichurri", "empanadas argentina",
"milanesa", "locro", "dulce de leche", "medialunas"],
"Venezuelan": ["venezuelan", "pabellón criollo", "arepas venezuela",
"hallacas", "cachapas", "tequeños", "caraotas"],
"Chilean": ["chilean", "cazuela", "pastel de choclo", "curanto",
"sopaipillas", "charquicán", "completo"],
"Cuban": ["cuban", "ropa vieja", "moros y cristianos",
"picadillo", "lechon cubano", "vaca frita",
"tostones", "platanos maduros"],
"Jamaican": ["jamaican", "jerk chicken", "jerk pork", "ackee saltfish",
"curry goat", "rice and peas", "escovitch",
"jamaican patty", "callaloo jamaica", "festival"],
"Puerto Rican": ["puerto rican", "mofongo", "pernil", "arroz con gandules",
"sofrito", "pasteles", "tostones pr", "tembleque",
"coquito", "asopao"],
"Dominican": ["dominican", "mangu", "sancocho dominicano",
"pollo guisado", "habichuelas guisadas",
"tostones dominicanos", "morir soñando"],
"Haitian": ["haitian", "griot", "pikliz", "riz et pois",
"joumou", "akra", "pain patate", "labouyi"],
"Trinidad": ["trinidadian", "doubles", "roti trinidad", "pelau",
"callaloo trinidad", "bake and shark",
"curry duck", "oil down"],
},
},
"Central American": {
"keywords": ["central american", "salvadoran", "guatemalan",
"honduran", "nicaraguan", "costa rican", "panamanian"],
"subcategories": {
"Salvadoran": ["salvadoran", "el salvador", "pupusas", "curtido",
"sopa de pata", "nuégados", "atol shuco"],
"Guatemalan": ["guatemalan", "pepián", "jocon", "kak'ik",
"hilachas", "rellenitos", "fiambre"],
"Costa Rican": ["costa rican", "gallo pinto", "casado",
"olla de carne", "arroz con leche cr",
"tres leches cr"],
"Honduran": ["honduran", "baleadas", "sopa de caracol",
"tapado", "machuca", "catrachitas"],
"Nicaraguan": ["nicaraguan", "nacatamal", "vigorón", "indio viejo",
"gallo pinto nicaragua", "güirilas"],
},
},
"African": {
"keywords": ["african", "west african", "east african", "ethiopian",
"nigerian", "ghanaian", "kenyan", "south african",
"senegalese", "tunisian"],
"subcategories": {
"West African": ["west african", "nigerian", "ghanaian",
"jollof rice", "egusi soup", "fufu", "suya",
"groundnut stew", "kelewele", "kontomire",
"waakye", "ofam", "bitterleaf soup"],
"Senegalese": ["senegalese", "senegal", "thieboudienne",
"yassa", "mafe", "thiou", "ceebu jen",
"domoda"],
"Ethiopian & Eritrean": ["ethiopian", "eritrean", "injera", "doro wat",
"kitfo", "tibs", "shiro", "misir wat",
"gomen", "ful ethiopian", "tegamino"],
"East African": ["east african", "kenyan", "tanzanian", "ugandan",
"nyama choma", "ugali", "sukuma wiki",
"pilau kenya", "mandazi", "matoke",
"githeri", "irio"],
"North African": ["north african", "tunisian", "algerian", "libyan",
"brik", "lablabi", "merguez", "shakshuka tunisian",
"harissa tunisian", "couscous algerian"],
"South African": ["south african", "braai", "bobotie", "boerewors",
"bunny chow", "pap", "chakalaka", "biltong",
"malva pudding", "koeksister", "potjiekos"],
"Moroccan": ["moroccan", "tagine", "couscous morocco",
"harissa", "chermoula", "preserved lemon",
"pastilla", "mechoui", "bastilla"],
},
},
"Pacific & Oceania": {
"keywords": ["pacific", "oceania", "polynesian", "melanesian",
"micronesian", "maori", "fijian", "samoan", "tongan",
"hawaiian", "australian", "new zealand"],
"subcategories": {
"Māori / New Zealand": ["maori", "new zealand", "hangi", "rewena bread",
"boil-up", "paua", "kumara", "pavlova nz",
"whitebait fritter", "kina", "hokey pokey"],
"Australian": ["australian", "meat pie", "lamington",
"anzac biscuits", "damper", "barramundi",
"vegemite", "pavlova australia", "tim tam",
"sausage sizzle", "chiko roll", "fairy bread"],
"Fijian": ["fijian", "fiji", "kokoda", "lovo",
"rourou", "palusami fiji", "duruka",
"vakalolo"],
"Samoan": ["samoan", "samoa", "palusami", "oka",
"fa'ausi", "chop suey samoa", "sapasui",
"koko alaisa", "supo esi"],
"Tongan": ["tongan", "tonga", "lu pulu", "'ota 'ika",
"fekkai", "faikakai topai", "kapisi pulu"],
"Papua New Guinean": ["papua new guinea", "png", "mumu",
"sago", "aibika", "kaukau",
"taro png", "coconut crab"],
"Hawaiian": ["hawaiian", "hawaii", "poke", "loco moco",
"plate lunch", "kalua pig", "haupia",
"spam musubi", "poi", "malasada"],
},
},
"Central Asian & Caucasus": {
"keywords": ["central asian", "caucasus", "georgian", "armenian", "uzbek",
"afghan", "persian", "iranian", "azerbaijani", "kazakh"],
"subcategories": {
"Persian / Iranian": ["persian", "iranian", "ghormeh sabzi", "fesenjan",
"tahdig", "joojeh kabab", "ash reshteh",
"zereshk polo", "khoresh", "mast o khiar",
"kashk-e-bademjan", "mirza ghasemi",
"baghali polo"],
"Georgian": ["georgian", "georgia", "khachapuri", "khinkali",
"churchkhela", "ajapsandali", "satsivi",
"pkhali", "lobiani", "badrijani nigvzit"],
"Armenian": ["armenian", "dolma armenia", "lahmajoun",
"manti armenia", "ghapama", "basturma",
"harissa armenia", "nazook", "tolma"],
"Azerbaijani": ["azerbaijani", "azerbaijan", "plov azerbaijan",
"dolma azeri", "dushbara", "levengi",
"shah plov", "gutab"],
"Uzbek": ["uzbek", "uzbekistan", "plov", "samsa",
"lagman", "shashlik", "manti uzbek",
"non bread", "dimlama", "sumalak"],
"Afghan": ["afghan", "afghanistan", "kabuli pulao", "mantu",
"bolani", "qorma", "ashak", "shorwa",
"aushak", "borani banjan"],
"Kazakh": ["kazakh", "beshbarmak", "kuyrdak", "baursak",
"kurt", "shubat", "kazy"],
},
},
},
},
"meal_type": {
"label": "Meal Type",
"categories": {
"Breakfast": ["breakfast", "brunch", "eggs", "pancakes", "waffles", "oatmeal", "muffin"],
"Lunch": ["lunch", "sandwich", "wrap", "salad", "soup", "light meal"],
"Dinner": ["dinner", "main dish", "entree", "main course", "supper"],
"Snack": ["snack", "appetizer", "finger food", "dip", "bite", "starter"],
"Dessert": ["dessert", "cake", "cookie", "pie", "sweet", "pudding", "ice cream", "brownie"],
"Beverage": ["drink", "smoothie", "cocktail", "beverage", "juice", "shake"],
"Side Dish": ["side dish", "side", "accompaniment", "garnish"],
"Breakfast": {
"keywords": ["breakfast", "brunch", "eggs", "pancakes", "waffles",
"oatmeal", "muffin"],
"subcategories": {
"Eggs": ["egg", "omelette", "frittata", "quiche",
"scrambled", "benedict", "shakshuka"],
"Pancakes & Waffles": ["pancake", "waffle", "crepe", "french toast"],
"Baked Goods": ["muffin", "scone", "biscuit", "quick bread",
"coffee cake", "danish"],
"Oats & Grains": ["oatmeal", "granola", "porridge", "muesli",
"overnight oats"],
},
},
"Lunch": {
"keywords": ["lunch", "sandwich", "wrap", "salad", "soup", "light meal"],
"subcategories": {
"Sandwiches": ["sandwich", "sub", "hoagie", "panini", "club",
"grilled cheese", "blt"],
"Salads": ["salad", "grain bowl", "chopped", "caesar",
"niçoise", "cobb"],
"Soups": ["soup", "bisque", "chowder", "gazpacho",
"minestrone", "lentil soup"],
"Wraps": ["wrap", "burrito bowl", "pita", "lettuce wrap",
"quesadilla"],
},
},
"Dinner": {
"keywords": ["dinner", "main dish", "entree", "main course", "supper"],
"subcategories": {
"Casseroles": ["casserole", "bake", "gratin", "lasagna",
"sheperd's pie", "pot pie"],
"Stews": ["stew", "braise", "slow cooker", "pot roast",
"daube", "ragù"],
"Grilled": ["grilled", "grill", "barbecue", "charred",
"kebab", "skewer"],
"Stir-Fries": ["stir fry", "stir-fry", "wok", "sauté",
"sauteed"],
"Roasts": ["roast", "roasted", "oven", "baked chicken",
"pot roast"],
},
},
"Snack": {
"keywords": ["snack", "appetizer", "finger food", "dip", "bite",
"starter"],
"subcategories": {
"Dips & Spreads": ["dip", "spread", "hummus", "guacamole",
"salsa", "pate"],
"Finger Foods": ["finger food", "bite", "skewer", "slider",
"wing", "nugget"],
"Chips & Crackers": ["chip", "cracker", "crisp", "popcorn",
"pretzel"],
},
},
"Dessert": {
"keywords": ["dessert", "cake", "cookie", "pie", "sweet", "pudding",
"ice cream", "brownie"],
"subcategories": {
"Cakes": ["cake", "cupcake", "layer cake", "bundt",
"cheesecake", "torte"],
"Cookies & Bars": ["cookie", "brownie", "blondie", "bar",
"biscotti", "shortbread"],
"Pies & Tarts": ["pie", "tart", "galette", "cobbler", "crisp",
"crumble"],
"Frozen": ["ice cream", "gelato", "sorbet", "frozen dessert",
"popsicle", "granita"],
"Puddings": ["pudding", "custard", "mousse", "panna cotta",
"flan", "creme brulee"],
"Candy": ["candy", "fudge", "truffle", "brittle",
"caramel", "toffee"],
},
},
"Beverage": ["drink", "smoothie", "cocktail", "beverage", "juice", "shake"],
"Side Dish": ["side dish", "side", "accompaniment", "garnish"],
},
},
"dietary": {
@ -56,31 +503,128 @@ DOMAINS: dict[str, dict] = {
"main_ingredient": {
"label": "Main Ingredient",
"categories": {
"Chicken": ["chicken", "poultry", "turkey"],
"Beef": ["beef", "ground beef", "steak", "brisket", "pot roast"],
"Pork": ["pork", "bacon", "ham", "sausage", "prosciutto"],
"Fish": ["fish", "salmon", "tuna", "tilapia", "cod", "halibut", "shrimp", "seafood"],
"Pasta": ["pasta", "noodle", "spaghetti", "penne", "fettuccine", "linguine"],
"Vegetables": ["vegetable", "veggie", "cauliflower", "broccoli", "zucchini", "eggplant"],
"Eggs": ["egg", "frittata", "omelette", "omelet", "quiche"],
"Legumes": ["bean", "lentil", "chickpea", "tofu", "tempeh", "edamame"],
"Grains": ["rice", "quinoa", "barley", "farro", "oat", "grain"],
"Cheese": ["cheese", "ricotta", "mozzarella", "parmesan", "cheddar"],
# keywords use exact inferred_tag strings (main:X) — indexed into recipe_browser_fts.
"Chicken": {
"keywords": ["main:Chicken"],
"subcategories": {
"Baked": ["baked chicken", "roast chicken", "chicken casserole",
"chicken bake"],
"Grilled": ["grilled chicken", "chicken kebab", "bbq chicken",
"chicken skewer"],
"Fried": ["fried chicken", "chicken cutlet", "chicken schnitzel",
"crispy chicken"],
"Stewed": ["chicken stew", "chicken soup", "coq au vin",
"chicken curry", "chicken braise"],
},
},
"Beef": {
"keywords": ["main:Beef"],
"subcategories": {
"Ground Beef": ["ground beef", "hamburger", "meatball", "meatloaf",
"bolognese", "burger"],
"Steak": ["steak", "sirloin", "ribeye", "flank steak",
"filet mignon", "t-bone"],
"Roasts": ["beef roast", "pot roast", "brisket", "prime rib",
"chuck roast"],
"Stews": ["beef stew", "beef braise", "beef bourguignon",
"short ribs"],
},
},
"Pork": {
"keywords": ["main:Pork"],
"subcategories": {
"Chops": ["pork chop", "pork loin", "pork cutlet"],
"Pulled/Slow": ["pulled pork", "pork shoulder", "pork butt",
"carnitas", "slow cooker pork"],
"Sausage": ["sausage", "bratwurst", "chorizo", "andouille",
"Italian sausage"],
"Ribs": ["pork ribs", "baby back ribs", "spare ribs",
"pork belly"],
},
},
"Fish": {
"keywords": ["main:Fish"],
"subcategories": {
"Salmon": ["salmon", "smoked salmon", "gravlax"],
"Tuna": ["tuna", "albacore", "ahi"],
"White Fish": ["cod", "tilapia", "halibut", "sole", "snapper",
"flounder", "bass"],
"Shellfish": ["shrimp", "prawn", "crab", "lobster", "scallop",
"mussel", "clam", "oyster"],
},
},
"Pasta": ["main:Pasta"],
"Vegetables": {
"keywords": ["main:Vegetables"],
"subcategories": {
"Root Veg": ["potato", "sweet potato", "carrot", "beet",
"parsnip", "turnip"],
"Leafy": ["spinach", "kale", "chard", "arugula",
"collard greens", "lettuce"],
"Brassicas": ["broccoli", "cauliflower", "brussels sprouts",
"cabbage", "bok choy"],
"Nightshades": ["tomato", "eggplant", "bell pepper", "zucchini",
"squash"],
"Mushrooms": ["mushroom", "portobello", "shiitake", "oyster mushroom",
"chanterelle"],
},
},
"Eggs": ["main:Eggs"],
"Legumes": ["main:Legumes"],
"Grains": ["main:Grains"],
"Cheese": ["main:Cheese"],
},
},
}
def _get_category_def(domain: str, category: str) -> list[str] | dict | None:
"""Return the raw category definition, or None if not found."""
return DOMAINS.get(domain, {}).get("categories", {}).get(category)
def get_domain_labels() -> list[dict]:
"""Return [{id, label}] for all available domains."""
return [{"id": k, "label": v["label"]} for k, v in DOMAINS.items()]
def get_keywords_for_category(domain: str, category: str) -> list[str]:
"""Return the keyword list for a domain/category pair, or [] if not found."""
domain_data = DOMAINS.get(domain, {})
categories = domain_data.get("categories", {})
return categories.get(category, [])
"""Return the keyword list for the category (top-level, covers all subcategories).
For flat categories returns the list directly.
For nested categories returns the 'keywords' key.
Returns [] if category or domain not found.
"""
cat_def = _get_category_def(domain, category)
if cat_def is None:
return []
if isinstance(cat_def, list):
return cat_def
return cat_def.get("keywords", [])
def category_has_subcategories(domain: str, category: str) -> bool:
"""Return True when a category has a subcategory level."""
cat_def = _get_category_def(domain, category)
if not isinstance(cat_def, dict):
return False
return bool(cat_def.get("subcategories"))
def get_subcategory_names(domain: str, category: str) -> list[str]:
"""Return subcategory names for a category, or [] if none exist."""
cat_def = _get_category_def(domain, category)
if not isinstance(cat_def, dict):
return []
return list(cat_def.get("subcategories", {}).keys())
def get_keywords_for_subcategory(domain: str, category: str, subcategory: str) -> list[str]:
"""Return keyword list for a specific subcategory, or [] if not found."""
cat_def = _get_category_def(domain, category)
if not isinstance(cat_def, dict):
return []
return cat_def.get("subcategories", {}).get(subcategory, [])
def get_category_names(domain: str) -> list[str]:

View file

@ -84,8 +84,9 @@ class ElementClassifier:
name = ingredient_name.lower().strip()
if not name:
return IngredientProfile(name="", elements=[], source="heuristic")
c = self._store._cp
row = self._store._fetch_one(
"SELECT * FROM ingredient_profiles WHERE name = ?", (name,)
f"SELECT * FROM {c}ingredient_profiles WHERE name = ?", (name,)
)
if row:
return self._row_to_profile(row)

View file

@ -84,7 +84,13 @@ class LLMRecipeGenerator:
if template.aromatics:
lines.append(f"Preferred aromatics: {', '.join(template.aromatics[:4])}")
unit_line = (
"Use metric units (grams, ml, Celsius) for all quantities and temperatures."
if req.unit_system == "metric"
else "Use imperial units (oz, cups, Fahrenheit) for all quantities and temperatures."
)
lines += [
unit_line,
"",
"Reply using EXACTLY this plain-text format — no markdown, no bold, no extra commentary:",
"Title: <name of the dish>",
@ -118,8 +124,14 @@ class LLMRecipeGenerator:
if allergy_list:
lines.append(f"Must NOT contain: {', '.join(allergy_list)}")
unit_line = (
"Use metric units (grams, ml, Celsius) for all quantities and temperatures."
if req.unit_system == "metric"
else "Use imperial units (oz, cups, Fahrenheit) for all quantities and temperatures."
)
lines += [
"Treat any mystery ingredient as a wildcard — use your imagination.",
unit_line,
"Reply using EXACTLY this plain-text format — no markdown, no bold:",
"Title: <name of the dish>",
"Ingredients: <comma-separated list>",
@ -131,12 +143,14 @@ class LLMRecipeGenerator:
return "\n".join(lines)
_MODEL_CANDIDATES: list[str] = ["Ouro-2.6B-Thinking", "Ouro-1.4B"]
_SERVICE_TYPE = "cf-text"
_TTL_S = 300.0
_CALLER = "kiwi-recipe"
def _get_llm_context(self):
"""Return a sync context manager that yields an Allocation or None.
When CF_ORCH_URL is set, uses CFOrchClient to acquire a vLLM allocation
When CF_ORCH_URL is set, uses CFOrchClient to acquire a cf-text allocation
(which handles service lifecycle and VRAM). Falls back to nullcontext(None)
when the env var is absent or CFOrchClient raises on construction.
"""
@ -146,10 +160,9 @@ class LLMRecipeGenerator:
from circuitforge_orch.client import CFOrchClient
client = CFOrchClient(cf_orch_url)
return client.allocate(
service="vllm",
model_candidates=self._MODEL_CANDIDATES,
ttl_s=300.0,
caller="kiwi-recipe",
service=self._SERVICE_TYPE,
ttl_s=self._TTL_S,
caller=self._CALLER,
)
except Exception as exc:
logger.debug("CFOrchClient init failed, falling back to direct URL: %s", exc)
@ -168,6 +181,19 @@ class LLMRecipeGenerator:
try:
alloc = ctx.__enter__()
except Exception as exc:
msg = str(exc)
# 429 = coordinator at capacity (all nodes at max_concurrent limit).
# Don't fall back to LLMRouter — it's also overloaded and the slow
# fallback causes nginx 504s. Return "" fast so the caller degrades
# gracefully (empty recipe result) rather than timing out.
if "429" in msg or "max_concurrent" in msg.lower():
logger.info("cf-orch at capacity — returning empty result (graceful degradation)")
if ctx is not None:
try:
ctx.__exit__(None, None, None)
except Exception:
pass
return ""
logger.debug("cf-orch allocation failed, falling back to LLMRouter: %s", exc)
ctx = None # __enter__ raised — do not call __exit__

View file

@ -0,0 +1,160 @@
"""
Shopping locale configuration.
Maps a locale key to Amazon domain, currency metadata, and retailer availability.
Instacart and Walmart are US/CA-only; all other locales get Amazon only.
Amazon Fresh (&i=amazonfresh) is US-only international domains use the general
grocery department (&rh=n:16310101) where available, plain search elsewhere.
"""
from __future__ import annotations
from typing import TypedDict
class LocaleConfig(TypedDict):
amazon_domain: str
amazon_grocery_dept: str # URL fragment for grocery department on this locale's site
currency_code: str
currency_symbol: str
instacart: bool
walmart: bool
LOCALES: dict[str, LocaleConfig] = {
"us": {
"amazon_domain": "amazon.com",
"amazon_grocery_dept": "i=amazonfresh",
"currency_code": "USD",
"currency_symbol": "$",
"instacart": True,
"walmart": True,
},
"ca": {
"amazon_domain": "amazon.ca",
"amazon_grocery_dept": "rh=n:6967215011", # Grocery dept on .ca # gitleaks:allow
"currency_code": "CAD",
"currency_symbol": "CA$",
"instacart": True,
"walmart": False,
},
"gb": {
"amazon_domain": "amazon.co.uk",
"amazon_grocery_dept": "rh=n:340831031", # Grocery dept on .co.uk
"currency_code": "GBP",
"currency_symbol": "£",
"instacart": False,
"walmart": False,
},
"au": {
"amazon_domain": "amazon.com.au",
"amazon_grocery_dept": "rh=n:5765081051", # Pantry/grocery on .com.au # gitleaks:allow
"currency_code": "AUD",
"currency_symbol": "A$",
"instacart": False,
"walmart": False,
},
"nz": {
# NZ has no Amazon storefront — route to .com.au as nearest option
"amazon_domain": "amazon.com.au",
"amazon_grocery_dept": "rh=n:5765081051", # gitleaks:allow
"currency_code": "NZD",
"currency_symbol": "NZ$",
"instacart": False,
"walmart": False,
},
"de": {
"amazon_domain": "amazon.de",
"amazon_grocery_dept": "rh=n:340843031", # Lebensmittel & Getränke
"currency_code": "EUR",
"currency_symbol": "",
"instacart": False,
"walmart": False,
},
"fr": {
"amazon_domain": "amazon.fr",
"amazon_grocery_dept": "rh=n:197858031",
"currency_code": "EUR",
"currency_symbol": "",
"instacart": False,
"walmart": False,
},
"it": {
"amazon_domain": "amazon.it",
"amazon_grocery_dept": "rh=n:525616031",
"currency_code": "EUR",
"currency_symbol": "",
"instacart": False,
"walmart": False,
},
"es": {
"amazon_domain": "amazon.es",
"amazon_grocery_dept": "rh=n:599364031",
"currency_code": "EUR",
"currency_symbol": "",
"instacart": False,
"walmart": False,
},
"nl": {
"amazon_domain": "amazon.nl",
"amazon_grocery_dept": "rh=n:16584827031",
"currency_code": "EUR",
"currency_symbol": "",
"instacart": False,
"walmart": False,
},
"se": {
"amazon_domain": "amazon.se",
"amazon_grocery_dept": "rh=n:20741393031",
"currency_code": "SEK",
"currency_symbol": "kr",
"instacart": False,
"walmart": False,
},
"jp": {
"amazon_domain": "amazon.co.jp",
"amazon_grocery_dept": "rh=n:2246283051", # gitleaks:allow
"currency_code": "JPY",
"currency_symbol": "¥",
"instacart": False,
"walmart": False,
},
"in": {
"amazon_domain": "amazon.in",
"amazon_grocery_dept": "rh=n:2454178031", # gitleaks:allow
"currency_code": "INR",
"currency_symbol": "",
"instacart": False,
"walmart": False,
},
"mx": {
"amazon_domain": "amazon.com.mx",
"amazon_grocery_dept": "rh=n:10737659011",
"currency_code": "MXN",
"currency_symbol": "MX$",
"instacart": False,
"walmart": False,
},
"br": {
"amazon_domain": "amazon.com.br",
"amazon_grocery_dept": "rh=n:17878420011",
"currency_code": "BRL",
"currency_symbol": "R$",
"instacart": False,
"walmart": False,
},
"sg": {
"amazon_domain": "amazon.sg",
"amazon_grocery_dept": "rh=n:6981647051", # gitleaks:allow
"currency_code": "SGD",
"currency_symbol": "S$",
"instacart": False,
"walmart": False,
},
}
DEFAULT_LOCALE = "us"
def get_locale(key: str) -> LocaleConfig:
"""Return locale config for *key*, falling back to US if unknown."""
return LOCALES.get(key, LOCALES[DEFAULT_LOCALE])

View file

@ -21,7 +21,6 @@ if TYPE_CHECKING:
from app.db.store import Store
from app.models.schemas.recipe import GroceryLink, NutritionPanel, RecipeRequest, RecipeResult, RecipeSuggestion, SwapCandidate
from app.services.recipe.assembly_recipes import match_assembly_templates
from app.services.recipe.element_classifier import ElementClassifier
from app.services.recipe.grocery_links import GroceryLinkBuilder
from app.services.recipe.substitution_engine import SubstitutionEngine
@ -156,6 +155,24 @@ _PANTRY_LABEL_SYNONYMS: dict[str, str] = {
}
# When a pantry item is in a secondary state (e.g. bread → "stale"), expand
# the pantry set with terms that recipe ingredients commonly use to describe
# that state. This lets "stale bread" in a recipe ingredient match a pantry
# entry that is simply called "Bread" but is past its nominal use-by date.
# Each key is (category_in_SECONDARY_WINDOW, label_returned_by_secondary_state).
# Values are additional strings added to the pantry set for FTS coverage.
_SECONDARY_STATE_SYNONYMS: dict[tuple[str, str], list[str]] = {
("bread", "stale"): ["stale bread", "day-old bread", "old bread", "dried bread"],
("bakery", "day-old"): ["day-old bread", "stale bread", "stale pastry"],
("bananas", "overripe"): ["overripe bananas", "very ripe banana", "ripe bananas", "mashed banana"],
("milk", "sour"): ["sour milk", "slightly sour milk", "buttermilk"],
("dairy", "sour"): ["sour milk", "slightly sour milk"],
("cheese", "well-aged"): ["parmesan rind", "cheese rind", "aged cheese"],
("rice", "day-old"): ["day-old rice", "leftover rice", "cold rice", "cooked rice"],
("tortillas", "stale"): ["stale tortillas", "dried tortillas", "day-old tortillas"],
}
# Matches leading quantity/unit prefixes in recipe ingredient strings,
# e.g. "2 cups flour" → "flour", "1/2 c. ketchup" → "ketchup",
# "3 oz. butter" → "butter"
@ -285,14 +302,24 @@ def _prep_note_for(ingredient: str) -> str | None:
return template.format(ingredient=ingredient_name)
def _expand_pantry_set(pantry_items: list[str]) -> set[str]:
def _expand_pantry_set(
pantry_items: list[str],
secondary_pantry_items: dict[str, str] | None = None,
) -> set[str]:
"""Return pantry_set expanded with canonical recipe-corpus synonyms.
For each pantry item, checks _PANTRY_LABEL_SYNONYMS for substring matches
and adds the canonical form. This lets single-word recipe ingredients
("hamburger", "chicken") match product-label pantry entries
("burger patties", "rotisserie chicken").
If secondary_pantry_items is provided (product_name state label), items
in a secondary state also receive state-specific synonym expansion so that
recipe ingredients like "stale bread" or "day-old rice" are matched.
"""
from app.services.expiration_predictor import ExpirationPredictor
_predictor = ExpirationPredictor()
expanded: set[str] = set()
for item in pantry_items:
lower = item.lower().strip()
@ -300,6 +327,15 @@ def _expand_pantry_set(pantry_items: list[str]) -> set[str]:
for pattern, canonical in _PANTRY_LABEL_SYNONYMS.items():
if pattern in lower:
expanded.add(canonical)
# Secondary state expansion — adds terms like "stale bread", "day-old rice"
if secondary_pantry_items and item in secondary_pantry_items:
state_label = secondary_pantry_items[item]
category = _predictor.get_category_from_product(item)
if category:
synonyms = _SECONDARY_STATE_SYNONYMS.get((category, state_label), [])
expanded.update(synonyms)
return expanded
@ -517,13 +553,6 @@ def _build_source_url(row: dict) -> str | None:
return None
_ASSEMBLY_TIER_LIMITS: dict[str, int] = {
"free": 2,
"paid": 4,
"premium": 6,
}
# Method complexity classification patterns
_EASY_METHODS = re.compile(
r"\b(microwave|mix|stir|blend|toast|assemble|heat)\b", re.IGNORECASE
@ -570,6 +599,19 @@ def _hard_day_sort_tier(
return 2
def _estimate_time_min(directions: list[str], complexity: str) -> int:
"""Rough cooking time estimate from step count and method complexity.
Not precise intended for filtering and display hints only.
"""
steps = len(directions)
if complexity == "easy":
return max(5, 10 + steps * 3)
if complexity == "involved":
return max(20, 30 + steps * 6)
return max(10, 20 + steps * 4) # moderate
def _classify_method_complexity(
directions: list[str],
available_equipment: list[str] | None = None,
@ -629,7 +671,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)
pantry_set = _expand_pantry_set(req.pantry_items, req.secondary_pantry_items or None)
if req.level >= 3:
from app.services.recipe.llm_recipe import LLMRecipeGenerator
@ -637,6 +679,11 @@ class RecipeEngine:
return gen.generate(req, profiles, gaps)
# Level 1 & 2: deterministic path
# L1 ("Use What I Have") applies strict quality gates:
# - exclude_generic: filter catch-all recipes at the DB level
# - effective_max_missing: default to 2 when user hasn't set a cap
# - match ratio: require ≥60% ingredient coverage to avoid low-signal results
_l1 = req.level == 1 and not req.shopping_mode
nf = req.nutrition_filters
rows = self._store.search_recipes_by_ingredients(
req.pantry_items,
@ -647,7 +694,16 @@ class RecipeEngine:
max_carbs_g=nf.max_carbs_g,
max_sodium_mg=nf.max_sodium_mg,
excluded_ids=req.excluded_ids or [],
exclude_generic=_l1,
)
# L1 strict defaults: cap missing ingredients and require a minimum ratio.
_L1_MAX_MISSING_DEFAULT = 2
_L1_MIN_MATCH_RATIO = 0.6
effective_max_missing = req.max_missing
if _l1 and effective_max_missing is None:
effective_max_missing = _L1_MAX_MISSING_DEFAULT
suggestions = []
hard_day_tier_map: dict[int, int] = {} # recipe_id → tier when hard_day_mode
@ -690,19 +746,37 @@ class RecipeEngine:
missing.append(n)
# Filter by max_missing — skipped in shopping mode (user is willing to buy)
if not req.shopping_mode and req.max_missing is not None and len(missing) > req.max_missing:
if not req.shopping_mode and effective_max_missing is not None and len(missing) > effective_max_missing:
continue
# "Can make now" toggle: drop any recipe that still has missing ingredients
# after swaps are applied. Swapped items count as covered.
if req.pantry_match_only and missing:
continue
# L1 match ratio gate: drop results where less than 60% of the recipe's
# ingredients are in the pantry. Prevents low-signal results like a
# 10-ingredient recipe matching on only one common item.
if _l1 and ingredient_names:
match_ratio = len(matched) / len(ingredient_names)
if match_ratio < _L1_MIN_MATCH_RATIO:
continue
# Parse directions — needed for complexity, hard_day_mode, and time estimate.
directions: list[str] = row.get("directions") or []
if isinstance(directions, str):
try:
directions = json.loads(directions)
except Exception:
directions = [directions]
# Compute complexity for every suggestion (used for badge + filter).
row_complexity = _classify_method_complexity(directions, available_equipment)
row_time_min = _estimate_time_min(directions, row_complexity)
# Filter and tier-rank by hard_day_mode
if req.hard_day_mode:
directions: list[str] = row.get("directions") or []
if isinstance(directions, str):
try:
directions = json.loads(directions)
except Exception:
directions = [directions]
complexity = _classify_method_complexity(directions, available_equipment)
if complexity == "involved":
if row_complexity == "involved":
continue
hard_day_tier_map[row["id"]] = _hard_day_sort_tier(
title=row.get("title", ""),
@ -710,6 +784,14 @@ class RecipeEngine:
directions=directions,
)
# Complexity filter (#58)
if req.complexity_filter and row_complexity != req.complexity_filter:
continue
# Max time filter (#58)
if req.max_time_min is not None and row_time_min > req.max_time_min:
continue
# Level 2: also add dietary constraint swaps from substitution_pairs
if req.level == 2 and req.constraints:
for ing in ingredient_names:
@ -759,41 +841,21 @@ class RecipeEngine:
level=req.level,
nutrition=nutrition if has_nutrition else None,
source_url=_build_source_url(row),
complexity=row_complexity,
estimated_time_min=row_time_min,
))
# Assembly-dish templates (burrito, fried rice, pasta, etc.)
# Expiry boost: when expiry_first, the pantry_items list is already sorted
# by expiry urgency — treat the first slice as the "expiring" set so templates
# that use those items bubble up in the merged ranking.
expiring_set: set[str] = set()
if req.expiry_first:
expiring_set = _expand_pantry_set(req.pantry_items[:10])
assembly = match_assembly_templates(
pantry_items=req.pantry_items,
pantry_set=pantry_set,
excluded_ids=req.excluded_ids or [],
expiring_set=expiring_set,
)
# Cap by tier — lifted in shopping mode since missing-ingredient templates
# are desirable there (each fires an affiliate link opportunity).
if not req.shopping_mode:
assembly_limit = _ASSEMBLY_TIER_LIMITS.get(req.tier, 3)
assembly = assembly[:assembly_limit]
# Interleave: sort templates and corpus recipes together.
# 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. Assembly templates are inherently
# simple so they default to tier 1 when not in the tier map.
# Normal mode: sort by match_count only.
# then by match_count within each tier.
# Normal mode: sort by match_count descending.
if req.hard_day_mode and hard_day_tier_map:
suggestions = sorted(
assembly + suggestions,
suggestions,
key=lambda s: (hard_day_tier_map.get(s.id, 1), -s.match_count),
)
else:
suggestions = sorted(assembly + suggestions, key=lambda s: s.match_count, reverse=True)
suggestions = sorted(suggestions, key=lambda s: -s.match_count)
# Build grocery list — deduplicated union of all missing ingredients
seen: set[str] = set()

View file

@ -55,11 +55,12 @@ class SubstitutionEngine:
ingredient_name: str,
constraint: str,
) -> list[SubstitutionSwap]:
rows = self._store._fetch_all("""
c = self._store._cp
rows = self._store._fetch_all(f"""
SELECT substitute_name, constraint_label,
fat_delta, moisture_delta, glutamate_delta, protein_delta,
occurrence_count, compensation_hints
FROM substitution_pairs
FROM {c}substitution_pairs
WHERE original_name = ? AND constraint_label = ?
ORDER BY occurrence_count DESC
""", (ingredient_name.lower(), constraint))

View file

@ -0,0 +1,316 @@
"""
Recipe tag inference engine.
Derives normalized tags from a recipe's title, ingredient names, existing corpus
tags (category + keywords), enriched ingredient profile data, and optional
nutrition data.
Tags are organized into five namespaces:
cuisine:* -- cuisine/region classification
dietary:* -- dietary restriction / nutrition profile
flavor:* -- flavor profile (spicy, smoky, sweet, etc.)
time:* -- effort / time signals
meal:* -- meal type
can_be:* -- achievable with substitutions (e.g. can_be:Gluten-Free)
Output is a flat sorted list of strings, e.g.:
["can_be:Gluten-Free", "cuisine:Italian", "dietary:Low-Carb",
"flavor:Savory", "flavor:Umami", "time:Quick"]
These populate recipes.inferred_tags and are FTS5-indexed so browse domain
queries find recipes the food.com corpus tags alone would miss.
"""
from __future__ import annotations
# ---------------------------------------------------------------------------
# Text-signal tables
# (tag, [case-insensitive substrings to search in combined title+ingredient text])
# ---------------------------------------------------------------------------
_CUISINE_SIGNALS: list[tuple[str, list[str]]] = [
("cuisine:Japanese", ["miso", "dashi", "ramen", "sushi", "teriyaki", "sake", "mirin",
"wasabi", "panko", "edamame", "tonkatsu", "yakitori", "ponzu"]),
("cuisine:Korean", ["gochujang", "kimchi", "doenjang", "gochugaru",
"bulgogi", "bibimbap", "japchae"]),
("cuisine:Thai", ["fish sauce", "lemongrass", "galangal", "pad thai", "thai basil",
"kaffir lime", "tom yum", "green curry", "red curry", "nam pla"]),
("cuisine:Chinese", ["hoisin", "oyster sauce", "five spice", "bok choy", "chow mein",
"dumpling", "wonton", "mapo", "char siu", "sichuan"]),
("cuisine:Vietnamese", ["pho", "banh mi", "nuoc cham", "rice paper", "vietnamese"]),
("cuisine:Indian", ["garam masala", "turmeric", "cardamom", "fenugreek", "paneer",
"tikka", "masala", "biryani", "dal", "naan", "tandoori",
"curry leaf", "tamarind", "chutney"]),
("cuisine:Middle Eastern", ["tahini", "harissa", "za'atar", "sumac", "baharat", "rose water",
"pomegranate molasses", "freekeh", "fattoush", "shakshuka"]),
("cuisine:Greek", ["feta", "tzatziki", "moussaka", "spanakopita", "orzo",
"kalamata", "gyro", "souvlaki", "dolma"]),
("cuisine:Mediterranean", ["hummus", "pita", "couscous", "preserved lemon"]),
("cuisine:Italian", ["pasta", "pizza", "risotto", "lasagna", "carbonara", "gnocchi",
"parmesan", "mozzarella", "ricotta", "prosciutto", "pancetta",
"arancini", "osso buco", "tiramisu", "pesto", "bolognese",
"cannoli", "polenta", "bruschetta", "focaccia"]),
("cuisine:French", ["croissant", "quiche", "crepe", "coq au vin",
"ratatouille", "bearnaise", "hollandaise", "bouillabaisse",
"herbes de provence", "dijon", "gruyere", "brie", "cassoulet"]),
("cuisine:Spanish", ["paella", "chorizo", "gazpacho", "tapas", "patatas bravas",
"sofrito", "manchego", "albondigas"]),
("cuisine:German", ["sauerkraut", "bratwurst", "schnitzel", "pretzel", "strudel",
"spaetzle", "sauerbraten"]),
("cuisine:Mexican", ["taco", "burrito", "enchilada", "salsa", "guacamole", "chipotle",
"queso", "tamale", "mole", "jalapeno", "tortilla", "carnitas",
"chile verde", "posole", "tostada", "quesadilla"]),
("cuisine:Latin American", ["plantain", "yuca", "chimichurri", "ceviche", "adobo", "empanada"]),
("cuisine:American", ["bbq sauce", "buffalo sauce", "ranch dressing", "coleslaw",
"cornbread", "mac and cheese", "brisket", "cheeseburger"]),
("cuisine:Southern", ["collard greens", "black-eyed peas", "okra", "grits", "catfish",
"hush puppies", "pecan pie"]),
("cuisine:Cajun", ["cajun", "creole", "gumbo", "jambalaya", "andouille", "etouffee"]),
("cuisine:African", ["injera", "berbere", "jollof", "suya", "egusi", "fufu", "tagine"]),
("cuisine:Caribbean", ["jerk", "scotch bonnet", "callaloo", "ackee"]),
]
_DIETARY_SIGNALS: list[tuple[str, list[str]]] = [
("dietary:Vegan", ["vegan", "plant-based", "plant based"]),
("dietary:Vegetarian", ["vegetarian", "meatless"]),
("dietary:Gluten-Free", ["gluten-free", "gluten free", "celiac"]),
("dietary:Dairy-Free", ["dairy-free", "dairy free", "lactose free", "non-dairy"]),
("dietary:Low-Carb", ["low-carb", "low carb", "keto", "ketogenic", "very low carbs"]),
("dietary:High-Protein", ["high protein", "high-protein"]),
("dietary:Low-Fat", ["low-fat", "low fat", "fat-free", "reduced fat"]),
("dietary:Paleo", ["paleo", "whole30"]),
("dietary:Nut-Free", ["nut-free", "nut free", "peanut free"]),
("dietary:Egg-Free", ["egg-free", "egg free"]),
("dietary:Low-Sodium", ["low sodium", "no salt"]),
("dietary:Healthy", ["healthy", "low cholesterol", "heart healthy", "wholesome"]),
]
_FLAVOR_SIGNALS: list[tuple[str, list[str]]] = [
("flavor:Spicy", ["jalapeno", "habanero", "ghost pepper", "sriracha",
"chili flake", "red pepper flake", "cayenne", "hot sauce",
"gochujang", "harissa", "scotch bonnet", "szechuan pepper", "spicy"]),
("flavor:Smoky", ["smoked", "liquid smoke", "smoked paprika",
"bbq sauce", "barbecue", "hickory", "mesquite"]),
("flavor:Sweet", ["honey", "maple syrup", "brown sugar", "caramel", "chocolate",
"vanilla", "condensed milk", "molasses", "agave"]),
("flavor:Savory", ["soy sauce", "fish sauce", "miso", "worcestershire", "anchovy",
"parmesan", "blue cheese", "bone broth"]),
("flavor:Tangy", ["lemon juice", "lime juice", "vinegar", "balsamic", "buttermilk",
"sour cream", "fermented", "pickled", "tamarind", "sumac"]),
("flavor:Herby", ["fresh basil", "fresh cilantro", "fresh dill", "fresh mint",
"fresh tarragon", "fresh thyme", "herbes de provence"]),
("flavor:Rich", ["heavy cream", "creme fraiche", "mascarpone", "double cream",
"ghee", "coconut cream", "cream cheese"]),
("flavor:Umami", ["mushroom", "nutritional yeast", "tomato paste",
"parmesan rind", "bonito", "kombu"]),
]
_TIME_SIGNALS: list[tuple[str, list[str]]] = [
("time:Quick", ["< 15 mins", "< 30 mins", "weeknight", "easy"]),
("time:Under 1 Hour", ["< 60 mins"]),
("time:Make-Ahead", ["freezer", "overnight", "refrigerator", "make-ahead", "make ahead"]),
("time:Slow Cook", ["slow cooker", "crockpot", "< 4 hours", "braise"]),
]
_MAIN_INGREDIENT_SIGNALS: list[tuple[str, list[str]]] = [
("main:Chicken", ["chicken", "poultry", "turkey"]),
("main:Beef", ["beef", "ground beef", "steak", "brisket", "pot roast"]),
("main:Pork", ["pork", "bacon", "ham", "sausage", "prosciutto"]),
("main:Fish", ["salmon", "tuna", "tilapia", "cod", "halibut", "shrimp", "seafood", "fish"]),
("main:Pasta", ["pasta", "noodle", "spaghetti", "penne", "fettuccine", "linguine"]),
("main:Vegetables", ["broccoli", "cauliflower", "zucchini", "eggplant", "carrot",
"vegetable", "veggie"]),
("main:Eggs", ["egg", "frittata", "omelette", "omelet", "quiche"]),
("main:Legumes", ["bean", "lentil", "chickpea", "tofu", "tempeh", "edamame"]),
("main:Grains", ["rice", "quinoa", "barley", "farro", "oat", "grain"]),
("main:Cheese", ["cheddar", "mozzarella", "parmesan", "ricotta", "brie",
"cheese"]),
]
# food.com corpus tag -> normalized tags
_CORPUS_TAG_MAP: dict[str, list[str]] = {
"european": ["cuisine:Italian", "cuisine:French", "cuisine:German",
"cuisine:Spanish"],
"asian": ["cuisine:Chinese", "cuisine:Japanese", "cuisine:Thai",
"cuisine:Korean", "cuisine:Vietnamese"],
"chinese": ["cuisine:Chinese"],
"japanese": ["cuisine:Japanese"],
"thai": ["cuisine:Thai"],
"vietnamese": ["cuisine:Vietnamese"],
"indian": ["cuisine:Indian"],
"greek": ["cuisine:Greek"],
"mexican": ["cuisine:Mexican"],
"african": ["cuisine:African"],
"caribbean": ["cuisine:Caribbean"],
"vegan": ["dietary:Vegan", "dietary:Vegetarian"],
"vegetarian": ["dietary:Vegetarian"],
"healthy": ["dietary:Healthy"],
"low cholesterol": ["dietary:Healthy"],
"very low carbs": ["dietary:Low-Carb"],
"high in...": ["dietary:High-Protein"],
"lactose free": ["dietary:Dairy-Free"],
"egg free": ["dietary:Egg-Free"],
"< 15 mins": ["time:Quick"],
"< 30 mins": ["time:Quick"],
"< 60 mins": ["time:Under 1 Hour"],
"< 4 hours": ["time:Slow Cook"],
"weeknight": ["time:Quick"],
"freezer": ["time:Make-Ahead"],
"dessert": ["meal:Dessert"],
"breakfast": ["meal:Breakfast"],
"lunch/snacks": ["meal:Lunch", "meal:Snack"],
"beverages": ["meal:Beverage"],
"cookie & brownie": ["meal:Dessert"],
"breads": ["meal:Bread"],
}
# ingredient_profiles.elements value -> flavor tag
_ELEMENT_TO_FLAVOR: dict[str, str] = {
"Aroma": "flavor:Herby",
"Richness": "flavor:Rich",
"Structure": "", # no flavor tag
"Binding": "",
"Crust": "flavor:Smoky",
"Lift": "",
"Emulsion": "flavor:Rich",
"Acid": "flavor:Tangy",
}
def _build_text(title: str, ingredient_names: list[str]) -> str:
parts = [title.lower()]
parts.extend(i.lower() for i in ingredient_names)
return " ".join(parts)
def _match_signals(text: str, table: list[tuple[str, list[str]]]) -> list[str]:
return [tag for tag, pats in table if any(p in text for p in pats)]
def infer_tags(
title: str,
ingredient_names: list[str],
corpus_keywords: list[str],
corpus_category: str = "",
# Enriched ingredient profile signals (from ingredient_profiles cross-ref)
element_coverage: dict[str, float] | None = None,
fermented_count: int = 0,
glutamate_total: float = 0.0,
ph_min: float | None = None,
available_sub_constraints: list[str] | None = None,
# Nutrition data for macro-based tags
calories: float | None = None,
protein_g: float | None = None,
fat_g: float | None = None,
carbs_g: float | None = None,
servings: float | None = None,
) -> list[str]:
"""
Derive normalized tags for a recipe.
Parameters
----------
title, ingredient_names, corpus_keywords, corpus_category
: Primary recipe data.
element_coverage
: Dict from recipes.element_coverage -- element name to coverage ratio
(e.g. {"Aroma": 0.6, "Richness": 0.4}). Derived from ingredient_profiles.
fermented_count
: Number of fermented ingredients (from ingredient_profiles.is_fermented).
glutamate_total
: Sum of glutamate_mg across all profiled ingredients. High values signal umami.
ph_min
: Minimum ph_estimate across profiled ingredients. Low values signal acidity.
available_sub_constraints
: Substitution constraint labels achievable for this recipe
(e.g. ["gluten_free", "low_carb"]). From substitution_pairs cross-ref.
These become can_be:* tags.
calories, protein_g, fat_g, carbs_g, servings
: Nutrition data for macro-based dietary tags.
Returns
-------
Sorted list of unique normalized tag strings.
"""
tags: set[str] = set()
# 1. Map corpus tags to normalized vocabulary
for kw in corpus_keywords:
for t in _CORPUS_TAG_MAP.get(kw.lower(), []):
tags.add(t)
if corpus_category:
for t in _CORPUS_TAG_MAP.get(corpus_category.lower(), []):
tags.add(t)
# 2. Text-signal matching
text = _build_text(title, ingredient_names)
tags.update(_match_signals(text, _CUISINE_SIGNALS))
tags.update(_match_signals(text, _DIETARY_SIGNALS))
tags.update(_match_signals(text, _FLAVOR_SIGNALS))
tags.update(_match_signals(text, _MAIN_INGREDIENT_SIGNALS))
# 3. Time signals from corpus keywords + text
corpus_text = " ".join(kw.lower() for kw in corpus_keywords)
tags.update(_match_signals(corpus_text, _TIME_SIGNALS))
tags.update(_match_signals(text, _TIME_SIGNALS))
# 4. Enriched profile signals
if element_coverage:
for element, coverage in element_coverage.items():
if coverage > 0.2: # >20% of ingredients carry this element
flavor_tag = _ELEMENT_TO_FLAVOR.get(element, "")
if flavor_tag:
tags.add(flavor_tag)
if glutamate_total > 50:
tags.add("flavor:Umami")
if fermented_count > 0:
tags.add("flavor:Tangy")
if ph_min is not None and ph_min < 4.5:
tags.add("flavor:Tangy")
# 5. Achievable-via-substitution tags
if available_sub_constraints:
label_to_tag = {
"gluten_free": "can_be:Gluten-Free",
"low_calorie": "can_be:Low-Calorie",
"low_carb": "can_be:Low-Carb",
"vegan": "can_be:Vegan",
"dairy_free": "can_be:Dairy-Free",
"low_sodium": "can_be:Low-Sodium",
}
for label in available_sub_constraints:
tag = label_to_tag.get(label)
if tag:
tags.add(tag)
# 6. Macro-based dietary tags
if servings and servings > 0 and any(
v is not None for v in (protein_g, fat_g, carbs_g, calories)
):
def _per(v: float | None) -> float | None:
return v / servings if v is not None else None
prot_s = _per(protein_g)
fat_s = _per(fat_g)
carb_s = _per(carbs_g)
cal_s = _per(calories)
if prot_s is not None and prot_s >= 20:
tags.add("dietary:High-Protein")
if fat_s is not None and fat_s <= 5:
tags.add("dietary:Low-Fat")
if carb_s is not None and carb_s <= 10:
tags.add("dietary:Low-Carb")
if cal_s is not None and cal_s <= 250:
tags.add("dietary:Light")
elif protein_g is not None and protein_g >= 20:
tags.add("dietary:High-Protein")
# 7. Vegan implies vegetarian
if "dietary:Vegan" in tags:
tags.add("dietary:Vegetarian")
return sorted(tags)

View file

@ -22,7 +22,7 @@ from app.services.expiration_predictor import ExpirationPredictor
log = logging.getLogger(__name__)
LLM_TASK_TYPES: frozenset[str] = frozenset({"expiry_llm_fallback"})
LLM_TASK_TYPES: frozenset[str] = frozenset({"expiry_llm_fallback", "recipe_llm"})
VRAM_BUDGETS: dict[str, float] = {
# ExpirationPredictor uses a small LLM (16 tokens out, single pass).
@ -88,6 +88,8 @@ def run_task(
try:
if task_type == "expiry_llm_fallback":
_run_expiry_llm_fallback(db_path, job_id, params)
elif task_type == "recipe_llm":
_run_recipe_llm(db_path, job_id, params)
else:
raise ValueError(f"Unknown kiwi task type: {task_type!r}")
_update_task_status(db_path, task_id, "completed")
@ -143,3 +145,41 @@ def _run_expiry_llm_fallback(
expiry,
days,
)
def _run_recipe_llm(db_path: Path, _job_id_int: int, params: str | None) -> None:
"""Run LLM recipe generation for an async recipe job.
params JSON keys:
job_id (required) recipe_jobs.job_id string (e.g. "rec_a1b2c3...")
Creates its own Store follows same pattern as _suggest_in_thread.
MUST call store.fail_recipe_job() before re-raising so recipe_jobs.status
doesn't stay 'running' while background_tasks shows 'failed'.
"""
from app.db.store import Store
from app.models.schemas.recipe import RecipeRequest
from app.services.recipe.recipe_engine import RecipeEngine
p = json.loads(params or "{}")
recipe_job_id: str = p.get("job_id", "")
if not recipe_job_id:
raise ValueError("recipe_llm: 'job_id' is required in params")
store = Store(db_path)
try:
store.update_recipe_job_running(recipe_job_id)
row = store._fetch_one(
"SELECT request FROM recipe_jobs WHERE job_id=?", (recipe_job_id,)
)
if row is None:
raise ValueError(f"recipe_llm: recipe_jobs row not found: {recipe_job_id!r}")
req = RecipeRequest.model_validate_json(row["request"])
result = RecipeEngine(store).suggest(req)
store.complete_recipe_job(recipe_job_id, result.model_dump_json())
log.info("recipe_llm: job %s completed (%d suggestion(s))", recipe_job_id, len(result.suggestions))
except Exception as exc:
store.fail_recipe_job(recipe_job_id, str(exc))
raise
finally:
store.close()

View file

@ -1,5 +1,10 @@
# app/tasks/scheduler.py
"""Kiwi LLM task scheduler — thin shim over circuitforge_core.tasks.scheduler."""
"""Kiwi LLM task scheduler — thin shim over circuitforge_core.tasks.scheduler.
Local mode (CLOUD_MODE unset): LocalScheduler simple FIFO, no coordinator.
Cloud mode (CLOUD_MODE=true): OrchestratedScheduler coordinator-aware, fans
out concurrent jobs across all registered cf-orch GPU nodes.
"""
from __future__ import annotations
from pathlib import Path
@ -7,15 +12,68 @@ from pathlib import Path
from circuitforge_core.tasks.scheduler import (
TaskScheduler,
get_scheduler as _base_get_scheduler,
reset_scheduler, # re-export for tests
reset_scheduler as _reset_local, # re-export for tests
)
from app.cloud_session import CLOUD_MODE
from app.core.config import settings
from app.tasks.runner import LLM_TASK_TYPES, VRAM_BUDGETS, run_task
def _orch_available() -> bool:
"""Return True if circuitforge_orch is installed in this environment."""
try:
import circuitforge_orch # noqa: F401
return True
except ImportError:
return False
def _use_orch() -> bool:
"""Return True if the OrchestratedScheduler should be used.
Priority order:
1. USE_ORCH_SCHEDULER env var explicit override always wins.
2. CLOUD_MODE=true use orch in managed cloud deployments.
3. circuitforge_orch installed paid+ local users who have cf-orch
set up get coordinator-aware scheduling (local GPU first) automatically.
"""
override = settings.USE_ORCH_SCHEDULER
if override is not None:
return override
return CLOUD_MODE or _orch_available()
def get_scheduler(db_path: Path) -> TaskScheduler:
"""Return the process-level TaskScheduler singleton for Kiwi."""
"""Return the process-level TaskScheduler singleton for Kiwi.
OrchestratedScheduler: coordinator-aware, fans out concurrent jobs across
all registered cf-orch GPU nodes. Active when USE_ORCH_SCHEDULER=true,
CLOUD_MODE=true, or circuitforge_orch is installed locally (paid+ users
running their own cf-orch stack get this automatically; local GPU is
preferred by the coordinator's allocation queue).
LocalScheduler: serial FIFO, no coordinator dependency. Free-tier local
installs without circuitforge_orch installed use this automatically.
"""
if _use_orch():
try:
from circuitforge_orch.scheduler import get_orch_scheduler
except ImportError:
import logging
logging.getLogger(__name__).warning(
"circuitforge_orch not installed — falling back to LocalScheduler"
)
else:
return get_orch_scheduler(
db_path=db_path,
run_task_fn=run_task,
task_types=LLM_TASK_TYPES,
vram_budgets=VRAM_BUDGETS,
coordinator_url=settings.COORDINATOR_URL,
service_name="kiwi",
)
return _base_get_scheduler(
db_path=db_path,
run_task_fn=run_task,
@ -24,3 +82,15 @@ def get_scheduler(db_path: Path) -> TaskScheduler:
coordinator_url=settings.COORDINATOR_URL,
service_name="kiwi",
)
def reset_scheduler() -> None:
"""Shut down and clear the active scheduler singleton. TEST TEARDOWN ONLY."""
if _use_orch():
try:
from circuitforge_orch.scheduler import reset_orch_scheduler
reset_orch_scheduler()
return
except ImportError:
pass
_reset_local()

View file

@ -18,8 +18,19 @@ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
"style_classifier",
"meal_plan_llm",
"meal_plan_llm_timing",
"community_fork_adapt",
})
# Sources subject to monthly cf-orch call caps. Subscription-based sources are uncapped.
LIFETIME_SOURCES: frozenset[str] = frozenset({"lifetime", "founders"})
# (source, tier) → monthly cf-orch call allowance
LIFETIME_ORCH_CAPS: dict[tuple[str, str], int] = {
("lifetime", "paid"): 60,
("lifetime", "premium"): 180,
("founders", "premium"): 300,
}
# Feature → minimum tier required
KIWI_FEATURES: dict[str, str] = {
# Free tier
@ -43,6 +54,8 @@ KIWI_FEATURES: dict[str, str] = {
"style_picker": "paid",
"recipe_collections": "paid",
"style_classifier": "paid", # LLM auto-tag for saved recipe style tags; BYOK-unlockable
"community_publish": "paid", # Publish plans/outcomes to community feed
"community_fork_adapt": "paid", # Fork with LLM pantry adaptation (BYOK-unlockable)
# Premium tier
"multi_household": "premium",

View file

@ -13,6 +13,7 @@ services:
environment:
CLOUD_MODE: "true"
CLOUD_DATA_ROOT: /devl/kiwi-cloud-data
RECIPE_DB_PATH: /devl/kiwi-corpus/recipes.db
KIWI_BASE_URL: https://menagerie.circuitforge.tech/kiwi
# DIRECTUS_JWT_SECRET, HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN — set in .env
# DEV ONLY: comma-separated IPs that bypass JWT auth (LAN testing without Caddy).
@ -20,10 +21,15 @@ 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
# Community PostgreSQL — shared across CF products; unset = community features unavailable (fail soft)
COMMUNITY_DB_URL: ${COMMUNITY_DB_URL:-}
COMMUNITY_PSEUDONYM_SALT: ${COMMUNITY_PSEUDONYM_SALT:-}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- /devl/kiwi-cloud-data:/devl/kiwi-cloud-data
# Recipe corpus — shared read-only NFS-backed SQLite (3.1M recipes, 2.9GB)
- /Library/Assets/kiwi/kiwi.db:/devl/kiwi-corpus/recipes.db:ro
# LLM config — shared with other CF products; read-only in container
- ${HOME}/.config/circuitforge:/root/.config/circuitforge:ro
networks:

View file

@ -8,9 +8,9 @@ services:
# Docker can follow the symlink inside the container.
- /Library/Assets/kiwi:/Library/Assets/kiwi:rw
# cf-orch agent sidecar: registers kiwi as a GPU node with the coordinator.
# 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 kiwi's VRAM usage visible on the orchestrator dashboard.
# 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
@ -21,7 +21,7 @@ services:
command: >
conda run -n kiwi cf-orch agent
--coordinator ${COORDINATOR_URL:-http://10.1.10.71:7700}
--node-id kiwi
--node-id sif
--host 0.0.0.0
--port 7702
--advertise-host ${CF_ORCH_ADVERTISE_HOST:-10.1.10.71}

74
config/llm.yaml.example Normal file
View file

@ -0,0 +1,74 @@
# Kiwi — LLM backend configuration
#
# Copy to ~/.config/circuitforge/llm.yaml (shared across all CF products)
# or to config/llm.yaml (Kiwi-local, takes precedence).
#
# Kiwi uses LLMs for:
# - Expiry prediction fallback (unknown products not in the lookup table)
# - Meal planning suggestions
#
# Local inference (Ollama / vLLM) is the default path — no API key required.
# BYOK (bring your own key): set api_key_env to point at your API key env var.
# cf-orch trunk: set CF_ORCH_URL env var to allocate cf-text on-demand via
# the coordinator instead of hitting a static URL.
backends:
ollama:
type: openai_compat
enabled: true
base_url: http://localhost:11434/v1
model: llama3.2:3b
api_key: ollama
supports_images: false
vllm:
type: openai_compat
enabled: false
base_url: http://localhost:8000/v1
model: __auto__ # resolved from /v1/models at runtime
api_key: ''
supports_images: false
# ── cf-orch trunk services ──────────────────────────────────────────────────
# These allocate via cf-orch rather than connecting to a static URL.
# cf-orch starts the service on-demand and returns its live URL.
# Set CF_ORCH_URL env var or fill in url below; leave enabled: false if
# cf-orch is not deployed in your environment.
cf_text:
type: openai_compat
enabled: false
base_url: http://localhost:8008/v1 # fallback when cf-orch is not available
model: __auto__
api_key: any
supports_images: false
cf_orch:
service: cf-text
# model_candidates: leave empty to use the service's default_model,
# or specify a catalog alias (e.g. "qwen2.5-3b").
model_candidates: []
ttl_s: 3600
# ── Cloud / BYOK ───────────────────────────────────────────────────────────
anthropic:
type: anthropic
enabled: false
model: claude-haiku-4-5-20251001
api_key_env: ANTHROPIC_API_KEY
supports_images: false
openai:
type: openai_compat
enabled: false
base_url: https://api.openai.com/v1
model: gpt-4o-mini
api_key_env: OPENAI_API_KEY
supports_images: false
fallback_order:
- cf_text
- ollama
- vllm
- anthropic
- openai

View file

@ -8,8 +8,10 @@ server {
# Proxy API requests to the FastAPI container via Docker bridge network.
location /api/ {
proxy_pass http://api:8512;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $http_host;
# Prefer X-Real-IP set by Caddy (real client address); fall back to $remote_addr
# when accessed directly on LAN without Caddy in the path.
proxy_set_header X-Real-IP $http_x_real_ip;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
# Forward the session header injected by Caddy from cf_session cookie.
@ -18,6 +20,22 @@ server {
client_max_body_size 20m;
}
# Direct-port LAN access (localhost:8515): when VITE_API_BASE='/kiwi', the frontend
# builds API calls as /kiwi/api/v1/... — proxy these to the API container.
# Through Caddy the /kiwi prefix is stripped before reaching nginx, so this block
# is only active for direct-port access without Caddy in the path.
# Longer prefix (/kiwi/api/ = 10 chars) beats ^~/kiwi/ (6 chars) per nginx rules.
location /kiwi/api/ {
rewrite ^/kiwi(/api/.*)$ $1 break;
proxy_pass http://api:8512;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $http_x_real_ip;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header X-CF-Session $http_x_cf_session;
client_max_body_size 20m;
}
# When accessed directly (localhost:8515) instead of via Caddy (/kiwi path-strip),
# Vite's /kiwi base URL means assets are requested at /kiwi/assets/... but stored
# at /assets/... in nginx's root. Alias /kiwi/ → root so direct port access works.

View file

@ -0,0 +1,69 @@
# Installation
Kiwi runs as a Docker Compose stack: a FastAPI backend and a Vue 3 frontend served by nginx. No external services are required for the core feature set.
## Prerequisites
- Docker and Docker Compose
- 500 MB disk for images + space for your pantry database
## Quick setup
```bash
git clone https://git.opensourcesolarpunk.com/Circuit-Forge/kiwi
cd kiwi
cp .env.example .env
./manage.sh build
./manage.sh start
```
The web UI opens at `http://localhost:8511`. The FastAPI backend is at `http://localhost:8512`.
## manage.sh commands
| Command | Description |
|---------|-------------|
| `./manage.sh start` | Start all services |
| `./manage.sh stop` | Stop all services |
| `./manage.sh restart` | Restart all services |
| `./manage.sh status` | Show running containers |
| `./manage.sh logs` | Tail logs (all services) |
| `./manage.sh build` | Rebuild images |
| `./manage.sh open` | Open browser to the web UI |
## Environment variables
Copy `.env.example` to `.env` and configure:
```bash
# Required — generate a random secret
SECRET_KEY=your-random-secret-here
# Optional — LLM backend for AI features (receipt OCR, recipe suggestions)
# See LLM Setup guide for details
LLM_BACKEND=ollama # ollama | openai-compatible | vllm
LLM_BASE_URL=http://localhost:11434
LLM_MODEL=llama3.1
```
## Data location
By default, Kiwi stores its SQLite database in `./data/kiwi.db` inside the repo directory. The `data/` folder is bind-mounted into the container so your pantry survives image rebuilds.
## Updating
```bash
git pull
./manage.sh build
./manage.sh restart
```
Database migrations run automatically on startup.
## Uninstalling
```bash
./manage.sh stop
docker compose down -v # removes containers and volumes
rm -rf data/ # removes local database
```

View file

@ -0,0 +1,74 @@
# LLM Backend Setup (Optional)
An LLM backend unlocks **receipt OCR**, **recipe suggestions (L3L4)**, and **style auto-classification**. Everything else works without one.
You can use any OpenAI-compatible inference server: Ollama, vLLM, LM Studio, a local llama.cpp server, or a commercial API.
## BYOK — Bring Your Own Key
BYOK means you provide your own LLM backend. Paid AI features are unlocked at **any tier** when a valid backend is configured. You pay for your own inference; Kiwi just uses it.
## Choosing a backend
| Backend | Best for | Notes |
|---------|----------|-------|
| **Ollama** | Local, easy setup | Recommended for getting started |
| **vLLM** | Local, high throughput | Better for faster hardware |
| **OpenAI API** | No local GPU | Requires paid API key |
| **Anthropic API** | No local GPU | Requires paid API key |
## Ollama setup (recommended)
```bash
# Install Ollama
curl -fsSL https://ollama.ai/install.sh | sh
# Pull a model — llama3.1 8B works well for recipe tasks
ollama pull llama3.1
# Verify it's running
ollama list
```
In your Kiwi `.env`:
```bash
LLM_BACKEND=ollama
LLM_BASE_URL=http://host.docker.internal:11434
LLM_MODEL=llama3.1
```
!!! note "Docker networking"
Use `host.docker.internal` instead of `localhost` when Ollama is running on your host and Kiwi is in Docker.
## OpenAI-compatible API
```bash
LLM_BACKEND=openai
LLM_BASE_URL=https://api.openai.com/v1
LLM_API_KEY=sk-your-key-here
LLM_MODEL=gpt-4o-mini
```
## Verify the connection
In the Kiwi **Settings** page, the LLM status indicator shows whether the backend is reachable. A green checkmark means OCR and L3L4 recipe suggestions are active.
## What LLM is used for
| Feature | LLM required |
|---------|-------------|
| Receipt OCR (line-item extraction) | Yes |
| Recipe suggestions L1 (pantry match) | No |
| Recipe suggestions L2 (substitution) | No |
| Recipe suggestions L3 (style templates) | Yes |
| Recipe suggestions L4 (full generation) | Yes |
| Style auto-classifier | Yes |
L1 and L2 suggestions use deterministic matching — they work without any LLM configured. See [Recipe Engine](../reference/recipe-engine.md) for the full algorithm breakdown.
## Model recommendations
- **Receipt OCR**: any model with vision capability (LLaVA, GPT-4o, etc.)
- **Recipe suggestions**: 7B13B instruction-tuned models work well; larger models produce more creative L4 output
- **Style classification**: small models handle this fine (3B+)

View file

@ -0,0 +1,52 @@
# Quick Start
This guide walks you through adding your first pantry item and getting a recipe suggestion. No LLM backend needed for these steps.
## 1. Add an item by barcode
Open the **Inventory** tab. Tap the barcode icon or click **Scan barcode**, then point your camera at a product barcode. Kiwi looks up the product in the open barcode database and adds it to your pantry.
If the barcode isn't recognized, you'll be prompted to enter the product name and details manually.
## 2. Add an item manually
Click **Add item** and fill in:
- **Name** — what is it? (e.g., "Canned chickpeas")
- **Quantity** — how many or how much
- **Expiry date** — when does it expire? (optional but recommended)
- **Category** — used for dietary filtering and pantry stats
## 3. Upload a receipt
Click **Receipts** in the sidebar, then **Upload receipt**. Take a photo of a grocery receipt or upload an image from your device.
- **Free tier**: the receipt is stored for you to review; line items are entered manually
- **Paid / BYOK**: OCR runs automatically and extracts items for you to approve
## 4. Browse recipes
Click **Recipes** in the sidebar. The recipe browser shows your **pantry match percentage** for each recipe — how much of the ingredient list you already have.
Use the filters to narrow by:
- **Cuisine** — Italian, Mexican, Japanese, etc.
- **Meal type** — breakfast, lunch, dinner, snack
- **Dietary** — vegetarian, vegan, gluten-free, dairy-free, etc.
- **Main ingredient** — chicken, pasta, lentils, etc.
## 5. Get a suggestion based on what's expiring
Click **Leftover mode** (the clock icon or toggle). Kiwi re-ranks suggestions to surface recipes that use your nearly-expired items first.
Free accounts get 5 leftover-mode requests per day. Paid accounts get unlimited.
## 6. Save a recipe
Click the bookmark icon on any recipe card to save it. You can add:
- **Notes** — cooking tips, modifications, family preferences
- **Star rating** — 0 to 5 stars
- **Style tags** — quick, comforting, weeknight, etc.
Saved recipes appear in the **Saved** tab. Paid accounts can organize them into named collections.

35
docs/index.md Normal file
View file

@ -0,0 +1,35 @@
# Kiwi — Pantry Tracker
**Stop throwing food away. Cook what you already have.**
Kiwi tracks your pantry, watches for expiry dates, and suggests recipes based on what's about to go bad. Scan barcodes, photograph receipts, and let Kiwi tell you what to make for dinner — without needing an AI backend to do it.
![Kiwi pantry view](screenshots/01-pantry.png)
---
## What Kiwi does
- **Inventory tracking** — add items by barcode scan, receipt photo, or manual entry
- **Expiry alerts** — know what's about to go bad before it does
- **Recipe browser** — browse by cuisine, meal type, dietary preference, or main ingredient; see pantry match percentage inline
- **Leftover mode** — prioritize nearly-expired items when getting recipe suggestions
- **Receipt OCR** — extract line items from receipt photos automatically (Paid / BYOK)
- **Recipe suggestions** — four levels from pantry-match corpus to full LLM generation (Paid / BYOK)
- **Saved recipes** — bookmark any recipe with notes, 05 star rating, and style tags
- **CSV export** — export your full pantry inventory anytime
## Quick links
- [Installation](getting-started/installation.md) — local self-hosted setup
- [Quick Start](getting-started/quick-start.md) — add your first item and get a recipe
- [LLM Setup](getting-started/llm-setup.md) — unlock AI features with your own backend
- [Tier System](reference/tier-system.md) — what's free vs. paid
## No AI required
Inventory tracking, barcode scanning, expiry alerts, the recipe browser, saved recipes, and CSV export all work without any LLM configured. AI features (receipt OCR, recipe suggestions, style auto-classification) are optional and BYOK-unlockable at any tier.
## Free and open core
Discovery and pipeline code is MIT-licensed. AI features are BSL 1.1 — free for personal non-commercial self-hosting, commercial SaaS requires a license. See the [tier table](reference/tier-system.md) for the full breakdown.

1
docs/plausible.js Normal file
View file

@ -0,0 +1 @@
(function(){var s=document.createElement("script");s.defer=true;s.dataset.domain="docs.circuitforge.tech,circuitforge.tech";s.dataset.api="https://analytics.circuitforge.tech/api/event";s.src="https://analytics.circuitforge.tech/js/script.js";document.head.appendChild(s);})();

View file

@ -0,0 +1,80 @@
# Architecture
Kiwi is a self-contained Docker Compose stack with a Vue 3 (SPA) frontend and a FastAPI backend backed by SQLite.
## Stack
| Layer | Technology |
|-------|-----------|
| Frontend | Vue 3 + TypeScript + Vite |
| Backend | FastAPI (Python 3.11+) |
| Database | SQLite (via circuitforge-core) |
| Auth (cloud) | CF session cookie → Directus JWT |
| Licensing | Heimdall (RS256 JWT, offline-capable) |
| LLM inference | Pluggable — Ollama, vLLM, OpenAI-compatible |
| Barcode lookup | Open Food Facts / UPC Database API |
| OCR | LLM vision model (configurable) |
## Data flow
```mermaid
graph LR
User -->|browser| Vue3[Vue 3 SPA]
Vue3 -->|/api/*| FastAPI
FastAPI -->|SQL| SQLite[(SQLite DB)]
FastAPI -->|HTTP| LLM[LLM Backend]
FastAPI -->|HTTP| Barcode[Barcode DB API]
FastAPI -->|JWT| Heimdall[Heimdall License]
```
## Docker Compose services
```yaml
services:
api:
# FastAPI backend — network_mode: host in dev
# Exposed at port 8512
web:
# Vue 3 SPA served by nginx
# Exposed at port 8511
```
In development, the API uses host networking so nginx can reach it at `172.17.0.1:8512` (Docker bridge gateway).
## Database
SQLite at `./data/kiwi.db`. The schema is managed by numbered migration files in `app/db/migrations/`. Migrations run automatically on startup — the startup script applies any new `*.sql` files in order.
Key tables:
| Table | Purpose |
|-------|---------|
| `products` | Product catalog (shared, barcode-keyed) |
| `pantry_items` | User's pantry (quantity, expiry, notes) |
| `recipes` | Recipe corpus |
| `saved_recipes` | User-bookmarked recipes |
| `collections` | Named recipe collections (Paid) |
| `receipts` | Receipt uploads and OCR results |
| `user_preferences` | User settings (dietary, LLM config) |
## Cloud mode
In cloud mode (managed instance at `menagerie.circuitforge.tech/kiwi`), each user gets their own SQLite database isolated under `/devl/kiwi-cloud-data/<user_id>/kiwi.db`. The cloud compose stack adds:
- `CLOUD_MODE=true` environment variable
- Directus JWT validation for session resolution
- Heimdall tier check on AI feature endpoints
The same codebase runs in both local and cloud modes — the cloud session middleware is a thin wrapper around the local auth logic.
## LLM integration
Kiwi uses `circuitforge-core`'s LLM router, which abstracts over Ollama, vLLM, and OpenAI-compatible APIs. The router is configured via environment variables at startup. All LLM calls are asynchronous and non-blocking — if the backend is unavailable, Kiwi falls back to the highest deterministic level (L2) and returns results without waiting.
## Privacy
- No PII is logged in production
- Pantry data stays on your machine in self-hosted mode
- Cloud mode: data stored per-user on Heimdall server, not shared with third parties, not used for training
- LLM calls include pantry context in the prompt — if using a cloud API, that context leaves your machine
- Using a local LLM backend (Ollama, vLLM) keeps all data on-device

View file

@ -0,0 +1,75 @@
# Recipe Engine
Kiwi uses a four-level recipe suggestion system. Each level adds more intelligence and better results, but requires more resources. Levels 12 are fully deterministic and work without any LLM. Levels 34 require an LLM backend.
## Level overview
| Level | Name | LLM required | Description |
|-------|------|-------------|-------------|
| L1 | Pantry match | No | Rank existing corpus by ingredient overlap |
| L2 | Substitution | No | Suggest swaps for missing ingredients |
| L3 | Style templates | Yes | Generate recipe variations from style templates |
| L4 | Full generation | Yes | Generate new recipes from scratch |
## L1 — Pantry match
The simplest level. Kiwi scores every recipe in the corpus by how many of its ingredients you already have:
```
score = (matched ingredients) / (total ingredients)
```
Recipes are sorted by this score descending. If leftover mode is active, the score is further weighted by expiry proximity.
This works entirely offline with no LLM — just set arithmetic on your current pantry.
## L2 — Substitution
L2 extends L1 by suggesting substitutions for missing ingredients. When a recipe scores well but you're missing one or two items, Kiwi checks a substitution table to see if something in your pantry could stand in:
- Buttermilk → plain yogurt + lemon juice
- Heavy cream → evaporated milk
- Fresh herbs → dried herbs (adjusted quantity)
Substitutions are sourced from a curated table — no LLM involved. L2 raises the effective match score for recipes where a reasonable substitute exists.
## L3 — Style templates
L3 uses the LLM to generate recipe variations from a style template. Rather than generating fully free-form text, it fills in a structured template:
```
[protein] + [vegetable] + [starch] + [sauce/flavor profile]
```
The template is populated from your pantry contents and the style tags you've set (e.g., "quick", "Italian"). The LLM fills in the techniques, proportions, and instructions.
Style templates produce consistent, practical results with less hallucination risk than fully open-ended generation.
## L4 — Full generation
L4 gives the LLM full creative freedom. Kiwi passes:
- Your full pantry inventory
- Your dietary preferences
- Any expiring items (if leftover mode is active)
- Your saved recipe history and style tags
The LLM generates a new recipe optimized for your situation. Results are more creative than L1L3 but require a capable model (7B+ recommended) and take longer to generate.
## Escalation
When you click **Suggest**, Kiwi tries each level in order and returns results as soon as a level produces usable output:
1. L1 and L2 run immediately (no LLM)
2. If no good matches exist (all scores < 30%), Kiwi escalates to L3
3. If L3 produces no results (LLM unavailable or error), Kiwi falls back to best L1 result
4. L4 is only triggered explicitly by the user ("Generate something new")
## Tier gates
| Level | Free | Paid | BYOK (any tier) |
|-------|------|------|-----------------|
| L1 — Pantry match | ✓ | ✓ | ✓ |
| L2 — Substitution | ✓ | ✓ | ✓ |
| L3 — Style templates | — | ✓ | ✓ |
| L4 — Full generation | — | ✓ | ✓ |

View file

@ -0,0 +1,53 @@
# Tier System
Kiwi uses CircuitForge's standard four-tier model. The free tier covers the full pantry tracking workflow. AI features are gated behind Paid or BYOK.
## Feature matrix
| Feature | Free | Paid | Premium |
|---------|------|------|---------|
| **Inventory** | | | |
| Inventory CRUD | ✓ | ✓ | ✓ |
| Barcode scan | ✓ | ✓ | ✓ |
| Receipt upload | ✓ | ✓ | ✓ |
| Expiry alerts | ✓ | ✓ | ✓ |
| CSV export | ✓ | ✓ | ✓ |
| **Recipes** | | | |
| Recipe browser | ✓ | ✓ | ✓ |
| Pantry match (L1) | ✓ | ✓ | ✓ |
| Substitution (L2) | ✓ | ✓ | ✓ |
| Style templates (L3) | BYOK | ✓ | ✓ |
| Full generation (L4) | BYOK | ✓ | ✓ |
| Leftover mode | 5/day | Unlimited | Unlimited |
| **Saved recipes** | | | |
| Save + notes + star rating | ✓ | ✓ | ✓ |
| Style tags (manual) | ✓ | ✓ | ✓ |
| LLM style auto-classifier | — | BYOK | ✓ |
| Named collections | — | ✓ | ✓ |
| Meal planning | — | ✓ | ✓ |
| **OCR** | | | |
| Receipt OCR | BYOK | ✓ | ✓ |
| **Account** | | | |
| Multi-household | — | — | ✓ |
**BYOK** = Bring Your Own LLM backend. Configure a local or cloud inference endpoint and these features activate at any tier. See [LLM Setup](../getting-started/llm-setup.md).
## Pricing
| Tier | Monthly | Lifetime |
|------|---------|----------|
| Free | $0 | — |
| Paid | $8/mo | $129 |
| Premium | $16/mo | $249 |
Lifetime licenses are available at [circuitforge.tech](https://circuitforge.tech).
## Self-hosting
Self-hosted Kiwi is free under the MIT license (inventory/pipeline) and BSL 1.1 (AI features, free for personal non-commercial use). You run it on your own hardware with your own LLM backend. No subscription required.
The cloud-managed instance at `menagerie.circuitforge.tech/kiwi` runs the same codebase and requires a CircuitForge account.
## Free key
Claim a free Paid-tier key (30 days) at [circuitforge.tech](https://circuitforge.tech/free-key). No credit card required.

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View file

@ -0,0 +1,39 @@
# Barcode Scanning
Kiwi's barcode scanner uses your device camera to look up products instantly. It works for UPC-A, UPC-E, EAN-13, EAN-8, and QR codes on packaged foods.
## How to scan
1. Open the **Inventory** tab
2. Click the **Scan barcode** button (camera icon)
3. Hold the barcode in the camera frame
4. Kiwi decodes it and looks up the product
## What happens after a scan
**Product found in database:**
Kiwi fills in the product name, category, and any nutritional metadata from the open barcode database. You confirm the quantity and expiry date, then save.
**Product not found:**
You'll see a manual entry form with the raw barcode pre-filled. Add a name and the product is saved to your personal pantry (not contributed to the shared database).
## Supported formats
| Format | Common use |
|--------|-----------|
| UPC-A (12 digit) | US grocery products |
| EAN-13 (13 digit) | International grocery products |
| UPC-E (compressed) | Small packaging |
| EAN-8 | Small packaging |
| QR Code | Some specialty products |
## Tips for reliable scanning
- **Good lighting**: scanning works best in well-lit conditions
- **Steady hand**: hold the camera still for 12 seconds
- **Fill the frame**: bring the barcode close enough to fill most of the camera view
- **Flat surface**: wrinkled or curved barcodes are harder to decode
## Manual barcode entry
If camera scanning isn't available (browser permissions denied, no camera, etc.), you can type the barcode number directly into the **Barcode** field on the manual add form.

View file

@ -0,0 +1,62 @@
# Inventory
![Kiwi pantry view](../screenshots/01-pantry.png)
The inventory is your pantry. Every item you add gives Kiwi the data it needs to show pantry match percentages, flag expiry, and rank recipe suggestions.
## Adding items
### By barcode
Tap the barcode scanner icon. Point your camera at the barcode on the product. Kiwi checks the open barcode database and fills in the product name and category.
If the product isn't in the database, you'll see a manual entry form pre-filled with whatever was decoded from the barcode — just add a name and save.
### By receipt
Upload a receipt photo in the **Receipts** tab. After the receipt is processed, approved items are added to your pantry in bulk. See [Receipt OCR](receipt-ocr.md) for details.
### Manually
Click **Add item** and fill in:
| Field | Required | Notes |
|-------|----------|-------|
| Name | Yes | What is it? |
| Quantity | Yes | Number + unit (e.g., "2 cans", "500 g") |
| Expiry date | No | Used for expiry alerts and leftover mode |
| Category | No | Helps with dietary filtering |
| Notes | No | Storage instructions, opened date, etc. |
## Editing and deleting
Click any item in the list to edit its quantity, expiry date, or notes. Items can be deleted individually or in bulk via the selection checkbox.
## Expiry alerts
Kiwi flags items approaching expiry with a color indicator:
- **Red**: expires within 2 days
- **Orange**: expires within 7 days
- **Yellow**: expires within 14 days
The **Leftover mode** uses this same expiry window to prioritize nearly-expired items in recipe rankings.
## Inventory stats
The stats panel (top of the Inventory page) shows:
- Total items in pantry
- Items expiring this week
- Breakdown by category
- Items added this month
## CSV export
Click **Export** to download your full pantry as a CSV file. The export includes name, quantity, category, expiry date, and notes for every item.
## Bulk operations
- Select multiple items with the checkbox column
- **Delete selected** — remove items in bulk
- **Mark as used** — remove items you've cooked with (coming in Phase 3)

View file

@ -0,0 +1,39 @@
# Leftover Mode
![Kiwi recipe results with pantry match](../screenshots/03-recipe-results.png)
Leftover mode re-ranks recipe suggestions to surface dishes that use your nearly-expired items first. It's the fastest way to answer "what should I cook before this goes bad?"
## Activating leftover mode
Click the **clock icon** or the **Leftover mode** toggle in the recipe browser. The recipe list immediately re-sorts to prioritize recipes that use items expiring within the next 7 days.
## How it works
When leftover mode is active, Kiwi weights the pantry match score toward items closer to their expiry date. A recipe that uses your 3-day-old spinach and day-old mushrooms ranks higher than a recipe that only uses shelf-stable pantry staples — even if the pantry match percentage is similar.
Items without an expiry date set are not weighted for leftover mode purposes. Setting expiry dates when you add items makes leftover mode much more useful.
## Rate limits
| Tier | Leftover mode requests |
|------|----------------------|
| Free | 5 per day |
| Paid | Unlimited |
| Premium | Unlimited |
A "request" is each time you activate leftover mode or click **Refresh**. The re-sort count resets at midnight.
## What counts as "nearly expired"
The leftover mode window uses the same thresholds as the expiry indicators:
- **Expiring within 2 days** — highest priority
- **Expiring within 7 days** — elevated priority
- **Expiring within 14 days** — mildly elevated priority
Items past their expiry date are still included (Kiwi doesn't remove them automatically) but displayed with a red indicator. Use your judgment — some items are fine past date, others aren't.
## Combining with filters
Leftover mode stacks with the dietary and cuisine filters. You can activate leftover mode and filter by "Vegetarian" or "Under 30 minutes" to narrow down to recipes that both use expiring items and match your constraints.

View file

@ -0,0 +1,57 @@
# Receipt OCR
![Kiwi receipt upload](../screenshots/04-receipts.png)
Receipt OCR automatically extracts grocery line items from a photo of your receipt and adds them to your pantry after you approve. It's available on the Paid tier and BYOK-unlockable on Free.
## Upload a receipt
1. Click **Receipts** in the sidebar
2. Click **Upload receipt**
3. Take a photo or select an image from your device
Supported formats: JPEG, PNG, HEIC, WebP. Maximum file size: 10 MB.
## How OCR processing works
When a receipt is uploaded:
1. **OCR runs** — the LLM reads the receipt image and identifies line items, quantities, and prices
2. **Review screen** — you see each extracted item with its detected quantity
3. **Approve or edit** — correct any mistakes, remove items you don't want tracked
4. **Confirm** — approved items are added to your pantry in bulk
The whole flow is designed around human approval — Kiwi never silently adds items to your pantry. You always see what's being imported and can adjust before confirming.
## Reviewing extracted items
Each extracted line item shows:
- **Product name** — as extracted from the receipt
- **Quantity** — detected from the receipt text (e.g., "2 × Canned Tomatoes")
- **Confidence** — how certain the OCR is about this item
- **Edit** — correct the name or quantity inline
- **Remove** — exclude this item from the import
Low-confidence items are flagged with a yellow indicator. Review those carefully — store abbreviations and handwriting can trip up the extractor.
## Free tier behavior
On the Free tier without a BYOK backend configured:
- Receipts are stored and displayed
- OCR does **not** run automatically
- You can enter items from the receipt manually using the item list view
To enable automatic OCR on Free tier, configure a [BYOK LLM backend](../getting-started/llm-setup.md).
## Tips for better results
- **Flatten the receipt**: lay it on a flat surface rather than crumpling
- **Include the full receipt**: get all four edges in frame
- **Good lighting**: avoid glare on thermal paper
- **Fresh receipts**: faded thermal receipts (older than a few months) are harder to read
## Re-running OCR
If OCR produced poor results, you can trigger a re-run from the receipt detail view. Each re-run uses a fresh extraction — previous results are discarded.

View file

@ -0,0 +1,50 @@
# Recipe Browser
![Kiwi recipe finder](../screenshots/02-recipes.png)
The recipe browser lets you explore the full recipe corpus filtered by cuisine, meal type, dietary preference, and main ingredient. Your **pantry match percentage** is shown on every recipe card so you can see at a glance what you can cook tonight.
## Browsing by domain
The recipe corpus is organized into three domains:
| Domain | Examples |
|--------|---------|
| **Cuisine** | Italian, Mexican, Japanese, Indian, Mediterranean, West African, ... |
| **Meal type** | Breakfast, Lunch, Dinner, Snack, Dessert, Drink |
| **Dietary** | Vegetarian, Vegan, Gluten-free, Dairy-free, Low-carb, Nut-free |
Click a domain tile to see its categories. Click a category to browse the recipes inside it.
## Pantry match percentage
Every recipe card shows what percentage of the ingredient list you already have in your pantry. This updates as your inventory changes.
- **100%**: you have everything — cook it now
- **7099%**: almost there, minor shopping needed
- **< 50%**: you'd need to buy most of the ingredients
## Filtering
Use the filter bar to narrow results:
- **Dietary** — show only recipes matching your dietary preferences
- **Min pantry match** — hide recipes below a match threshold
- **Time** — prep + cook time total
- **Sort** — by pantry match (default), alphabetical, or rating (for saved recipes)
## Recipe detail
Click any recipe card to open the full recipe:
- Ingredient list with **in pantry / not in pantry** indicators
- Step-by-step instructions
- Substitution suggestions for missing ingredients
- Nutritional summary
- **Bookmark** button to save with notes and rating
## Getting suggestions
The recipe browser shows the **full corpus** sorted by pantry match. For AI-powered suggestions tailored to what's expiring, use [Leftover Mode](leftover-mode.md) or the **Suggest** button (Paid / BYOK).
See [Recipe Engine](../reference/recipe-engine.md) for how the four suggestion levels work.

View file

@ -0,0 +1,53 @@
# Saved Recipes
Save any recipe from the browser to your personal collection. Add notes, a star rating, and style tags to build a library of recipes you love.
## Saving a recipe
Click the **bookmark icon** on any recipe card or the **Save** button in the recipe detail view. The recipe is immediately saved to your **Saved** tab.
## Notes and ratings
On each saved recipe you can add:
- **Notes** — your modifications, family feedback, what you'd change next time
- **Star rating** — 0 to 5 stars; used to sort your collection
- **Style tags** — free-text labels like "quick", "comforting", "weeknight", "meal prep"
Click the pencil icon on a saved recipe to edit these fields.
## Style tags
Style tags are free-text — type anything that helps you find the recipe later. Common tags used by Kiwi users:
`quick` · `weeknight` · `comforting` · `meal prep` · `kid-friendly` · `hands-off` · `summer` · `one-pot`
**Paid tier and above:** the LLM style auto-classifier can suggest tags based on the recipe's ingredients and instructions. Click **Auto-tag** on any saved recipe to get suggestions you can accept or dismiss.
## Collections (Paid)
On the Paid tier, you can organize saved recipes into named collections:
1. Click **New collection** in the Saved tab
2. Give it a name (e.g., "Weeknight dinners", "Holiday baking")
3. Add recipes to the collection from the saved recipe list or directly when saving
Collections are listed in the sidebar of the Saved tab. A recipe can belong to multiple collections.
## Sorting and filtering saved recipes
Sort by:
- **Date saved** (newest first, default)
- **Star rating** (highest first)
- **Pantry match** (how many ingredients you currently have)
- **Alphabetical**
Filter by:
- **Collection** (Paid)
- **Style tag**
- **Star rating** (e.g., show only 4+ star recipes)
- **Dietary**
## Removing a recipe
Click the bookmark icon again (or the **Remove** button in the detail view) to unsave a recipe. Your notes and rating are lost when you unsave — there's no archive.

View file

@ -0,0 +1,63 @@
# Settings
The Settings page lets you configure your LLM backend, dietary preferences, notification behavior, and account details.
## LLM backend
Shows the currently configured inference backend and its connection status. A green indicator means Kiwi can reach the backend and AI features are active. A red indicator means the backend is unreachable — check the URL and whether the server is running.
To change or add a backend, edit your `.env` file and restart:
```bash
LLM_BACKEND=ollama
LLM_BASE_URL=http://host.docker.internal:11434
LLM_MODEL=llama3.1
```
See [LLM Backend Setup](../getting-started/llm-setup.md) for full configuration options.
## Dietary preferences
Set your default dietary filters here. These are applied automatically when you browse recipes and get suggestions:
- Vegetarian
- Vegan
- Gluten-free
- Dairy-free
- Nut-free
- Low-carb
- Halal
- Kosher
Dietary preferences are stored locally and not shared with any server.
## Expiry alert thresholds
Configure when Kiwi starts flagging items:
| Indicator | Default |
|-----------|---------|
| Red (urgent) | 2 days |
| Orange (soon) | 7 days |
| Yellow (upcoming) | 14 days |
## Notification settings
Kiwi can send browser notifications when items are about to expire. Enable this in Settings by clicking **Allow notifications**. Your browser will ask for permission.
Notifications are sent once per day for items entering the red (2-day) window.
## Account and tier
Shows your current tier (Free / Paid / Premium) and account email (cloud mode only). Includes a link to manage your subscription.
## Affiliate links
When browsing recipes that call for specialty ingredients, Kiwi may show eBay links to find them at a discount. You can:
- **Disable affiliate links entirely** — turn off all affiliate link insertion
- **Use your own affiliate ID** — if you have an eBay Partner Network (EPN) ID, enter it here and your ID will be used instead of CircuitForge's (Premium tier)
## Export
Click **Export pantry** to download your full inventory as a CSV file. The export includes all items, quantities, categories, expiry dates, and notes.

View file

@ -23,6 +23,9 @@
.app-body { display: flex; flex-direction: column; flex: 1; }
}
</style>
<!-- Plausible analytics: cookie-free, GDPR-compliant, self-hosted.
Skips localhost/127.0.0.1. Reports to hostname + circuitforge.tech rollup. -->
<script>(function(){if(/localhost|127\.0\.0\.1/.test(location.hostname))return;var s=document.createElement('script');s.defer=true;s.dataset.domain=location.hostname+',circuitforge.tech';s.dataset.api='https://analytics.circuitforge.tech/api/event';s.src='https://analytics.circuitforge.tech/js/script.js';document.head.appendChild(s);})();</script>
</head>
<body>
<div id="app"></div>

View file

@ -58,6 +58,15 @@
<span class="sidebar-label">Meal Plan</span>
</button>
<button :class="['sidebar-item', { active: currentTab === 'shopping' }]" @click="switchTab('shopping')" aria-label="Shopping List">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/>
<line x1="3" y1="6" x2="21" y2="6"/>
<path d="M16 10a4 4 0 01-8 0"/>
</svg>
<span class="sidebar-label">Shopping</span>
</button>
<button :class="['sidebar-item', { active: currentTab === 'settings' }]" @click="switchTab('settings')">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
@ -79,21 +88,24 @@
<main class="app-main">
<div class="container">
<div v-show="currentTab === 'inventory'" class="tab-content fade-in">
<div v-if="mountedTabs.has('inventory')" v-show="currentTab === 'inventory'" class="tab-content fade-in">
<InventoryList />
</div>
<div v-show="currentTab === 'receipts'" class="tab-content fade-in">
<div v-if="mountedTabs.has('receipts')" v-show="currentTab === 'receipts'" class="tab-content fade-in">
<ReceiptsView />
</div>
<div v-show="currentTab === 'recipes'" class="tab-content fade-in">
<RecipesView />
</div>
<div v-show="currentTab === 'settings'" class="tab-content fade-in">
<div v-if="mountedTabs.has('settings')" v-show="currentTab === 'settings'" class="tab-content fade-in">
<SettingsView />
</div>
<div v-show="currentTab === 'mealplan'" class="tab-content">
<div v-if="mountedTabs.has('mealplan')" v-show="currentTab === 'mealplan'" class="tab-content">
<MealPlanView />
</div>
<div v-if="mountedTabs.has('shopping')" v-show="currentTab === 'shopping'" class="tab-content fade-in">
<ShoppingView />
</div>
</div>
</main>
</div>
@ -144,6 +156,14 @@
</svg>
<span class="nav-label">Meal Plan</span>
</button>
<button :class="['nav-item', { active: currentTab === 'shopping' }]" @click="switchTab('shopping')" aria-label="Shopping List">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/>
<line x1="3" y1="6" x2="21" y2="6"/>
<path d="M16 10a4 4 0 01-8 0"/>
</svg>
<span class="nav-label">Shopping</span>
</button>
</nav>
<!-- Feedback FAB hidden when FORGEJO_API_TOKEN not configured -->
@ -184,21 +204,26 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import InventoryList from './components/InventoryList.vue'
import ReceiptsView from './components/ReceiptsView.vue'
import RecipesView from './components/RecipesView.vue'
import SettingsView from './components/SettingsView.vue'
import MealPlanView from './components/MealPlanView.vue'
import ShoppingView from './components/ShoppingView.vue'
import FeedbackButton from './components/FeedbackButton.vue'
import { useInventoryStore } from './stores/inventory'
import { useEasterEggs } from './composables/useEasterEggs'
import { householdAPI } from './services/api'
import { householdAPI, bootstrapSession } from './services/api'
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings' | 'mealplan'
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings' | 'mealplan' | 'shopping'
const currentTab = ref<Tab>('inventory')
const currentTab = ref<Tab>('recipes')
const sidebarCollapsed = ref(false)
// Lazy-mount: tabs mount on first visit and stay mounted (KeepAlive-like behaviour).
// Only 'recipes' is in the initial set so non-active tabs don't mount simultaneously
// on page load eliminates concurrent onMounted calls across all tab components.
const mountedTabs = reactive(new Set<Tab>(['recipes']))
const inventoryStore = useInventoryStore()
const { kiwiVisible, kiwiDirection } = useEasterEggs()
@ -218,6 +243,7 @@ function onWordmarkClick() {
}
async function switchTab(tab: Tab) {
mountedTabs.add(tab)
currentTab.value = tab
if (tab === 'recipes' && inventoryStore.items.length === 0) {
await inventoryStore.fetchItems()
@ -225,6 +251,15 @@ async function switchTab(tab: Tab) {
}
onMounted(async () => {
// Session bootstrap logs auth= + tier= server-side for log-based analytics.
// Fire-and-forget: failure doesn't affect UX.
bootstrapSession()
// Pre-fetch inventory so Recipes tab has data on first load
if (inventoryStore.items.length === 0) {
await inventoryStore.fetchItems()
}
// Handle household invite links: /#/join?household_id=xxx&token=yyy
const hash = window.location.hash
if (hash.includes('/join')) {

View file

@ -0,0 +1,275 @@
<template>
<Transition name="modal">
<div v-if="show" class="modal-overlay" @click="handleCancel">
<div class="modal-container" @click.stop>
<div class="modal-header">
<h3>{{ title }}</h3>
</div>
<div class="modal-body">
<p>{{ message }}</p>
<!-- Partial quantity input -->
<div v-if="inputType === 'quantity'" class="action-input-row">
<label class="action-input-label">{{ inputLabel }}</label>
<div class="qty-input-group">
<input
v-model.number="inputNumber"
type="number"
:min="0.01"
:max="inputMax"
step="0.5"
class="action-number-input"
:aria-label="inputLabel"
/>
<span class="qty-unit">{{ inputUnit }}</span>
</div>
<button class="btn-use-all" @click="inputNumber = inputMax">
Use all ({{ inputMax }} {{ inputUnit }})
</button>
</div>
<!-- Reason select -->
<div v-if="inputType === 'select'" class="action-input-row">
<label class="action-input-label">{{ inputLabel }}</label>
<select v-model="inputSelect" class="action-select" :aria-label="inputLabel">
<option value=""> skip </option>
<option v-for="opt in inputOptions" :key="opt" :value="opt">{{ opt }}</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="handleCancel">Cancel</button>
<button :class="['btn', `btn-${type}`]" @click="handleConfirm">
{{ confirmText }}
</button>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
interface Props {
show: boolean
title?: string
message: string
confirmText?: string
type?: 'primary' | 'danger' | 'warning' | 'secondary'
inputType?: 'quantity' | 'select' | null
inputLabel?: string
inputMax?: number
inputUnit?: string
inputOptions?: string[]
}
const props = withDefaults(defineProps<Props>(), {
title: 'Confirm',
confirmText: 'Confirm',
type: 'primary',
inputType: null,
inputLabel: '',
inputMax: 1,
inputUnit: '',
inputOptions: () => [],
})
const emit = defineEmits<{
confirm: [value: number | string | undefined]
cancel: []
}>()
const inputNumber = ref<number>(props.inputMax)
const inputSelect = ref<string>('')
watch(() => props.inputMax, (v) => { inputNumber.value = v })
watch(() => props.show, (v) => {
if (v) {
inputNumber.value = props.inputMax
inputSelect.value = ''
}
})
function handleConfirm() {
if (props.inputType === 'quantity') {
const qty = Math.min(Math.max(0.01, inputNumber.value || props.inputMax), props.inputMax)
emit('confirm', qty)
} else if (props.inputType === 'select') {
emit('confirm', inputSelect.value || undefined)
} else {
emit('confirm', undefined)
}
}
function handleCancel() {
emit('cancel')
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: var(--spacing-lg);
}
.modal-container {
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
max-width: 480px;
width: 100%;
overflow: hidden;
}
.modal-header {
padding: var(--spacing-lg);
border-bottom: 1px solid var(--color-border);
}
.modal-header h3 {
margin: 0;
color: var(--color-text-primary);
font-size: var(--font-size-lg);
font-weight: 600;
}
.modal-body {
padding: var(--spacing-lg);
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.modal-body p {
margin: 0;
color: var(--color-text-primary);
font-size: var(--font-size-base);
line-height: 1.5;
}
.action-input-row {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.action-input-label {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
font-weight: 500;
}
.qty-input-group {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.action-number-input {
width: 90px;
padding: var(--spacing-xs) var(--spacing-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: var(--font-size-base);
}
.qty-unit {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.btn-use-all {
align-self: flex-start;
background: none;
border: none;
color: var(--color-primary);
font-size: var(--font-size-sm);
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.action-select {
padding: var(--spacing-xs) var(--spacing-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: var(--font-size-base);
width: 100%;
}
.modal-footer {
padding: var(--spacing-lg);
border-top: 1px solid var(--color-border);
display: flex;
justify-content: flex-end;
gap: var(--spacing-md);
}
.btn {
padding: var(--spacing-sm) var(--spacing-lg);
border: none;
border-radius: var(--radius-md);
font-size: var(--font-size-base);
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-secondary {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
}
.btn-secondary:hover { background: var(--color-bg-primary); }
.btn-primary {
background: var(--gradient-primary);
color: white;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-danger {
background: var(--color-error);
color: white;
}
.btn-danger:hover {
background: var(--color-error-dark);
transform: translateY(-1px);
}
.btn-warning {
background: var(--color-warning);
color: white;
}
/* Animations */
.modal-enter-active,
.modal-leave-active { transition: opacity 0.3s ease; }
.modal-enter-active .modal-container,
.modal-leave-active .modal-container { transition: transform 0.3s ease; }
.modal-enter-from,
.modal-leave-to { opacity: 0; }
.modal-enter-from .modal-container,
.modal-leave-to .modal-container { transform: scale(0.9) translateY(-20px); }
</style>

View file

@ -0,0 +1,586 @@
<template>
<div class="byo-tab">
<!-- Step 0: Template grid -->
<div v-if="phase === 'select'" class="byo-section">
<h2 class="section-title text-xl mb-sm">Build Your Own Recipe</h2>
<p class="text-sm text-secondary mb-md">
Choose a style, then pick your ingredients one step at a time.
</p>
<div v-if="templatesLoading" class="text-secondary text-sm">Loading templates</div>
<div v-else-if="templatesError" role="alert" class="status-badge status-error mb-md">
{{ templatesError }}
</div>
<div v-else class="template-grid" role="list">
<button
v-for="tmpl in templates"
:key="tmpl.id"
class="template-card card"
role="listitem"
:aria-label="tmpl.title + ': ' + tmpl.descriptor"
@click="selectTemplate(tmpl)"
>
<span class="tmpl-icon" aria-hidden="true">{{ tmpl.icon }}</span>
<span class="tmpl-title">{{ tmpl.title }}</span>
<span class="tmpl-descriptor text-sm text-secondary">{{ tmpl.descriptor }}</span>
</button>
</div>
</div>
<!-- Step 1+: Ingredient wizard -->
<div v-else-if="phase === 'wizard'" class="byo-section">
<!-- Back + step counter -->
<div class="byo-nav mb-sm">
<button class="btn btn-sm btn-secondary" @click="goBack"> Back</button>
<span class="text-sm text-secondary step-counter">Step {{ wizardStep + 1 }} of {{ totalSteps }}</span>
</div>
<h2 class="section-title text-xl mb-xs">What's your {{ currentRole?.display }}?</h2>
<p v-if="currentRole?.hint" class="text-sm text-secondary mb-md">{{ currentRole.hint }}</p>
<!-- Missing ingredient mode toggle -->
<div class="mode-toggle mb-sm" role="radiogroup" aria-label="Missing ingredients">
<button
v-for="mode in missingModes"
:key="mode.value"
:class="['btn', 'btn-sm', recipesStore.missingIngredientMode === mode.value ? 'btn-primary' : 'btn-secondary']"
:aria-checked="recipesStore.missingIngredientMode === mode.value"
role="radio"
@click="recipesStore.missingIngredientMode = mode.value as any"
>{{ mode.label }}</button>
</div>
<!-- Filter row: text search or tag cloud -->
<div class="filter-row mb-sm">
<input
v-if="recipesStore.builderFilterMode === 'text'"
v-model="filterText"
class="form-input filter-input"
:placeholder="'Search ' + (currentRole?.display ?? 'ingredients') + '…'"
aria-label="Search ingredients"
/>
<div
v-else
class="tag-cloud"
role="group"
aria-label="Filter by tag"
>
<button
v-for="tag in candidates?.available_tags ?? []"
:key="tag"
:class="['btn', 'btn-sm', 'tag-chip', selectedTags.has(tag) ? 'tag-active' : '']"
:aria-pressed="selectedTags.has(tag)"
@click="toggleTag(tag)"
>{{ tag }}</button>
<span v-if="(candidates?.available_tags ?? []).length === 0" class="text-secondary text-sm">
No tags available for this ingredient set.
</span>
</div>
<button
class="btn btn-sm btn-secondary filter-mode-btn"
:aria-pressed="recipesStore.builderFilterMode === 'tags'"
:aria-label="recipesStore.builderFilterMode === 'text' ? 'Switch to tag filter' : 'Switch to text search'"
@click="recipesStore.builderFilterMode = recipesStore.builderFilterMode === 'text' ? 'tags' : 'text'"
>{{ recipesStore.builderFilterMode === 'text' ? '🏷️' : '🔍' }}</button>
</div>
<!-- Candidates loading / error -->
<div v-if="candidatesLoading" class="text-secondary text-sm mb-sm">Loading options</div>
<div v-else-if="candidatesError" role="alert" class="status-badge status-error mb-sm">
{{ candidatesError }}
</div>
<!-- Compatible candidates -->
<div v-if="filteredCompatible.length > 0" class="candidates-section mb-sm">
<p class="text-xs font-semibold text-secondary mb-xs" aria-hidden="true">Available</p>
<div class="ingredient-grid">
<button
v-for="item in filteredCompatible"
:key="item.name"
:class="['ingredient-card', 'btn', selectedInRole.has(item.name) ? 'ingredient-active' : '']"
:aria-pressed="selectedInRole.has(item.name)"
:aria-label="item.name + (item.in_pantry ? '' : ' — not in pantry')"
@click="toggleIngredient(item.name)"
>
<span class="ingredient-name">{{ item.name }}</span>
<span v-if="!item.in_pantry && recipesStore.missingIngredientMode === 'add-to-cart'"
class="cart-icon" aria-hidden="true">🛒</span>
</button>
</div>
</div>
<!-- Other candidates (greyed or add-to-cart mode only) -->
<template v-if="recipesStore.missingIngredientMode !== 'hidden' && filteredOther.length > 0">
<div class="candidates-separator text-xs text-secondary mb-xs">also works</div>
<div class="ingredient-grid ingredient-grid-other mb-sm">
<button
v-for="item in filteredOther"
:key="item.name"
:class="['ingredient-card', 'btn',
item.in_pantry ? '' : 'ingredient-missing',
selectedInRole.has(item.name) ? 'ingredient-active' : '']"
:aria-pressed="selectedInRole.has(item.name)"
:aria-label="item.name + (item.in_pantry ? '' : ' — not in pantry')"
:disabled="!item.in_pantry && recipesStore.missingIngredientMode === 'greyed'"
@click="item.in_pantry || recipesStore.missingIngredientMode !== 'greyed' ? toggleIngredient(item.name) : undefined"
>
<span class="ingredient-name">{{ item.name }}</span>
<span v-if="!item.in_pantry && recipesStore.missingIngredientMode === 'add-to-cart'"
class="cart-icon" aria-hidden="true">🛒</span>
</button>
</div>
</template>
<!-- No-match state: nothing compatible AND nothing visible in other section.
filteredOther items are hidden when mode is 'hidden', so check visibility too. -->
<template v-if="!candidatesLoading && !candidatesError && filteredCompatible.length === 0 && (filteredOther.length === 0 || recipesStore.missingIngredientMode === 'hidden')">
<!-- Custom freeform input: text filter with no matches offer "use anyway" -->
<div v-if="recipesStore.builderFilterMode === 'text' && filterText.trim().length > 0" class="custom-ingredient-prompt mb-sm">
<p class="text-sm text-secondary mb-xs">
No match for "{{ filterText.trim() }}" in your pantry.
</p>
<button class="btn btn-secondary" @click="useCustomIngredient">
Use "{{ filterText.trim() }}" anyway
</button>
</div>
<!-- No pantry items at all for this role -->
<p v-else class="text-sm text-secondary mb-sm">
Nothing in your pantry fits this role yet. You can skip it or
<button class="btn-link" @click="recipesStore.missingIngredientMode = 'greyed'">show options to add.</button>
</p>
</template>
<!-- Skip / Next -->
<div class="byo-actions">
<button
v-if="!currentRole?.required"
class="btn btn-secondary"
@click="advanceStep"
>Skip (optional)</button>
<button
v-else-if="currentRole?.required && selectedInRole.size === 0"
class="btn btn-secondary"
@click="advanceStep"
>I'll add this later</button>
<button
class="btn btn-primary"
:disabled="buildLoading"
@click="wizardStep < totalSteps - 1 ? advanceStep() : buildRecipe()"
>
{{ wizardStep < totalSteps - 1 ? 'Next →' : 'Build this recipe' }}
</button>
</div>
</div>
<!-- Result -->
<div v-else-if="phase === 'result'" class="byo-section">
<div v-if="buildLoading" class="text-secondary text-sm mb-md">Building your recipe</div>
<div v-else-if="buildError" role="alert" class="status-badge status-error mb-md">
{{ buildError }}
</div>
<template v-else-if="builtRecipe">
<RecipeDetailPanel
:recipe="builtRecipe"
:grocery-links="[]"
@close="phase = 'select'"
@cooked="phase = 'select'"
/>
<!-- Shopping list: items the user chose that aren't in their pantry -->
<div v-if="(builtRecipe.missing_ingredients ?? []).length > 0" class="cart-list card mb-sm">
<h3 class="text-sm font-semibold mb-xs">🛒 You'll need to pick up</h3>
<ul class="cart-items">
<li v-for="item in builtRecipe.missing_ingredients" :key="item" class="cart-item text-sm">{{ item }}</li>
</ul>
</div>
<div class="byo-actions mt-sm">
<button class="btn btn-secondary" @click="resetToTemplate">Try a different build</button>
<button class="btn btn-secondary" @click="phase = 'wizard'">Adjust ingredients</button>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRecipesStore } from '../stores/recipes'
import RecipeDetailPanel from './RecipeDetailPanel.vue'
import { recipesAPI, type AssemblyTemplateOut, type RoleCandidatesResponse, type RecipeSuggestion } from '../services/api'
const recipesStore = useRecipesStore()
type Phase = 'select' | 'wizard' | 'result'
const phase = ref<Phase>('select')
// Template grid state
const templates = ref<AssemblyTemplateOut[]>([])
const templatesLoading = ref(false)
const templatesError = ref<string | null>(null)
// Wizard state
const selectedTemplate = ref<AssemblyTemplateOut | null>(null)
const wizardStep = ref(0)
const roleOverrides = ref<Record<string, string[]>>({})
// Candidates for current step
const candidates = ref<RoleCandidatesResponse | null>(null)
const candidatesLoading = ref(false)
const candidatesError = ref<string | null>(null)
// Filter state (reset on step advance)
const filterText = ref('')
const selectedTags = ref<Set<string>>(new Set())
// Result state
const builtRecipe = ref<RecipeSuggestion | null>(null)
const buildLoading = ref(false)
const buildError = ref<string | null>(null)
// Shopping list is derived from builtRecipe.missing_ingredients (computed by backend)
const missingModes = [
{ label: 'Available only', value: 'hidden' },
{ label: 'Show missing', value: 'greyed' },
{ label: 'Add to cart', value: 'add-to-cart' },
]
const totalSteps = computed(() => selectedTemplate.value?.role_sequence.length ?? 0)
const currentRole = computed(() => selectedTemplate.value?.role_sequence[wizardStep.value] ?? null)
const selectedInRole = computed<Set<string>>(() => {
const role = currentRole.value?.display
if (!role) return new Set()
return new Set(roleOverrides.value[role] ?? [])
})
const priorPicks = computed<string[]>(() => {
if (!selectedTemplate.value) return []
return selectedTemplate.value.role_sequence
.slice(0, wizardStep.value)
.flatMap((r) => roleOverrides.value[r.display] ?? [])
})
const filteredCompatible = computed(() => applyFilter(candidates.value?.compatible ?? []))
const filteredOther = computed(() => applyFilter(candidates.value?.other ?? []))
function applyFilter(items: RoleCandidatesResponse['compatible']) {
if (recipesStore.builderFilterMode === 'text') {
const q = filterText.value.trim().toLowerCase()
if (!q) return items
return items.filter((i) => i.name.toLowerCase().includes(q))
} else {
if (selectedTags.value.size === 0) return items
return items.filter((i) =>
[...selectedTags.value].every((tag) => i.tags.includes(tag))
)
}
}
function toggleTag(tag: string) {
const next = new Set(selectedTags.value)
next.has(tag) ? next.delete(tag) : next.add(tag)
selectedTags.value = next
}
function toggleIngredient(name: string) {
const role = currentRole.value?.display
if (!role) return
const current = new Set(roleOverrides.value[role] ?? [])
current.has(name) ? current.delete(name) : current.add(name)
roleOverrides.value = { ...roleOverrides.value, [role]: [...current] }
}
function useCustomIngredient() {
const name = filterText.value.trim()
if (!name) return
const role = currentRole.value?.display
if (!role) return
// Add to role overrides so it's included in the build request
const current = new Set(roleOverrides.value[role] ?? [])
current.add(name)
roleOverrides.value = { ...roleOverrides.value, [role]: [...current] }
// Inject into the local candidates list so it renders as a selected card.
// Mark in_pantry: true so it stays visible regardless of missing-ingredient mode.
if (candidates.value) {
const knownNames = new Set([
...(candidates.value.compatible ?? []).map((i) => i.name.toLowerCase()),
...(candidates.value.other ?? []).map((i) => i.name.toLowerCase()),
])
if (!knownNames.has(name.toLowerCase())) {
candidates.value = {
...candidates.value,
compatible: [{ name, in_pantry: true, tags: [] }, ...(candidates.value.compatible ?? [])],
}
}
}
filterText.value = ''
}
async function selectTemplate(tmpl: AssemblyTemplateOut) {
selectedTemplate.value = tmpl
wizardStep.value = 0
roleOverrides.value = {}
phase.value = 'wizard'
await loadCandidates()
}
async function loadCandidates() {
if (!selectedTemplate.value || !currentRole.value) return
candidatesLoading.value = true
candidatesError.value = null
filterText.value = ''
selectedTags.value = new Set()
try {
candidates.value = await recipesAPI.getRoleCandidates(
selectedTemplate.value.id,
currentRole.value.display,
priorPicks.value,
)
} catch {
candidatesError.value = 'Could not load ingredient options. Please try again.'
} finally {
candidatesLoading.value = false
}
}
async function advanceStep() {
if (!selectedTemplate.value) return
if (wizardStep.value < totalSteps.value - 1) {
wizardStep.value++
await loadCandidates()
}
}
function goBack() {
if (phase.value === 'result') {
phase.value = 'wizard'
return
}
if (wizardStep.value > 0) {
wizardStep.value--
loadCandidates()
} else {
phase.value = 'select'
selectedTemplate.value = null
}
}
async function buildRecipe() {
if (!selectedTemplate.value) return
buildLoading.value = true
buildError.value = null
phase.value = 'result'
const overrides: Record<string, string> = {}
for (const [role, picks] of Object.entries(roleOverrides.value)) {
if (picks.length > 0) overrides[role] = picks[0]!
}
try {
builtRecipe.value = await recipesAPI.buildRecipe({
template_id: selectedTemplate.value.id,
role_overrides: overrides,
})
} catch {
buildError.value = 'Could not build recipe. Try adjusting your ingredients.'
} finally {
buildLoading.value = false
}
}
function resetToTemplate() {
phase.value = 'select'
selectedTemplate.value = null
wizardStep.value = 0
roleOverrides.value = {}
builtRecipe.value = null
buildError.value = null
}
onMounted(async () => {
templatesLoading.value = true
try {
templates.value = await recipesAPI.getTemplates()
} catch {
templatesError.value = 'Could not load templates. Please refresh.'
} finally {
templatesLoading.value = false
}
})
</script>
<style scoped>
.byo-tab {
padding: var(--spacing-sm) 0;
}
.byo-section {
max-width: 640px;
}
.template-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-sm);
}
@media (min-width: 640px) {
.template-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.template-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xs);
padding: var(--spacing-md);
text-align: left;
cursor: pointer;
}
.tmpl-icon {
font-size: 1.5rem;
}
.tmpl-title {
font-weight: 600;
font-size: 0.95rem;
}
.tmpl-descriptor {
line-height: 1.35;
}
.byo-nav {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.step-counter {
margin-left: auto;
}
.mode-toggle {
display: flex;
gap: var(--spacing-xs);
flex-wrap: wrap;
}
.filter-row {
display: flex;
gap: var(--spacing-xs);
align-items: flex-start;
}
.filter-input {
flex: 1;
}
.filter-mode-btn {
flex-shrink: 0;
min-width: 36px;
}
.tag-cloud {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.tag-active {
background: var(--color-primary);
color: var(--color-bg-primary);
}
.ingredient-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-xs);
}
@media (min-width: 640px) {
.ingredient-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.ingredient-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
min-height: 44px;
cursor: pointer;
}
.ingredient-active {
border: 2px solid var(--color-primary);
background: var(--color-primary-light);
color: var(--color-bg-primary);
}
.ingredient-missing {
opacity: 0.55;
}
.ingredient-name {
flex: 1;
font-size: 0.9rem;
}
.cart-icon {
font-size: 0.85rem;
margin-left: var(--spacing-xs);
}
.candidates-separator {
margin-top: var(--spacing-sm);
padding-top: var(--spacing-xs);
border-top: 1px solid var(--color-border);
}
.byo-actions {
display: flex;
gap: var(--spacing-sm);
flex-wrap: wrap;
}
.btn-link {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.cart-list {
padding: var(--spacing-sm) var(--spacing-md);
}
.cart-items {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
margin-top: var(--spacing-xs);
}
.cart-item {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 2px var(--spacing-sm);
}
</style>

View file

@ -0,0 +1,337 @@
<template>
<div class="community-feed-panel">
<!-- Filter tabs: All / Plans / Successes / Bloopers -->
<div role="tablist" aria-label="Community post filters" class="filter-bar flex gap-xs mb-md">
<button
v-for="f in filters"
:key="f.id"
role="tab"
:aria-selected="activeFilter === f.id"
:tabindex="activeFilter === f.id ? 0 : -1"
:class="['btn', 'tab-btn', activeFilter === f.id ? 'btn-primary' : 'btn-secondary']"
@click="setFilter(f.id)"
@keydown="onFilterKeydown"
@pointerdown="f.id === 'recipe_blooper' ? onBlooperPointerDown($event) : undefined"
@pointerup="f.id === 'recipe_blooper' ? onBlooperPointerCancel() : undefined"
@pointerleave="f.id === 'recipe_blooper' ? onBlooperPointerCancel() : undefined"
>{{ f.label }}</button>
</div>
<!-- Share a plan action row -->
<div class="action-row flex-between mb-sm">
<button
class="btn btn-secondary btn-sm share-plan-btn"
aria-haspopup="dialog"
@click="showPublishPlan = true"
>
Share a plan
</button>
</div>
<!-- Loading skeletons -->
<div
v-if="store.loading"
class="skeleton-list flex-col gap-sm"
aria-busy="true"
aria-label="Loading posts"
>
<div v-for="n in 3" :key="n" class="skeleton-card">
<div class="skeleton-line skeleton-line-short"></div>
<div class="skeleton-line skeleton-line-long mt-xs"></div>
<div class="skeleton-line skeleton-line-med mt-xs"></div>
</div>
</div>
<!-- Error state -->
<div
v-else-if="store.error"
class="error-state card"
role="alert"
>
<p class="text-sm text-secondary mb-sm">{{ store.error }}</p>
<button class="btn btn-secondary btn-sm" @click="retry">
Try again
</button>
</div>
<!-- Empty state -->
<div
v-else-if="store.posts.length === 0"
class="empty-state card text-center"
>
<p class="text-secondary mb-xs">No posts yet</p>
<p class="text-sm text-muted">Be the first to share a meal plan or recipe story.</p>
</div>
<!-- Post list -->
<div v-else class="post-list flex-col gap-sm">
<CommunityPostCard
v-for="post in store.posts"
:key="post.slug"
:post="post"
@fork="handleFork"
/>
</div>
<!-- Fork success toast -->
<Transition name="toast-fade">
<div
v-if="forkFeedback"
class="fork-toast status-badge status-success"
role="status"
aria-live="polite"
>
{{ forkFeedback }}
</div>
</Transition>
<!-- Fork error toast -->
<Transition name="toast-fade">
<div
v-if="forkError"
class="fork-toast status-badge status-error"
role="alert"
aria-live="assertive"
>
{{ forkError }}
</div>
</Transition>
<!-- Publish plan modal -->
<PublishPlanModal
v-if="showPublishPlan"
:plan="null"
@close="showPublishPlan = false"
@published="onPlanPublished"
/>
<!-- Hall of Chaos easter egg: hold Bloopers tab for 800ms -->
<HallOfChaosView
v-if="showHallOfChaos"
@close="showHallOfChaos = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useCommunityStore } from '../stores/community'
import type { ForkResult } from '../stores/community'
import CommunityPostCard from './CommunityPostCard.vue'
import PublishPlanModal from './PublishPlanModal.vue'
import HallOfChaosView from './HallOfChaosView.vue'
const emit = defineEmits<{
'plan-forked': [payload: ForkResult]
}>()
const store = useCommunityStore()
const activeFilter = ref('all')
const showPublishPlan = ref(false)
const showHallOfChaos = ref(false)
let blooperHoldTimer: ReturnType<typeof setTimeout> | null = null
function onBlooperPointerDown(_e: PointerEvent) {
blooperHoldTimer = setTimeout(() => {
showHallOfChaos.value = true
blooperHoldTimer = null
}, 800)
}
function onBlooperPointerCancel() {
if (blooperHoldTimer !== null) {
clearTimeout(blooperHoldTimer)
blooperHoldTimer = null
}
}
const filters = [
{ id: 'all', label: 'All' },
{ id: 'plan', label: 'Plans' },
{ id: 'recipe_success', label: 'Successes' },
{ id: 'recipe_blooper', label: 'Bloopers' },
]
const filterIds = filters.map((f) => f.id)
function onFilterKeydown(e: KeyboardEvent) {
const current = filterIds.indexOf(activeFilter.value)
let next = current
if (e.key === 'ArrowRight') {
e.preventDefault()
next = (current + 1) % filterIds.length
} else if (e.key === 'ArrowLeft') {
e.preventDefault()
next = (current - 1 + filterIds.length) % filterIds.length
} else {
return
}
setFilter(filterIds[next]!)
// Move DOM focus to the newly active tab per ARIA tablist pattern
const bar = (e.currentTarget as HTMLElement).closest('[role="tablist"]')
const buttons = bar?.querySelectorAll<HTMLButtonElement>('[role="tab"]')
buttons?.[next]?.focus()
}
async function setFilter(filterId: string) {
activeFilter.value = filterId
await store.fetchPosts(filterId === 'all' ? undefined : filterId)
}
async function retry() {
await store.fetchPosts(activeFilter.value === 'all' ? undefined : activeFilter.value)
}
const forkFeedback = ref<string | null>(null)
const forkError = ref<string | null>(null)
function showToast(msg: string, type: 'success' | 'error') {
if (type === 'success') {
forkFeedback.value = msg
setTimeout(() => { forkFeedback.value = null }, 3000)
} else {
forkError.value = msg
setTimeout(() => { forkError.value = null }, 4000)
}
}
async function handleFork(slug: string) {
try {
const result = await store.forkPost(slug)
showToast('Plan added to your week.', 'success')
emit('plan-forked', result)
} catch (err: unknown) {
showToast(err instanceof Error ? err.message : 'Could not fork this plan.', 'error')
}
}
function onPlanPublished(_payload: { slug: string }) {
showPublishPlan.value = false
store.fetchPosts(activeFilter.value === 'all' ? undefined : activeFilter.value)
}
onMounted(async () => {
if (store.posts.length === 0) {
await store.fetchPosts()
}
})
onUnmounted(() => {
if (blooperHoldTimer !== null) {
clearTimeout(blooperHoldTimer)
blooperHoldTimer = null
}
})
</script>
<style scoped>
.community-feed-panel {
position: relative;
}
.filter-bar {
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--spacing-sm);
}
.tab-btn {
border-radius: var(--radius-md) var(--radius-md) 0 0;
border-bottom: none;
}
.action-row {
padding: var(--spacing-xs) 0;
}
.share-plan-btn {
font-size: var(--font-size-xs);
}
/* Loading skeletons */
.skeleton-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-md);
overflow: hidden;
}
.skeleton-line {
height: 12px;
border-radius: var(--radius-sm);
background: var(--color-bg-elevated);
animation: shimmer 1.4s ease-in-out infinite;
}
.skeleton-line-short { width: 35%; }
.skeleton-line-med { width: 60%; }
.skeleton-line-long { width: 90%; }
@keyframes shimmer {
0% { opacity: 0.6; }
50% { opacity: 1.0; }
100% { opacity: 0.6; }
}
/* Empty / error states */
.empty-state {
padding: var(--spacing-xl) var(--spacing-lg);
}
.error-state {
padding: var(--spacing-md);
}
/* Post list */
.post-list {
padding-top: var(--spacing-sm);
}
/* Toast */
.fork-toast {
position: fixed;
bottom: calc(72px + var(--spacing-md));
left: 50%;
transform: translateX(-50%);
z-index: 300;
white-space: nowrap;
box-shadow: var(--shadow-lg);
}
@media (min-width: 769px) {
.fork-toast {
bottom: var(--spacing-lg);
}
}
.toast-fade-enter-active,
.toast-fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.toast-fade-enter-from,
.toast-fade-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
@media (prefers-reduced-motion: reduce) {
.skeleton-line {
animation: none;
opacity: 0.7;
}
.toast-fade-enter-active,
.toast-fade-leave-active {
transition: none;
}
.toast-fade-enter-from,
.toast-fade-leave-to {
transform: translateX(-50%);
}
}
</style>

View file

@ -0,0 +1,178 @@
<template>
<article class="community-post-card" :class="`post-type-${post.post_type}`">
<!-- Header row: type badge + date -->
<div class="card-header flex-between gap-sm mb-xs">
<span
class="post-type-badge status-badge"
:class="typeBadgeClass"
:aria-label="`Post type: ${typeLabel}`"
>{{ typeLabel }}</span>
<time
class="post-date text-xs text-muted"
:datetime="post.published"
:title="fullDate"
>{{ shortDate }}</time>
</div>
<!-- Title -->
<h3 class="post-title text-base font-semibold mb-xs">{{ post.title }}</h3>
<!-- Author -->
<p class="post-author text-xs text-muted mb-xs">
by {{ post.pseudonym }}
</p>
<!-- Description (if present) -->
<p v-if="post.description" class="post-description text-sm text-secondary mb-sm">
{{ post.description }}
</p>
<!-- Dietary tag pills -->
<div
v-if="post.dietary_tags.length > 0"
class="tag-row flex flex-wrap gap-xs mb-sm"
role="list"
aria-label="Dietary tags"
>
<span
v-for="tag in post.dietary_tags"
:key="tag"
class="status-badge status-success tag-pill"
role="listitem"
>{{ tag }}</span>
</div>
<!-- Fork button (plan posts only) -->
<div v-if="post.post_type === 'plan'" class="card-actions mt-sm">
<button
class="btn btn-primary btn-sm btn-fork"
:aria-label="`Fork ${post.title} to my meal plan`"
@click="$emit('fork', post.slug)"
>
Fork to my plan
</button>
</div>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { CommunityPost } from '../stores/community'
const props = defineProps<{
post: CommunityPost
}>()
defineEmits<{
fork: [slug: string]
}>()
const typeLabel = computed(() => {
switch (props.post.post_type) {
case 'plan': return 'Meal Plan'
case 'recipe_success': return 'Success'
case 'recipe_blooper': return 'Blooper'
default: return props.post.post_type
}
})
const typeBadgeClass = computed(() => {
switch (props.post.post_type) {
case 'plan': return 'status-info'
case 'recipe_success': return 'status-success'
case 'recipe_blooper': return 'status-warning'
default: return 'status-info'
}
})
const shortDate = computed(() => {
try {
return new Date(props.post.published).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})
} catch {
return ''
}
})
const fullDate = computed(() => {
try {
return new Date(props.post.published).toLocaleString()
} catch {
return props.post.published
}
})
</script>
<style scoped>
.community-post-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-md);
transition: box-shadow 0.18s ease;
}
.community-post-card:hover {
box-shadow: var(--shadow-md);
}
.post-type-plan { border-left: 3px solid var(--color-info); }
.post-type-recipe_success { border-left: 3px solid var(--color-success); }
.post-type-recipe_blooper { border-left: 3px solid var(--color-warning); }
.card-header {
align-items: center;
}
.post-type-badge,
.post-date {
flex-shrink: 0;
}
.post-title {
margin: 0;
color: var(--color-text-primary);
line-height: 1.3;
}
.post-author,
.post-description {
margin: 0;
}
.post-description {
line-height: 1.5;
}
.tag-pill {
text-transform: lowercase;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
.btn-fork {
min-width: 120px;
}
@media (max-width: 480px) {
.community-post-card {
padding: var(--spacing-sm);
border-radius: var(--radius-md);
}
.btn-fork {
width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
.community-post-card {
transition: none;
}
}
</style>

View file

@ -75,6 +75,21 @@
/>
</div>
<div class="form-group">
<label class="form-label">Screenshot <span class="text-muted text-xs">(optional, max 5 MB)</span></label>
<input
type="file"
accept="image/*"
class="form-input-file"
@change="onScreenshotChange"
ref="fileInput"
/>
<div v-if="screenshotPreview" class="screenshot-preview">
<img :src="screenshotPreview" alt="Screenshot preview" />
<button class="screenshot-remove btn-link" type="button" @click="clearScreenshot" aria-label="Remove screenshot">Remove</button>
</div>
</div>
<p v-if="stepError" class="feedback-error">{{ stepError }}</p>
</div>
@ -140,11 +155,37 @@ import { ref, computed, onMounted } from 'vue'
const props = defineProps<{ currentTab?: string }>()
const fileInput = ref<HTMLInputElement | null>(null)
const screenshotB64 = ref<string | null>(null)
const screenshotPreview = ref<string | null>(null)
const screenshotFilename = ref('screenshot.png')
function onScreenshotChange(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
screenshotFilename.value = file.name
const reader = new FileReader()
reader.onload = (e) => {
const result = e.target?.result as string
screenshotB64.value = result
screenshotPreview.value = result
}
reader.readAsDataURL(file)
}
function clearScreenshot() {
screenshotB64.value = null
screenshotPreview.value = null
if (fileInput.value) fileInput.value.value = ''
}
const apiBase = (import.meta.env.VITE_API_BASE as string) ?? ''
// Probe once on mount hidden until confirmed enabled so button never flashes
const enabled = ref(false)
onMounted(async () => {
try {
const res = await fetch('/api/v1/feedback/status')
const res = await fetch(`${apiBase}/api/v1/feedback/status`)
if (res.ok) {
const data = await res.json()
enabled.value = data.enabled === true
@ -190,6 +231,7 @@ function reset() {
submitted.value = false
issueUrl.value = ''
form.value = { type: 'bug', title: '', description: '', repro: '', submitter: '' }
clearScreenshot()
}
function nextStep() {
@ -205,7 +247,7 @@ async function submit() {
loading.value = true
submitError.value = ''
try {
const res = await fetch('/api/v1/feedback', {
const res = await fetch(`${apiBase}/api/v1/feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@ -224,6 +266,23 @@ async function submit() {
}
const data = await res.json()
issueUrl.value = data.issue_url
// Upload screenshot if provided
if (screenshotB64.value) {
try {
await fetch(`${apiBase}/api/v1/feedback/attach`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
issue_number: data.issue_number,
filename: screenshotFilename.value,
image_b64: screenshotB64.value,
}),
})
// Non-fatal: if attach fails, the issue was still filed
} catch { /* ignore attach errors */ }
}
submitted.value = true
} catch (e) {
submitError.value = 'Network error — please try again.'
@ -407,6 +466,165 @@ async function submit() {
.mt-md { margin-top: var(--spacing-md); }
.mt-xs { margin-top: var(--spacing-xs); }
/* ── Form elements ────────────────────────────────────────────────────── */
.form-group {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.form-label {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.form-input {
width: 100%;
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
font-family: var(--font-body);
font-size: var(--font-size-sm);
line-height: 1.5;
transition: border-color 0.15s;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: var(--color-border-focus);
}
.form-input::placeholder { color: var(--color-text-muted); opacity: 0.7; }
/* ── Buttons ──────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
padding: var(--spacing-xs) var(--spacing-md);
border-radius: var(--radius-md);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 500;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary {
background: var(--color-primary);
color: #fff;
border: 1px solid var(--color-primary);
}
.btn-primary:hover:not(:disabled) { filter: brightness(1.1); }
.btn-ghost {
background: transparent;
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.btn-ghost:hover:not(:disabled) {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
border-color: var(--color-border-focus);
}
/* ── Filter chips ─────────────────────────────────────────────────────── */
.filter-chip-row {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-xs);
}
.btn-chip {
padding: 5px var(--spacing-sm);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 999px;
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text-secondary);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.btn-chip.active,
.btn-chip:hover {
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
border-color: var(--color-primary);
color: var(--color-primary);
}
/* ── Card ─────────────────────────────────────────────────────────────── */
.card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
/* ── Text utilities ───────────────────────────────────────────────────── */
.text-muted { color: var(--color-text-muted); }
.text-sm { font-size: var(--font-size-sm); line-height: 1.5; }
.text-xs { font-size: 0.75rem; line-height: 1.5; }
.font-semibold { font-weight: 600; }
/* ── Screenshot attachment ────────────────────────────────────────────── */
.form-input-file {
display: block;
width: 100%;
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--color-bg-secondary);
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-secondary);
font-family: var(--font-body);
font-size: var(--font-size-sm);
cursor: pointer;
box-sizing: border-box;
}
.form-input-file:focus { outline: 2px solid var(--color-border-focus); outline-offset: 2px; }
.screenshot-preview {
margin-top: var(--spacing-xs);
display: flex;
align-items: flex-start;
gap: var(--spacing-sm);
}
.screenshot-preview img {
max-width: 160px;
max-height: 100px;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
object-fit: cover;
}
.screenshot-remove {
font-size: var(--font-size-xs);
color: var(--color-text-muted);
background: none;
border: none;
cursor: pointer;
padding: 2px 4px;
min-height: 24px;
}
.screenshot-remove:hover { color: var(--color-error); }
.btn-link {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
padding: 0;
font-family: var(--font-body);
font-size: inherit;
text-decoration: underline;
}
/* Transition */
.modal-fade-enter-active, .modal-fade-leave-active { transition: opacity 0.2s ease; }
.modal-fade-enter-from, .modal-fade-leave-to { opacity: 0; }

View file

@ -0,0 +1,182 @@
<template>
<div class="hall-of-chaos-overlay" role="dialog" aria-modal="true" aria-label="Hall of Chaos">
<!-- Header -->
<div class="chaos-header">
<h2 class="chaos-title">HALL OF CHAOS</h2>
<p class="chaos-subtitle text-sm">
Chaos Level: <span class="chaos-level">{{ chaosLevel }}</span>
</p>
<button
class="btn btn-secondary chaos-exit-btn"
aria-label="Exit Hall of Chaos"
@click="$emit('close')"
>
Escape the chaos
</button>
</div>
<!-- Loading -->
<div v-if="loading" class="chaos-loading text-center text-secondary" aria-live="polite" aria-busy="true">
Assembling the chaos...
</div>
<!-- Error -->
<div v-else-if="error" class="chaos-empty text-center text-secondary" role="alert">
The chaos is temporarily indisposed.
</div>
<!-- Empty -->
<div v-else-if="posts.length === 0" class="chaos-empty text-center text-secondary">
<p>No bloopers yet. Be the first to make a glorious mistake.</p>
</div>
<!-- Blooper cards -->
<div v-else class="chaos-grid" aria-label="Blooper posts">
<article
v-for="(post, index) in posts"
:key="post.slug"
class="chaos-card"
:class="`chaos-card--tilt-${(index % 5) + 1}`"
:style="{ '--chaos-border-color': borderColors[index % borderColors.length] }"
>
<p class="chaos-card-author text-xs text-muted">{{ post.pseudonym }}</p>
<h3 class="chaos-card-title text-base font-semibold">{{ post.title }}</h3>
<p v-if="post.outcome_notes" class="chaos-card-notes text-sm text-secondary">
{{ post.outcome_notes }}
</p>
<p v-if="post.recipe_name" class="chaos-card-recipe text-xs text-muted mt-xs">
Recipe: {{ post.recipe_name }}
</p>
</article>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import api from '../services/api'
import type { CommunityPost } from '../stores/community'
defineEmits<{ close: [] }>()
const posts = ref<CommunityPost[]>([])
const chaosLevel = ref(0)
const loading = ref(true)
const error = ref(false)
// CSS custom property strings -- no hardcoded hex
const borderColors = [
'var(--color-warning)',
'var(--color-info)',
'var(--color-success)',
'var(--color-error)',
'var(--color-warning)',
]
onMounted(async () => {
try {
const response = await api.get<{ posts: CommunityPost[]; chaos_level: number }>(
'/community/hall-of-chaos'
)
posts.value = response.data.posts
chaosLevel.value = response.data.chaos_level
} catch {
error.value = true
} finally {
loading.value = false
}
})
</script>
<style scoped>
.hall-of-chaos-overlay {
position: absolute;
inset: 0;
z-index: 200;
background: var(--color-bg-primary);
overflow-y: auto;
padding: var(--spacing-md);
border-radius: var(--radius-lg);
}
.chaos-header {
text-align: center;
margin-bottom: var(--spacing-lg);
}
.chaos-title {
font-size: 2rem;
font-weight: 900;
letter-spacing: 0.12em;
color: var(--color-warning);
margin: 0 0 var(--spacing-xs);
text-transform: uppercase;
}
.chaos-subtitle {
color: var(--color-text-secondary);
margin: 0 0 var(--spacing-sm);
}
.chaos-level {
font-weight: 700;
color: var(--color-warning);
}
.chaos-exit-btn {
font-size: var(--font-size-xs);
}
.chaos-loading,
.chaos-empty {
padding: var(--spacing-xl);
}
.chaos-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--spacing-md);
padding-bottom: var(--spacing-lg);
}
/* Static tilts applied once at render -- not animations, no reduced-motion concern */
.chaos-card {
background: var(--color-bg-card);
border: 2px solid var(--chaos-border-color, var(--color-border));
border-radius: var(--radius-lg);
padding: var(--spacing-md);
}
.chaos-card--tilt-1 { transform: rotate(-3deg); }
.chaos-card--tilt-2 { transform: rotate(2deg); }
.chaos-card--tilt-3 { transform: rotate(-1.5deg); }
.chaos-card--tilt-4 { transform: rotate(4deg); }
.chaos-card--tilt-5 { transform: rotate(-4.5deg); }
.chaos-card-title {
margin: var(--spacing-xs) 0;
color: var(--color-text-primary);
}
.chaos-card-author,
.chaos-card-notes,
.chaos-card-recipe {
margin: 0;
}
@media (max-width: 480px) {
.chaos-grid {
grid-template-columns: 1fr;
}
.chaos-card--tilt-1,
.chaos-card--tilt-2,
.chaos-card--tilt-3,
.chaos-card--tilt-4,
.chaos-card--tilt-5 {
transform: none;
}
}
</style>

View file

@ -11,11 +11,25 @@
<div class="stat-num text-amber">{{ stats.available_items }}</div>
<div class="stat-lbl">Available</div>
</div>
<div class="stat-strip-item">
<div
:class="['stat-strip-item', 'stat-clickable', { 'stat-active': expiryView === 'soon' }]"
@click="toggleExpiryView('soon')"
@keydown.enter="toggleExpiryView('soon')"
role="button"
tabindex="0"
:aria-label="`${store.expiringItems.length} items expiring soon — tap to view`"
>
<div class="stat-num text-warning">{{ store.expiringItems.length }}</div>
<div class="stat-lbl">Expiring</div>
</div>
<div class="stat-strip-item">
<div
:class="['stat-strip-item', 'stat-clickable', { 'stat-active': expiryView === 'expired' }]"
@click="toggleExpiryView('expired')"
@keydown.enter="toggleExpiryView('expired')"
role="button"
tabindex="0"
:aria-label="`${store.expiredItems.length} expired items — tap to view`"
>
<div class="stat-num text-error">{{ store.expiredItems.length }}</div>
<div class="stat-lbl">Expired</div>
</div>
@ -245,9 +259,162 @@
<div class="inventory-section">
<!-- Filter chips -->
<div class="inventory-header">
<h2 class="section-title">Pantry</h2>
<h2 class="section-title">
{{ expiryView === 'soon' ? 'Expiring Soon' : expiryView === 'expired' ? 'Expired Items' : 'Pantry' }}
</h2>
<button v-if="expiryView" @click="expiryView = null" class="btn-text expiry-back-btn" type="button">
All items
</button>
</div>
<!-- Expiry Panel -->
<template v-if="expiryView === 'soon'">
<div v-if="!store.expiringItems.length" class="empty-state">
<p class="text-secondary">Nothing expiring in the next 7 days.</p>
</div>
<div v-else class="expiry-panel">
<!-- Urgent: 3 days -->
<div v-if="urgentItems.length" class="expiry-group">
<div class="expiry-group-label expiry-group-urgent">Use within 3 days</div>
<div
v-for="item in urgentItems"
:key="item.id"
class="expiry-item-row"
>
<span :class="['loc-dot', `loc-dot-${item.location}`]"></span>
<div class="expiry-item-name">
<span class="inv-name">{{ item.product_name || 'Unknown' }}</span>
<span v-if="item.category" class="inv-category">{{ item.category }}</span>
</div>
<div class="expiry-item-right">
<span class="inv-qty">{{ formatQuantity(item.quantity, item.unit, settingsStore.unitSystem) }}</span>
<span :class="['expiry-badge', getExpiryBadgeClass(item.expiration_date!)]">
{{ daysLabel(item.expiration_date!) }}
</span>
<div class="inv-actions">
<button @click="markAsConsumed(item)" class="btn-icon btn-icon-success" aria-label="Use">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<polyline points="4 10 8 14 16 6"/>
</svg>
</button>
<button @click="markAsDiscarded(item)" class="btn-icon btn-icon-discard" aria-label="Not used">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<path d="M4 4l12 12M4 16L16 4"/>
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- Soon: 4-7 days -->
<div v-if="soonItems.length" class="expiry-group">
<div class="expiry-group-label expiry-group-soon">Coming up (47 days)</div>
<div
v-for="item in soonItems"
:key="item.id"
class="expiry-item-row"
>
<span :class="['loc-dot', `loc-dot-${item.location}`]"></span>
<div class="expiry-item-name">
<span class="inv-name">{{ item.product_name || 'Unknown' }}</span>
<span v-if="item.category" class="inv-category">{{ item.category }}</span>
</div>
<div class="expiry-item-right">
<span class="inv-qty">{{ formatQuantity(item.quantity, item.unit, settingsStore.unitSystem) }}</span>
<span :class="['expiry-badge', getExpiryBadgeClass(item.expiration_date!)]">
{{ daysLabel(item.expiration_date!) }}
</span>
<div class="inv-actions">
<button @click="markAsConsumed(item)" class="btn-icon btn-icon-success" aria-label="Use">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<polyline points="4 10 8 14 16 6"/>
</svg>
</button>
<button @click="markAsDiscarded(item)" class="btn-icon btn-icon-discard" aria-label="Not used">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<path d="M4 4l12 12M4 16L16 4"/>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- Expired panel -->
<template v-else-if="expiryView === 'expired'">
<div v-if="!store.expiredItems.length" class="empty-state">
<p class="text-secondary">No expired items.</p>
</div>
<div v-else class="expiry-panel">
<!-- Items with a secondary use window -->
<div v-if="secondaryStateItems.length" class="expiry-group">
<div class="expiry-group-label expiry-group-secondary">Still useful with the right recipe</div>
<div
v-for="item in secondaryStateItems"
:key="item.id"
class="expiry-item-row expiry-item-secondary"
>
<span :class="['loc-dot', `loc-dot-${item.location}`]"></span>
<div class="expiry-item-name">
<span class="inv-name">{{ item.product_name || 'Unknown' }}</span>
<span class="secondary-state-badge">{{ item.secondary_state }}</span>
<span v-if="item.secondary_uses?.length" class="secondary-uses-text">
Good for: {{ item.secondary_uses!.slice(0, 3).join(', ') }}
</span>
<span v-if="item.secondary_warning" class="secondary-warning-text">
{{ item.secondary_warning }}
</span>
</div>
<div class="expiry-item-right">
<span class="inv-qty">{{ formatQuantity(item.quantity, item.unit, settingsStore.unitSystem) }}</span>
<div class="inv-actions">
<button @click="markAsConsumed(item)" class="btn-icon btn-icon-success" aria-label="Use now">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<polyline points="4 10 8 14 16 6"/>
</svg>
</button>
<button @click="markAsDiscarded(item)" class="btn-icon btn-icon-discard" aria-label="Not used">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<path d="M4 4l12 12M4 16L16 4"/>
</svg>
</button>
</div>
</div>
</div>
</div>
<!-- Truly expired past secondary window -->
<div v-if="trulyExpiredItems.length" class="expiry-group">
<div class="expiry-group-label expiry-group-done">Time to let it go</div>
<div
v-for="item in trulyExpiredItems"
:key="item.id"
class="expiry-item-row"
>
<span :class="['loc-dot', `loc-dot-${item.location}`]"></span>
<div class="expiry-item-name">
<span class="inv-name">{{ item.product_name || 'Unknown' }}</span>
<span v-if="item.category" class="inv-category">{{ item.category }}</span>
</div>
<div class="expiry-item-right">
<span class="inv-qty">{{ formatQuantity(item.quantity, item.unit, settingsStore.unitSystem) }}</span>
<span class="expiry-badge expiry-expired">{{ daysLabel(item.expiration_date!) }}</span>
<div class="inv-actions">
<button @click="markAsDiscarded(item)" class="btn-icon btn-icon-discard" aria-label="Not used">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<path d="M4 4l12 12M4 16L16 4"/>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<!-- Normal filter + list view -->
<template v-else>
<div class="filter-row">
<div class="filter-chip-row">
<button
@ -321,11 +488,18 @@
<!-- Right side: qty + expiry + actions -->
<div class="inv-row-right">
<span class="inv-qty">{{ item.quantity }}<span class="inv-unit"> {{ item.unit }}</span></span>
<span class="inv-qty">{{ formatQuantity(item.quantity, item.unit, settingsStore.unitSystem) }}</span>
<!-- Opened expiry takes priority over sell-by date -->
<span
v-if="item.expiration_date"
v-if="item.opened_expiry_date"
:class="['expiry-badge', 'expiry-opened', getExpiryBadgeClass(item.opened_expiry_date)]"
:title="`Opened · ${formatDateFull(item.opened_expiry_date)}`"
>📂 {{ formatDateShort(item.opened_expiry_date) }}</span>
<span
v-else-if="item.expiration_date"
:class="['expiry-badge', getExpiryBadgeClass(item.expiration_date)]"
:title="formatDateFull(item.expiration_date)"
>{{ formatDateShort(item.expiration_date) }}</span>
<div class="inv-actions">
@ -334,11 +508,41 @@
<path d="M13.586 3.586a2 2 0 112.828 2.828L7 14.828 4 16l1.172-3L13.586 3.586z"/>
</svg>
</button>
<button @click="markAsConsumed(item)" class="btn-icon btn-icon-success" aria-label="Mark consumed">
<button
v-if="!item.opened_date && item.status === 'available'"
@click="markAsOpened(item)"
class="btn-icon btn-icon-open"
aria-label="Mark as opened today"
title="I opened this today"
>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<path d="M5 8V6a7 7 0 0114 0v2"/>
<rect x="3" y="8" width="14" height="10" rx="2"/>
<circle cx="10" cy="13" r="1.5" fill="currentColor"/>
</svg>
</button>
<button
v-if="item.status === 'available'"
@click="markAsConsumed(item)"
class="btn-icon btn-icon-success"
:aria-label="item.quantity > 1 ? `Use some (${item.quantity} ${item.unit})` : 'Mark as used'"
:title="item.quantity > 1 ? `Use some or all (${item.quantity} ${item.unit})` : 'Mark as used'"
>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<polyline points="4 10 8 14 16 6"/>
</svg>
</button>
<button
v-if="item.status === 'available'"
@click="markAsDiscarded(item)"
class="btn-icon btn-icon-discard"
aria-label="Mark as not used"
title="I didn't use this (went bad, too much, etc)"
>
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<path d="M4 4l12 12M4 16L16 4"/>
</svg>
</button>
<button @click="confirmDelete(item)" class="btn-icon btn-icon-danger" aria-label="Delete">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
<polyline points="3 6 5 6 17 6"/>
@ -350,15 +554,20 @@
</div>
</div>
</div>
</template><!-- end v-else normal view -->
</div>
<!-- Export -->
<div class="card export-card">
<h2 class="section-title">Export</h2>
<div class="flex gap-sm" style="margin-top: var(--spacing-sm)">
<div class="flex gap-sm flex-wrap" style="margin-top: var(--spacing-sm)">
<button @click="exportJSON" class="btn btn-primary">Download JSON (full backup)</button>
<button @click="exportCSV" class="btn btn-secondary">Download CSV</button>
<button @click="exportExcel" class="btn btn-secondary">Download Excel</button>
</div>
<p class="text-sm text-secondary" style="margin-top: var(--spacing-xs)">
JSON includes pantry + saved recipes. Import it into another Kiwi instance any time.
</p>
</div>
<!-- Edit Modal -->
@ -380,6 +589,22 @@
@cancel="confirmDialog.show = false"
/>
<!-- Action Dialog (partial consume / discard reason) -->
<ActionDialog
:show="actionDialog.show"
:title="actionDialog.title"
:message="actionDialog.message"
:type="actionDialog.type"
:confirm-text="actionDialog.confirmText"
:input-type="actionDialog.inputType"
:input-label="actionDialog.inputLabel"
:input-max="actionDialog.inputMax"
:input-unit="actionDialog.inputUnit"
:input-options="actionDialog.inputOptions"
@confirm="(v) => { actionDialog.onConfirm(v); actionDialog.show = false }"
@cancel="actionDialog.show = false"
/>
<!-- Toast Notification -->
<ToastNotification
:show="toast.show"
@ -395,18 +620,66 @@
import { ref, computed, onMounted, reactive } from 'vue'
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 { formatQuantity } from '../utils/units'
import EditItemModal from './EditItemModal.vue'
import ConfirmDialog from './ConfirmDialog.vue'
import ActionDialog from './ActionDialog.vue'
import ToastNotification from './ToastNotification.vue'
const store = useInventoryStore()
const settingsStore = useSettingsStore()
const { items, stats, loading, locationFilter, statusFilter } = storeToRefs(store)
const filteredItems = computed(() => store.filteredItems)
const editingItem = ref<InventoryItem | null>(null)
// Expiry view
const expiryView = ref<'soon' | 'expired' | null>(null)
function toggleExpiryView(mode: 'soon' | 'expired') {
expiryView.value = expiryView.value === mode ? null : mode
// Ensure available items are loaded so computeds have data
if (expiryView.value && statusFilter.value !== 'available' && statusFilter.value !== 'all') {
statusFilter.value = 'available'
store.fetchItems()
}
}
const urgentItems = computed(() =>
store.expiringItems.filter((item) => {
if (!item.expiration_date) return false
const diff = Math.ceil((new Date(item.expiration_date).getTime() - Date.now()) / 86_400_000)
return diff <= 3
})
)
const soonItems = computed(() =>
store.expiringItems.filter((item) => {
if (!item.expiration_date) return false
const diff = Math.ceil((new Date(item.expiration_date).getTime() - Date.now()) / 86_400_000)
return diff > 3
})
)
const secondaryStateItems = computed(() =>
store.expiredItems.filter((item) => item.secondary_state != null)
)
const trulyExpiredItems = computed(() =>
store.expiredItems.filter((item) => item.secondary_state == null)
)
function daysLabel(dateStr: string): string {
const diff = Math.ceil((new Date(dateStr).getTime() - Date.now()) / 86_400_000)
if (diff < 0) return `${Math.abs(diff)}d ago`
if (diff === 0) return 'today'
if (diff === 1) return '1 day'
return `${diff} days`
}
// Scan mode toggle
const scanMode = ref<'gun' | 'camera' | 'manual'>('gun')
@ -443,6 +716,20 @@ const confirmDialog = reactive({
onConfirm: () => {},
})
const actionDialog = reactive({
show: false,
title: '',
message: '',
type: 'primary' as 'primary' | 'danger' | 'warning' | 'secondary',
confirmText: 'Confirm',
inputType: null as 'quantity' | 'select' | null,
inputLabel: '',
inputMax: 1,
inputUnit: '',
inputOptions: [] as string[],
onConfirm: (_v: number | string | undefined) => {},
})
// Toast Notification
const toast = reactive({
show: false,
@ -552,24 +839,75 @@ async function confirmDelete(item: InventoryItem) {
)
}
async function markAsConsumed(item: InventoryItem) {
showConfirm(
`Mark ${item.product_name || 'item'} as consumed?`,
async () => {
async function markAsOpened(item: InventoryItem) {
try {
await inventoryAPI.openItem(item.id)
await refreshItems()
showToast(`${item.product_name || 'Item'} marked as opened — tracking freshness`, 'info')
} catch {
showToast('Could not mark item as opened', 'error')
}
}
function markAsConsumed(item: InventoryItem) {
const isMulti = item.quantity > 1
const label = item.product_name || 'item'
Object.assign(actionDialog, {
show: true,
title: 'Mark as Used',
message: isMulti
? `How much of ${label} did you use?`
: `Mark ${label} as used?`,
type: 'primary',
confirmText: isMulti ? 'Use' : 'Mark as Used',
inputType: isMulti ? 'quantity' : null,
inputLabel: 'Amount used:',
inputMax: item.quantity,
inputUnit: item.unit,
inputOptions: [],
onConfirm: async (val: number | string | undefined) => {
const qty = isMulti ? (val as number) : undefined
try {
await inventoryAPI.consumeItem(item.id)
await inventoryAPI.consumeItem(item.id, qty)
await refreshItems()
showToast(`${item.product_name || 'item'} marked as consumed`, 'success')
} catch (err) {
showToast('Failed to mark item as consumed', 'error')
const verb = qty !== undefined && qty < item.quantity ? 'partially used' : 'marked as used'
showToast(`${label} ${verb}`, 'success')
} catch {
showToast('Could not update item', 'error')
}
},
{
title: 'Mark as Consumed',
type: 'primary',
confirmText: 'Mark as Consumed',
}
)
})
}
function markAsDiscarded(item: InventoryItem) {
const label = item.product_name || 'item'
Object.assign(actionDialog, {
show: true,
title: 'Item Not Used',
message: `${label} — what happened to it?`,
type: 'secondary',
confirmText: 'Log It',
inputType: 'select',
inputLabel: 'Reason (optional):',
inputMax: 1,
inputUnit: '',
inputOptions: [
'went bad before I could use it',
'too much — had excess',
'changed my mind',
'other',
],
onConfirm: async (val: number | string | undefined) => {
const reason = typeof val === 'string' && val ? val : undefined
try {
await inventoryAPI.discardItem(item.id, reason)
await refreshItems()
showToast(`${label} logged as not used`, 'info')
} catch {
showToast('Could not update item', 'error')
}
},
})
}
// Scanner Gun Functions
@ -588,13 +926,22 @@ async function handleScannerGunInput() {
true
)
if (result.success && result.barcodes_found > 0) {
const item = result.results[0]
const item = result.results[0]
if (item?.added_to_inventory) {
const productName = item.product?.name || 'item'
const productBrand = item.product?.brand ? ` (${item.product.brand})` : ''
scannerResults.value.push({
type: 'success',
message: `Added: ${item.product_name || 'item'} to ${scannerLocation.value}`,
message: `Added: ${productName}${productBrand} to ${scannerLocation.value}`,
})
await refreshItems()
} else if (item?.needs_manual_entry) {
// Barcode not found in any database guide user to manual entry
scannerResults.value.push({
type: 'warning',
message: `Barcode ${barcode} not found. Fill in the details below.`,
})
scanMode.value = 'manual'
} else {
scannerResults.value.push({
type: 'error',
@ -609,7 +956,7 @@ async function handleScannerGunInput() {
} finally {
scannerLoading.value = false
scannerBarcode.value = ''
scannerGunInput.value?.focus()
if (scanMode.value === 'gun') scannerGunInput.value?.focus()
}
}
@ -718,20 +1065,40 @@ function exportExcel() {
window.open(`${apiUrl}/export/inventory/excel`, '_blank')
}
// Short date for compact row display
function formatDateShort(dateStr: string): string {
function exportJSON() {
const apiUrl = import.meta.env.VITE_API_URL || '/api/v1'
window.open(`${apiUrl}/export/json`, '_blank')
}
// Full date string for tooltip (accessible label)
function formatDateFull(dateStr: string): string {
const date = new Date(dateStr)
const today = new Date()
today.setHours(0, 0, 0, 0)
const expiry = new Date(dateStr)
expiry.setHours(0, 0, 0, 0)
const diffDays = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
const cal = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
if (diffDays < 0) return `Expired ${cal}`
if (diffDays === 0) return `Expires today (${cal})`
if (diffDays === 1) return `Expires tomorrow (${cal})`
return `Expires in ${diffDays} days (${cal})`
}
// Short date for compact row display
function formatDateShort(dateStr: string): string {
const today = new Date()
today.setHours(0, 0, 0, 0)
const expiry = new Date(dateStr)
expiry.setHours(0, 0, 0, 0)
const diffDays = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
const cal = new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
if (diffDays < 0) return `${Math.abs(diffDays)}d ago`
if (diffDays === 0) return 'today'
if (diffDays === 1) return 'tmrw'
if (diffDays <= 14) return `${diffDays}d`
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
if (diffDays === 1) return `tmrw · ${cal}`
if (diffDays <= 14) return `${diffDays}d · ${cal}`
return cal
}
function getExpiryBadgeClass(expiryStr: string): string {
@ -1144,6 +1511,30 @@ function getItemClass(item: InventoryItem): string {
text-decoration: line-through;
}
/* "I opened this today" button */
.btn-icon-open {
color: var(--color-warning);
}
.btn-icon-open:hover {
background: var(--color-warning-bg);
}
/* "Item not used" discard button — muted, not alarming */
.btn-icon-discard {
color: var(--color-text-tertiary);
}
.btn-icon-discard:hover {
color: var(--color-text-secondary);
background: var(--color-bg-secondary);
}
/* Opened badge — distinct icon prefix signals this is after-open expiry */
.expiry-opened {
letter-spacing: 0;
}
/* Action icons inline */
.inv-actions {
display: flex;
@ -1217,6 +1608,12 @@ function getItemClass(item: InventoryItem): string {
border: 1px solid var(--color-info-border);
}
.result-warning {
background: var(--color-warning-bg, #fffbeb);
color: var(--color-warning-dark, #92400e);
border: 1px solid var(--color-warning-border, #fcd34d);
}
/* ============================================
EXPORT CARD
============================================ */
@ -1310,4 +1707,125 @@ function getItemClass(item: InventoryItem): string {
flex-wrap: nowrap;
}
}
/* ============================================
STATS clickable badges
============================================ */
.stat-clickable {
cursor: pointer;
transition: background var(--transition-fast);
}
.stat-clickable:hover {
background: var(--color-bg-secondary);
}
.stat-clickable.stat-active {
background: var(--color-bg-secondary);
box-shadow: inset 0 -2px 0 var(--color-accent);
}
/* ============================================
EXPIRY PANEL
============================================ */
.expiry-back-btn {
background: none;
border: none;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
cursor: pointer;
padding: 0;
display: inline-flex;
align-items: center;
gap: 4px;
}
.expiry-back-btn:hover {
color: var(--color-text-primary);
}
.expiry-panel {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.expiry-group {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.expiry-group-label {
font-size: var(--font-size-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: var(--spacing-xs) 0;
color: var(--color-text-muted);
border-bottom: 1px solid var(--color-border);
margin-bottom: 2px;
}
.expiry-group-urgent { color: var(--color-error); }
.expiry-group-soon { color: var(--color-warning); }
.expiry-group-secondary { color: var(--color-success); }
.expiry-group-done { color: var(--color-text-muted); }
.expiry-item-row {
display: flex;
align-items: flex-start;
gap: var(--spacing-sm);
padding: var(--spacing-sm) 0;
border-bottom: 1px solid var(--color-border-subtle, var(--color-border));
}
.expiry-item-row:last-child {
border-bottom: none;
}
.expiry-item-secondary {
background: color-mix(in srgb, var(--color-success) 5%, transparent);
border-radius: var(--radius-md);
padding: var(--spacing-sm);
}
.expiry-item-name {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.expiry-item-right {
display: flex;
align-items: center;
gap: var(--spacing-xs);
flex-shrink: 0;
}
.secondary-state-badge {
display: inline-flex;
align-items: center;
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--color-success);
font-size: var(--font-size-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 1px 6px;
border-radius: var(--radius-sm);
width: fit-content;
}
.secondary-uses-text {
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
}
.secondary-warning-text {
font-size: var(--font-size-xs);
color: var(--color-warning);
}
</style>

View file

@ -14,7 +14,7 @@
<div v-show="!collapsed" class="grid-body">
<!-- Day headers -->
<div class="day-headers">
<div class="day-headers" :class="{ 'headers-editing': editing }">
<div class="meal-type-col-spacer" />
<div
v-for="(day, i) in DAY_LABELS"
@ -26,11 +26,35 @@
<!-- One row per meal type -->
<div
v-for="mealType in activeMealTypes"
v-for="(mealType, idx) in activeMealTypes"
:key="mealType"
class="meal-row"
:class="{ 'row-editing': editing }"
>
<div class="meal-type-label">{{ mealType }}</div>
<div class="meal-type-label" :class="{ 'label-editing': editing }">
<template v-if="editing">
<button
class="reorder-btn"
:disabled="idx === 0 || mealTypeChanging"
aria-label="Move up"
@click="onMoveUp(idx)"
></button>
<button
class="reorder-btn"
:disabled="idx === activeMealTypes.length - 1 || mealTypeChanging"
aria-label="Move down"
@click="onMoveDown(idx)"
></button>
<span class="label-text">{{ mealType }}</span>
<button
class="remove-btn"
:disabled="activeMealTypes.length <= 1 || mealTypeChanging"
:aria-label="`Remove ${mealType}`"
@click="$emit('remove-meal-type', mealType)"
></button>
</template>
<template v-else>{{ mealType }}</template>
</div>
<button
v-for="dayIndex in 7"
:key="dayIndex - 1"
@ -46,11 +70,17 @@
</button>
</div>
<!-- Add meal type row (Paid only) -->
<div v-if="canAddMealType" class="add-meal-type-row">
<button class="add-meal-type-btn" @click="$emit('add-meal-type')">
<!-- Add / edit meal type controls (Paid only) -->
<div v-if="canAddMealType || activeMealTypes.length > 1" class="add-meal-type-row">
<button v-if="canAddMealType && !editing" class="add-meal-type-btn" @click="$emit('add-meal-type')">
+ Add meal type
</button>
<button
v-if="activeMealTypes.length > 1"
class="edit-types-btn"
:class="{ active: editing }"
@click="editing = !editing"
>{{ editing ? 'Done' : 'Edit types' }}</button>
</div>
</div>
</div>
@ -60,22 +90,44 @@
import { ref } from 'vue'
import { useMealPlanStore } from '../stores/mealPlan'
defineProps<{
const props = defineProps<{
activeMealTypes: string[]
canAddMealType: boolean
mealTypeChanging?: boolean
}>()
defineEmits<{
const emit = defineEmits<{
(e: 'slot-click', payload: { dayOfWeek: number; mealType: string }): void
(e: 'add-meal-type'): void
(e: 'remove-meal-type', mealType: string): void
(e: 'reorder-meal-types', newOrder: string[]): void
}>()
const store = useMealPlanStore()
const { getSlot } = store
const collapsed = ref(false)
const editing = ref(false)
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
function onMoveUp(idx: number) {
if (idx === 0) return
const arr = [...props.activeMealTypes]
const tmp = arr[idx - 1]!
arr[idx - 1] = arr[idx]!
arr[idx] = tmp
emit('reorder-meal-types', arr)
}
function onMoveDown(idx: number) {
if (idx === props.activeMealTypes.length - 1) return
const arr = [...props.activeMealTypes]
const tmp = arr[idx]!
arr[idx] = arr[idx + 1]!
arr[idx + 1] = tmp
emit('reorder-meal-types', arr)
}
</script>
<style scoped>
@ -117,10 +169,38 @@ const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
.slot-title { text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; color: var(--color-text); }
.slot-empty { opacity: 0.25; font-size: 1rem; }
.add-meal-type-row { padding: 0.4rem 0 0.2rem; }
.add-meal-type-row { padding: 0.4rem 0 0.2rem; display: flex; gap: 0.75rem; align-items: center; }
.add-meal-type-btn { font-size: 0.75rem; background: none; border: none; cursor: pointer; color: var(--color-accent); padding: 0; }
.edit-types-btn {
font-size: 0.75rem; background: none; border: none; cursor: pointer;
color: var(--color-text-secondary); padding: 0;
}
.edit-types-btn:hover { color: var(--color-text); }
.edit-types-btn.active { color: var(--color-accent); font-weight: 600; }
/* Edit mode — expand label column to fit controls */
.row-editing, .headers-editing { grid-template-columns: auto repeat(7, 1fr); }
.label-editing {
flex-direction: row; align-items: center; gap: 2px;
opacity: 1; white-space: nowrap;
}
.label-text { flex: 1; font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; font-weight: 600; padding: 0 2px; }
.reorder-btn {
background: none; border: 1px solid var(--color-border); border-radius: 3px;
cursor: pointer; font-size: 0.6rem; padding: 1px 3px; line-height: 1;
color: var(--color-text-secondary); min-width: 16px;
}
.reorder-btn:hover:not(:disabled) { border-color: var(--color-accent); color: var(--color-accent); }
.reorder-btn:disabled { opacity: 0.25; cursor: default; }
.remove-btn {
background: none; border: none; cursor: pointer; font-size: 0.65rem;
color: var(--color-text-secondary); padding: 1px 3px; border-radius: 3px; line-height: 1;
}
.remove-btn:hover:not(:disabled) { color: var(--color-error, #e05252); background: var(--color-error-subtle, #fef2f2); }
.remove-btn:disabled { opacity: 0.25; cursor: default; }
@media (max-width: 600px) {
.day-headers, .meal-row { grid-template-columns: 2.5rem repeat(7, 1fr); }
.row-editing, .headers-editing { grid-template-columns: auto repeat(7, 1fr); }
}
</style>

View file

@ -14,18 +14,88 @@
Week of {{ p.week_start }}
</option>
</select>
<button class="new-plan-btn" @click="onNewPlan">+ New week</button>
<button class="new-plan-btn" @click="onNewPlan" :disabled="planCreating">
{{ planCreating ? 'Creating…' : '+ New week' }}
</button>
</div>
<p v-if="planError" class="plan-error">{{ planError }}</p>
<template v-if="activePlan">
<!-- Compact expandable week grid (always visible) -->
<MealPlanGrid
:active-meal-types="activePlan.meal_types"
:can-add-meal-type="canAddMealType"
:meal-type-changing="mealTypeAdding"
@slot-click="onSlotClick"
@add-meal-type="onAddMealType"
@remove-meal-type="onRemoveMealType"
@reorder-meal-types="onReorderMealTypes"
/>
<!-- Slot editor panel -->
<div v-if="slotEditing" class="slot-editor card">
<div class="slot-editor-header">
<span class="slot-editor-title">
{{ DAY_LABELS[slotEditing.dayOfWeek] }} · {{ slotEditing.mealType }}
</span>
<button class="close-btn" @click="slotEditing = null" aria-label="Close"></button>
</div>
<!-- Custom label -->
<div class="form-group">
<label class="form-label">Custom label</label>
<input
v-model="slotCustomLabel"
class="form-input"
type="text"
placeholder="e.g. Taco night, Leftovers…"
maxlength="80"
/>
</div>
<!-- Pick from saved recipes -->
<div v-if="savedStore.saved.length" class="form-group">
<label class="form-label">Or pick a saved recipe</label>
<select class="week-select" v-model="slotRecipeId">
<option :value="null"> None </option>
<option v-for="r in savedStore.saved" :key="r.recipe_id" :value="r.recipe_id">
{{ r.title }}
</option>
</select>
</div>
<p v-else class="slot-hint">Save recipes from the Recipes tab to pick them here.</p>
<div class="slot-editor-actions">
<button class="btn-secondary" @click="slotEditing = null">Cancel</button>
<button
v-if="currentSlot"
class="btn-danger-subtle"
@click="onClearSlot"
:disabled="slotSaving"
>Clear slot</button>
<button
class="btn-primary"
@click="onSaveSlot"
:disabled="slotSaving"
>{{ slotSaving ? 'Saving…' : 'Save' }}</button>
</div>
</div>
<!-- Meal type picker -->
<div v-if="addingMealType" class="meal-type-picker card">
<span class="slot-editor-title">Add meal type</span>
<div class="chip-row">
<button
v-for="t in availableMealTypes"
:key="t"
class="btn-chip"
:disabled="mealTypeAdding"
@click="onPickMealType(t)"
>{{ t }}</button>
</div>
<button class="close-link" @click="addingMealType = false">Cancel</button>
</div>
<!-- Panel tabs: Shopping List | Prep Schedule -->
<div class="panel-tabs" role="tablist" aria-label="Plan outputs">
<button
@ -64,7 +134,9 @@
<div v-else-if="!loading" class="empty-plan-state">
<p>No meal plan yet for this week.</p>
<button class="new-plan-btn" @click="onNewPlan">Start planning</button>
<button class="new-plan-btn" @click="onNewPlan" :disabled="planCreating">
{{ planCreating ? 'Creating…' : 'Start planning' }}
</button>
</div>
</div>
</template>
@ -73,49 +145,154 @@
import { ref, computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useMealPlanStore } from '../stores/mealPlan'
import { useSavedRecipesStore } from '../stores/savedRecipes'
import MealPlanGrid from './MealPlanGrid.vue'
import ShoppingListPanel from './ShoppingListPanel.vue'
import PrepSessionView from './PrepSessionView.vue'
import type { MealPlanSlot } from '../services/api'
const TABS = [
{ id: 'shopping', label: 'Shopping List' },
{ id: 'prep', label: 'Prep Schedule' },
] as const
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const ALL_MEAL_TYPES = ['breakfast', 'lunch', 'dinner', 'snack']
type TabId = typeof TABS[number]['id']
const store = useMealPlanStore()
const savedStore = useSavedRecipesStore()
const { plans, activePlan, loading } = storeToRefs(store)
const activeTab = ref<TabId>('shopping')
const planError = ref<string | null>(null)
const planCreating = ref(false)
// slot editor
const slotEditing = ref<{ dayOfWeek: number; mealType: string } | null>(null)
const slotCustomLabel = ref('')
const slotRecipeId = ref<number | null>(null)
const slotSaving = ref(false)
const currentSlot = computed((): MealPlanSlot | undefined => {
if (!slotEditing.value) return undefined
return store.getSlot(slotEditing.value.dayOfWeek, slotEditing.value.mealType)
})
// meal type picker
const addingMealType = ref(false)
const mealTypeAdding = ref(false)
const availableMealTypes = computed(() =>
ALL_MEAL_TYPES.filter(t => !activePlan.value?.meal_types.includes(t))
)
// canAddMealType is a UI hint backend enforces the paid gate authoritatively
const canAddMealType = computed(() =>
(activePlan.value?.meal_types.length ?? 0) < 4
)
onMounted(() => store.loadPlans())
onMounted(async () => {
await Promise.all([store.loadPlans(), savedStore.load()])
store.autoSelectPlan(mondayOfCurrentWeek())
})
function mondayOfCurrentWeek(): string {
const today = new Date()
const day = today.getDay() // 0=Sun, 1=Mon...
// Build date string from local parts to avoid UTC-offset day drift
const d = new Date(today)
d.setDate(today.getDate() - ((day + 6) % 7))
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
async function onNewPlan() {
const today = new Date()
const day = today.getDay()
// Compute Monday of current week (getDay: 0=Sun, 1=Mon...)
const monday = new Date(today)
monday.setDate(today.getDate() - ((day + 6) % 7))
const weekStart = monday.toISOString().split('T')[0]
await store.createPlan(weekStart, ['dinner'])
planError.value = null
planCreating.value = true
const weekStart = mondayOfCurrentWeek()
try {
await store.createPlan(weekStart, ['dinner'])
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
if (msg.includes('409') || msg.toLowerCase().includes('already exists')) {
const existing = plans.value.find(p => p.week_start === weekStart)
if (existing) await store.setActivePlan(existing.id)
} else {
planError.value = `Couldn't create plan: ${msg}`
}
} finally {
planCreating.value = false
}
}
async function onSelectPlan(planId: number) {
if (planId) await store.setActivePlan(planId)
}
function onSlotClick(_: { dayOfWeek: number; mealType: string }) {
// Recipe picker integration filed as follow-up
function onSlotClick(payload: { dayOfWeek: number; mealType: string }) {
slotEditing.value = payload
const existing = store.getSlot(payload.dayOfWeek, payload.mealType)
slotCustomLabel.value = existing?.custom_label ?? ''
slotRecipeId.value = existing?.recipe_id ?? null
}
async function onSaveSlot() {
if (!slotEditing.value) return
slotSaving.value = true
try {
await store.upsertSlot(slotEditing.value.dayOfWeek, slotEditing.value.mealType, {
recipe_id: slotRecipeId.value,
custom_label: slotCustomLabel.value.trim() || null,
})
slotEditing.value = null
} finally {
slotSaving.value = false
}
}
async function onClearSlot() {
if (!slotEditing.value) return
slotSaving.value = true
try {
await store.clearSlot(slotEditing.value.dayOfWeek, slotEditing.value.mealType)
slotEditing.value = null
} finally {
slotSaving.value = false
}
}
function onAddMealType() {
// Add meal type picker Paid gate enforced by backend
addingMealType.value = true
}
async function onPickMealType(mealType: string) {
mealTypeAdding.value = true
try {
await store.addMealType(mealType)
addingMealType.value = false
} finally {
mealTypeAdding.value = false
}
}
async function onRemoveMealType(mealType: string) {
mealTypeAdding.value = true
try {
await store.removeMealType(mealType)
} finally {
mealTypeAdding.value = false
}
}
async function onReorderMealTypes(newOrder: string[]) {
mealTypeAdding.value = true
try {
await store.reorderMealTypes(newOrder)
} finally {
mealTypeAdding.value = false
}
}
</script>
@ -135,6 +312,29 @@ function onAddMealType() {
}
.new-plan-btn:hover { background: var(--color-accent); color: white; }
/* Slot editor */
.slot-editor, .meal-type-picker {
padding: 1rem; border-radius: 8px;
border: 1px solid var(--color-border); background: var(--color-surface);
display: flex; flex-direction: column; gap: 0.75rem;
}
.slot-editor-header { display: flex; align-items: center; justify-content: space-between; }
.slot-editor-title { font-size: 0.85rem; font-weight: 600; }
.close-btn {
background: none; border: none; cursor: pointer; font-size: 0.9rem;
color: var(--color-text-secondary); padding: 0.1rem 0.3rem; border-radius: 4px;
}
.close-btn:hover { background: var(--color-surface-2); }
.slot-hint { font-size: 0.8rem; opacity: 0.55; margin: 0; }
.slot-editor-actions { display: flex; gap: 0.5rem; justify-content: flex-end; flex-wrap: wrap; }
.chip-row { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.close-link {
background: none; border: none; cursor: pointer; font-size: 0.8rem;
color: var(--color-text-secondary); align-self: flex-start; padding: 0;
}
.close-link:hover { text-decoration: underline; }
.panel-tabs { display: flex; gap: 6px; border-bottom: 1px solid var(--color-border); padding-bottom: 0; }
.panel-tab {
font-size: 0.82rem; padding: 0.4rem 1rem; border-radius: 6px 6px 0 0;
@ -150,4 +350,11 @@ function onAddMealType() {
.tab-panel { padding-top: 0.75rem; }
.empty-plan-state { text-align: center; padding: 2rem 0; opacity: 0.6; font-size: 0.9rem; }
.plan-error {
font-size: 0.82rem; color: var(--color-error, #e05252);
background: var(--color-error-subtle, #fef2f2);
border: 1px solid var(--color-error, #e05252); border-radius: 6px;
padding: 0.4rem 0.75rem; margin: 0;
}
</style>

View file

@ -0,0 +1,70 @@
<template>
<!-- Only shown when user has opted in AND a lifetime key is present (usage != null) -->
<div
v-if="enabled && usage !== null"
class="orch-usage-pill"
:class="{ 'orch-usage-pill--low': isLow }"
:title="`Cloud recipe calls this period: ${usage.calls_used} of ${usage.calls_total}`"
>
<span class="orch-usage-pill__label">
{{ usage.calls_used + usage.topup_calls }} / {{ usage.calls_total }} calls
<span class="orch-usage-pill__reset">· resets {{ resetsLabel }}</span>
</span>
<a
class="orch-usage-pill__topup"
href="https://circuitforge.tech/kiwi/topup"
target="_blank"
rel="noopener"
>Topup</a>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useOrchUsage } from '@/composables/useOrchUsage'
const { usage, enabled, resetsLabel } = useOrchUsage()
// Warn visually when less than 20% remains calm yellow only, no red/panic
const isLow = computed(() => {
if (!usage.value || usage.value.calls_total === 0) return false
const remaining = usage.value.calls_total - usage.value.calls_used + usage.value.topup_calls
return remaining / usage.value.calls_total < 0.2
})
</script>
<style scoped>
.orch-usage-pill {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
padding: 0.25rem 0.625rem;
border-radius: 999px;
font-size: var(--font-size-sm);
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.orch-usage-pill--low {
background: var(--color-warning-bg);
border-color: var(--color-warning-border);
color: var(--color-warning);
}
.orch-usage-pill__reset {
opacity: 0.7;
}
.orch-usage-pill__topup {
color: var(--color-primary);
text-decoration: none;
font-weight: 500;
white-space: nowrap;
margin-left: var(--spacing-xs);
}
.orch-usage-pill__topup:hover {
text-decoration: underline;
}
</style>

View file

@ -0,0 +1,365 @@
<template>
<Teleport to="body">
<div
class="modal-overlay"
@click.self="$emit('close')"
>
<div
ref="dialogRef"
class="modal-panel card"
role="dialog"
aria-modal="true"
aria-labelledby="publish-outcome-title"
tabindex="-1"
>
<!-- Header -->
<div class="flex-between mb-md">
<h2 id="publish-outcome-title" class="section-title">
Share a recipe story
<span v-if="recipeName" class="recipe-name-hint text-sm text-muted">
-- {{ recipeName }}
</span>
</h2>
<button
class="btn-close"
aria-label="Close"
@click="$emit('close')"
>&#x2715;</button>
</div>
<!-- Post type selector -->
<div class="form-group">
<fieldset class="type-fieldset">
<legend class="form-label">What kind of story is this?</legend>
<div class="type-toggle flex gap-sm">
<button
ref="firstFocusRef"
:class="['btn', 'type-btn', postType === 'recipe_success' ? 'type-btn-active' : 'btn-secondary']"
:aria-pressed="postType === 'recipe_success'"
@click="postType = 'recipe_success'"
>
Success
</button>
<button
:class="['btn', 'type-btn', postType === 'recipe_blooper' ? 'type-btn-active type-btn-blooper' : 'btn-secondary']"
:aria-pressed="postType === 'recipe_blooper'"
@click="postType = 'recipe_blooper'"
>
Blooper
</button>
</div>
</fieldset>
</div>
<!-- Title field -->
<div class="form-group">
<label class="form-label" for="outcome-title">
Title <span class="required-mark" aria-hidden="true">*</span>
</label>
<input
id="outcome-title"
v-model="title"
class="form-input"
type="text"
maxlength="200"
placeholder="e.g. Perfect crust on the first try"
autocomplete="off"
required
/>
<span class="form-hint char-counter" aria-live="polite" aria-atomic="true">
{{ title.length }}/200
</span>
</div>
<!-- Outcome notes field -->
<div class="form-group">
<label class="form-label" for="outcome-notes">
What happened? <span class="optional-mark">(optional)</span>
</label>
<textarea
id="outcome-notes"
v-model="outcomeNotes"
class="form-input form-textarea"
maxlength="2000"
rows="4"
placeholder="Describe what you tried, what worked, or what went sideways."
/>
<span class="form-hint char-counter" aria-live="polite" aria-atomic="true">
{{ outcomeNotes.length }}/2000
</span>
</div>
<!-- Pseudonym field -->
<div class="form-group">
<label class="form-label" for="outcome-pseudonym">
Community name <span class="optional-mark">(optional)</span>
</label>
<input
id="outcome-pseudonym"
v-model="pseudonymName"
class="form-input"
type="text"
maxlength="40"
placeholder="Leave blank to use your existing handle"
autocomplete="nickname"
/>
<span class="form-hint">How you appear on posts -- not your real name or email.</span>
</div>
<!-- Submission feedback (aria-live region, always rendered) -->
<div
class="feedback-region"
aria-live="polite"
aria-atomic="true"
>
<p v-if="submitError" class="feedback-error text-sm" role="alert">{{ submitError }}</p>
<p v-if="submitSuccess" class="feedback-success text-sm">{{ submitSuccess }}</p>
</div>
<!-- Footer actions -->
<div class="modal-footer flex gap-sm">
<button
class="btn btn-primary"
:disabled="submitting || !title.trim()"
:aria-busy="submitting"
@click="onSubmit"
>
<span v-if="submitting" class="spinner spinner-sm" aria-hidden="true"></span>
{{ submitting ? 'Publishing...' : 'Publish' }}
</button>
<button class="btn btn-secondary" @click="$emit('close')">
Cancel
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { useCommunityStore } from '../stores/community'
import type { PublishPayload } from '../stores/community'
const props = defineProps<{
recipeId: number | null
recipeName: string | null
visible?: boolean
}>()
const emit = defineEmits<{
close: []
published: [payload: { slug: string }]
}>()
const store = useCommunityStore()
const postType = ref<'recipe_success' | 'recipe_blooper'>('recipe_success')
const title = ref('')
const outcomeNotes = ref('')
const pseudonymName = ref('')
const submitting = ref(false)
const submitError = ref<string | null>(null)
const submitSuccess = ref<string | null>(null)
const dialogRef = ref<HTMLElement | null>(null)
const firstFocusRef = ref<HTMLButtonElement | null>(null)
let previousFocus: HTMLElement | null = null
function getFocusables(): HTMLElement[] {
if (!dialogRef.value) return []
return Array.from(
dialogRef.value.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
)
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
emit('close')
return
}
if (e.key !== 'Tab') return
// Only intercept Tab when focus is inside this dialog
if (!dialogRef.value?.contains(document.activeElement)) return
const focusables = getFocusables()
if (focusables.length === 0) return
const first = focusables[0]!
const last = focusables[focusables.length - 1]!
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault()
last.focus()
}
} else {
if (document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
onMounted(() => {
previousFocus = document.activeElement as HTMLElement
document.addEventListener('keydown', handleKeydown)
nextTick(() => {
(firstFocusRef.value ?? dialogRef.value)?.focus()
})
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
previousFocus?.focus()
})
async function onSubmit() {
submitError.value = null
submitSuccess.value = null
if (!title.value.trim()) return
const payload: PublishPayload = {
post_type: postType.value,
title: title.value.trim(),
}
if (outcomeNotes.value.trim()) payload.outcome_notes = outcomeNotes.value.trim()
if (pseudonymName.value.trim()) payload.pseudonym_name = pseudonymName.value.trim()
if (props.recipeId != null) payload.recipe_id = props.recipeId
submitting.value = true
try {
const result = await store.publishPost(payload)
submitSuccess.value = 'Your story has been posted.'
nextTick(() => {
emit('published', { slug: result.slug })
})
} catch (err: unknown) {
submitError.value = err instanceof Error ? err.message : 'Could not publish. Please try again.'
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 400;
padding: var(--spacing-md);
}
.modal-panel {
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.btn-close {
background: none;
border: none;
font-size: 1.1rem;
cursor: pointer;
color: var(--color-text-secondary);
padding: var(--spacing-xs);
line-height: 1;
border-radius: var(--radius-sm);
}
.btn-close:hover {
color: var(--color-text-primary);
}
.recipe-name-hint {
font-style: italic;
}
.required-mark {
color: var(--color-error);
margin-left: 2px;
}
.optional-mark {
font-weight: 400;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.type-fieldset {
border: none;
padding: 0;
margin: 0;
}
.type-toggle {
flex-wrap: wrap;
}
.type-btn {
min-width: 100px;
}
.type-btn-active {
background: var(--color-success);
color: white;
border-color: var(--color-success);
font-weight: 700;
}
.type-btn-active.type-btn-blooper {
background: var(--color-warning);
border-color: var(--color-warning);
color: var(--color-text-primary);
}
.char-counter {
text-align: right;
display: block;
margin-top: var(--spacing-xs);
}
.feedback-region {
min-height: 1.4rem;
margin-bottom: var(--spacing-xs);
}
.feedback-error {
color: var(--color-error);
margin: 0;
}
.feedback-success {
color: var(--color-success);
margin: 0;
}
.modal-footer {
justify-content: flex-start;
padding-top: var(--spacing-md);
border-top: 1px solid var(--color-border);
margin-top: var(--spacing-md);
flex-wrap: wrap;
}
@media (max-width: 480px) {
.modal-panel {
max-height: 95vh;
}
.modal-footer {
flex-direction: column-reverse;
}
.modal-footer .btn {
width: 100%;
}
}
</style>

View file

@ -0,0 +1,311 @@
<template>
<Teleport to="body">
<div
class="modal-overlay"
@click.self="$emit('close')"
>
<div
ref="dialogRef"
class="modal-panel card"
role="dialog"
aria-modal="true"
aria-labelledby="publish-plan-title"
tabindex="-1"
>
<!-- Header -->
<div class="flex-between mb-md">
<h2 id="publish-plan-title" class="section-title">Share this week's plan</h2>
<button
class="btn-close"
aria-label="Close"
@click="$emit('close')"
>&#x2715;</button>
</div>
<!-- Title field -->
<div class="form-group">
<label class="form-label" for="plan-pub-title">
Title <span class="required-mark" aria-hidden="true">*</span>
</label>
<input
id="plan-pub-title"
ref="firstFocusRef"
v-model="title"
class="form-input"
type="text"
maxlength="200"
placeholder="e.g. Mediterranean Week"
autocomplete="off"
required
/>
<span class="form-hint char-counter" aria-live="polite" aria-atomic="true">
{{ title.length }}/200
</span>
</div>
<!-- Description field -->
<div class="form-group">
<label class="form-label" for="plan-pub-desc">
Description <span class="optional-mark">(optional)</span>
</label>
<textarea
id="plan-pub-desc"
v-model="description"
class="form-input form-textarea"
maxlength="2000"
rows="3"
placeholder="What makes this week worth sharing?"
/>
<span class="form-hint char-counter" aria-live="polite" aria-atomic="true">
{{ description.length }}/2000
</span>
</div>
<!-- Pseudonym field -->
<div class="form-group">
<label class="form-label" for="plan-pub-pseudonym">
Community name <span class="optional-mark">(optional)</span>
</label>
<input
id="plan-pub-pseudonym"
v-model="pseudonymName"
class="form-input"
type="text"
maxlength="40"
placeholder="Leave blank to use your existing handle"
autocomplete="nickname"
/>
<span class="form-hint">How you appear on posts -- not your real name or email.</span>
</div>
<!-- Submission feedback (aria-live region, always rendered) -->
<div
class="feedback-region"
aria-live="polite"
aria-atomic="true"
>
<p v-if="submitError" class="feedback-error text-sm" role="alert">{{ submitError }}</p>
<p v-if="submitSuccess" class="feedback-success text-sm">{{ submitSuccess }}</p>
</div>
<!-- Footer actions -->
<div class="modal-footer flex gap-sm">
<button
class="btn btn-primary"
:disabled="submitting || !title.trim()"
:aria-busy="submitting"
@click="onSubmit"
>
<span v-if="submitting" class="spinner spinner-sm" aria-hidden="true"></span>
{{ submitting ? 'Publishing...' : 'Publish' }}
</button>
<button class="btn btn-secondary" @click="$emit('close')">
Cancel
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { useCommunityStore } from '../stores/community'
import type { PublishPayload } from '../stores/community'
const props = defineProps<{
plan?: {
id: number
week_start: string
slots: Array<{ day: string; meal_type: string; recipe_id: number; recipe_name: string }>
} | null
visible?: boolean
}>()
const emit = defineEmits<{
close: []
published: [payload: { slug: string }]
}>()
const store = useCommunityStore()
const title = ref('')
const description = ref('')
const pseudonymName = ref('')
const submitting = ref(false)
const submitError = ref<string | null>(null)
const submitSuccess = ref<string | null>(null)
const dialogRef = ref<HTMLElement | null>(null)
const firstFocusRef = ref<HTMLInputElement | null>(null)
let previousFocus: HTMLElement | null = null
function getFocusables(): HTMLElement[] {
if (!dialogRef.value) return []
return Array.from(
dialogRef.value.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
)
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
emit('close')
return
}
if (e.key !== 'Tab') return
// Only intercept Tab when focus is inside this dialog
if (!dialogRef.value?.contains(document.activeElement)) return
const focusables = getFocusables()
if (focusables.length === 0) return
const first = focusables[0]!
const last = focusables[focusables.length - 1]!
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault()
last.focus()
}
} else {
if (document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
onMounted(() => {
previousFocus = document.activeElement as HTMLElement
document.addEventListener('keydown', handleKeydown)
nextTick(() => {
(firstFocusRef.value ?? dialogRef.value)?.focus()
})
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
previousFocus?.focus()
})
async function onSubmit() {
submitError.value = null
submitSuccess.value = null
if (!title.value.trim()) return
const payload: PublishPayload = {
post_type: 'plan',
title: title.value.trim(),
}
if (description.value.trim()) payload.description = description.value.trim()
if (pseudonymName.value.trim()) payload.pseudonym_name = pseudonymName.value.trim()
if (props.plan?.id != null) payload.plan_id = props.plan.id
if (props.plan?.slots?.length) {
payload.slots = props.plan.slots.map(({ day, meal_type, recipe_id }) => ({ day, meal_type, recipe_id }))
}
submitting.value = true
try {
const result = await store.publishPost(payload)
submitSuccess.value = 'Plan published to the community feed.'
nextTick(() => {
emit('published', { slug: result.slug })
})
} catch (err: unknown) {
submitError.value = err instanceof Error ? err.message : 'Could not publish. Please try again.'
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 400;
padding: var(--spacing-md);
}
.modal-panel {
width: 100%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.btn-close {
background: none;
border: none;
font-size: 1.1rem;
cursor: pointer;
color: var(--color-text-secondary);
padding: var(--spacing-xs);
line-height: 1;
border-radius: var(--radius-sm);
}
.btn-close:hover {
color: var(--color-text-primary);
}
.required-mark {
color: var(--color-error);
margin-left: 2px;
}
.optional-mark {
font-weight: 400;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.char-counter {
text-align: right;
display: block;
margin-top: var(--spacing-xs);
}
.feedback-region {
min-height: 1.4rem;
margin-bottom: var(--spacing-xs);
}
.feedback-error {
color: var(--color-error);
margin: 0;
}
.feedback-success {
color: var(--color-success);
margin: 0;
}
.modal-footer {
justify-content: flex-start;
padding-top: var(--spacing-md);
border-top: 1px solid var(--color-border);
margin-top: var(--spacing-md);
flex-wrap: wrap;
}
@media (max-width: 480px) {
.modal-panel {
max-height: 95vh;
}
.modal-footer {
flex-direction: column-reverse;
}
.modal-footer .btn {
width: 100%;
}
}
</style>

View file

@ -175,7 +175,8 @@ async function uploadFile(file: File) {
async function loadReceipts() {
try {
const data = await receiptsAPI.listReceipts()
const raw = await receiptsAPI.listReceipts()
const data = Array.isArray(raw) ? raw : []
// Fetch OCR data for each receipt
receipts.value = await Promise.all(
data.map(async (receipt: any) => {

View file

@ -15,8 +15,19 @@
<div v-if="loadingDomains" class="text-secondary text-sm">Loading</div>
<div v-else-if="activeDomain" class="browser-body">
<!-- Corpus unavailable notice shown when all category counts are 0 -->
<div v-if="allCountsZero" class="browser-unavailable card p-md text-secondary text-sm">
Recipe library is not available on this instance yet. Browse categories will appear once the recipe corpus is loaded.
</div>
<!-- Category list + Surprise Me -->
<div class="category-list mb-md flex flex-wrap gap-xs">
<div v-else class="category-list mb-sm flex flex-wrap gap-xs">
<button
:class="['btn', 'btn-secondary', 'cat-btn', { active: activeCategory === '_all' }]"
@click="selectCategory('_all')"
>
All
</button>
<button
v-for="cat in categories"
:key="cat.category"
@ -25,6 +36,7 @@
>
{{ cat.category }}
<span class="cat-count">{{ cat.recipe_count }}</span>
<span v-if="cat.has_subcategories" class="cat-drill-indicator" title="Has subcategories"></span>
</button>
<button
v-if="categories.length > 1"
@ -36,11 +48,64 @@
</button>
</div>
<!-- Subcategory row shown when the active category has subcategories -->
<div
v-if="activeCategoryHasSubs && (subcategories.length > 0 || loadingSubcategories)"
class="subcategory-list mb-md flex flex-wrap gap-xs"
>
<span v-if="loadingSubcategories" class="text-secondary text-xs">Loading</span>
<template v-else>
<button
:class="['btn', 'btn-secondary', 'subcat-btn', { active: activeSubcategory === null }]"
@click="selectSubcategory(null)"
>
All {{ activeCategory }}
</button>
<button
v-for="sub in subcategories"
:key="sub.subcategory"
:class="['btn', 'btn-secondary', 'subcat-btn', { active: activeSubcategory === sub.subcategory }]"
@click="selectSubcategory(sub.subcategory)"
>
{{ sub.subcategory }}
<span class="cat-count">{{ sub.recipe_count }}</span>
</button>
</template>
</div>
<!-- Recipe grid -->
<template v-if="activeCategory">
<div v-if="loadingRecipes" class="text-secondary text-sm">Loading recipes</div>
<template v-else>
<!-- Search + sort controls -->
<div class="browser-controls flex gap-sm mb-sm flex-wrap align-center">
<input
v-model="searchQuery"
@input="onSearchInput"
type="search"
placeholder="Filter by title…"
class="browser-search"
/>
<div class="sort-btns flex gap-xs">
<button
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'default' }]"
@click="setSort('default')"
title="Corpus order"
>Default</button>
<button
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'alpha' }]"
@click="setSort('alpha')"
title="Alphabetical A→Z"
>AZ</button>
<button
:class="['btn', 'btn-secondary', 'sort-btn', { active: sortOrder === 'alpha_desc' }]"
@click="setSort('alpha_desc')"
title="Alphabetical Z→A"
>ZA</button>
</div>
</div>
<div class="results-header flex-between mb-sm">
<span class="text-sm text-secondary">
{{ total }} recipes
@ -101,7 +166,7 @@
</template>
</template>
<div v-else class="text-secondary text-sm">Loading recipes</div>
<div v-else-if="!allCountsZero" class="text-secondary text-sm">Loading recipes</div>
</div>
<div v-else-if="!loadingDomains" class="text-secondary text-sm">Loading</div>
@ -120,7 +185,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { browserAPI, type BrowserDomain, type BrowserCategory, type BrowserRecipe } from '../services/api'
import { browserAPI, type BrowserDomain, type BrowserCategory, type BrowserSubcategory, type BrowserRecipe } from '../services/api'
import { useSavedRecipesStore } from '../stores/savedRecipes'
import { useInventoryStore } from '../stores/inventory'
import SaveRecipeModal from './SaveRecipeModal.vue'
@ -136,6 +201,9 @@ const domains = ref<BrowserDomain[]>([])
const activeDomain = ref<string | null>(null)
const categories = ref<BrowserCategory[]>([])
const activeCategory = ref<string | null>(null)
const subcategories = ref<BrowserSubcategory[]>([])
const activeSubcategory = ref<string | null>(null)
const loadingSubcategories = ref(false)
const recipes = ref<BrowserRecipe[]>([])
const total = ref(0)
const page = ref(1)
@ -143,8 +211,18 @@ const pageSize = 20
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')
let searchDebounce: ReturnType<typeof setTimeout> | null = null
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
const allCountsZero = computed(() =>
categories.value.length > 0 && categories.value.every(c => c.recipe_count === 0)
)
const activeCategoryHasSubs = computed(() => {
if (!activeCategory.value || activeCategory.value === '_all') return false
return categories.value.find(c => c.category === activeCategory.value)?.has_subcategories ?? false
})
const pantryItems = computed(() =>
inventoryStore.items
@ -172,15 +250,34 @@ onMounted(async () => {
if (!savedStore.savedIds.size) savedStore.load()
})
function onSearchInput() {
if (searchDebounce) clearTimeout(searchDebounce)
searchDebounce = setTimeout(() => {
page.value = 1
loadRecipes()
}, 350)
}
function setSort(s: 'default' | 'alpha' | 'alpha_desc') {
if (sortOrder.value === s) return
sortOrder.value = s
page.value = 1
loadRecipes()
}
async function selectDomain(domainId: string) {
activeDomain.value = domainId
activeCategory.value = null
recipes.value = []
total.value = 0
page.value = 1
searchQuery.value = ''
sortOrder.value = 'default'
categories.value = await browserAPI.listCategories(domainId)
// Auto-select the most-populated category so content appears immediately
if (categories.value.length > 0) {
// Auto-select the most-populated category so content appears immediately.
// Skip when all counts are 0 (corpus not seeded) no point loading an empty result.
const hasRecipes = categories.value.some(c => c.recipe_count > 0)
if (hasRecipes) {
const top = categories.value.reduce((best, c) =>
c.recipe_count > best.recipe_count ? c : best, categories.value[0]!)
selectCategory(top.category)
@ -195,6 +292,27 @@ function surpriseMe() {
async function selectCategory(category: string) {
activeCategory.value = category
activeSubcategory.value = null
subcategories.value = []
page.value = 1
searchQuery.value = ''
sortOrder.value = 'default'
// Fetch subcategories in the background when the category supports them,
// then immediately start loading recipes at the full-category level.
const catMeta = categories.value.find(c => c.category === category)
if (catMeta?.has_subcategories) {
loadingSubcategories.value = true
browserAPI.listSubcategories(activeDomain.value!, category)
.then(subs => { subcategories.value = subs })
.finally(() => { loadingSubcategories.value = false })
}
await loadRecipes()
}
async function selectSubcategory(subcat: string | null) {
activeSubcategory.value = subcat
page.value = 1
await loadRecipes()
}
@ -217,6 +335,9 @@ async function loadRecipes() {
pantry_items: pantryItems.value.length > 0
? pantryItems.value.join(',')
: undefined,
subcategory: activeSubcategory.value ?? undefined,
q: searchQuery.value.trim() || undefined,
sort: sortOrder.value !== 'default' ? sortOrder.value : undefined,
}
)
recipes.value = result.recipes
@ -279,6 +400,68 @@ async function doUnsave(recipeId: number) {
opacity: 1;
}
.cat-drill-indicator {
margin-left: var(--spacing-xs);
opacity: 0.5;
font-size: var(--font-size-sm);
}
.subcategory-list {
padding-left: var(--spacing-sm);
border-left: 2px solid var(--color-border);
margin-left: var(--spacing-xs);
}
.subcat-btn {
font-size: var(--font-size-xs, 0.78rem);
padding: var(--spacing-xs) var(--spacing-sm);
opacity: 0.9;
}
.subcat-btn.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
opacity: 1;
}
.subcat-btn.active .cat-count {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.browser-controls {
align-items: center;
}
.browser-search {
flex: 1;
min-width: 120px;
max-width: 260px;
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--font-size-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
}
.browser-search:focus {
outline: none;
border-color: var(--color-primary);
}
.sort-btn {
font-size: var(--font-size-xs, 0.75rem);
padding: 2px var(--spacing-sm);
}
.sort-btn.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.recipe-grid {
display: flex;
flex-direction: column;

Some files were not shown because too many files have changed in this diff Show more