From 3235fb365fead5c9b7c53f58979151fcdf64261b Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 13:11:34 -0700 Subject: [PATCH 01/24] feat(db): add meal_plans, slots, prep_sessions, prep_tasks migrations (022-025) --- app/db/migrations/022_meal_plans.sql | 8 ++++++++ app/db/migrations/023_meal_plan_slots.sql | 11 +++++++++++ app/db/migrations/024_prep_sessions.sql | 10 ++++++++++ app/db/migrations/025_prep_tasks.sql | 15 +++++++++++++++ 4 files changed, 44 insertions(+) create mode 100644 app/db/migrations/022_meal_plans.sql create mode 100644 app/db/migrations/023_meal_plan_slots.sql create mode 100644 app/db/migrations/024_prep_sessions.sql create mode 100644 app/db/migrations/025_prep_tasks.sql diff --git a/app/db/migrations/022_meal_plans.sql b/app/db/migrations/022_meal_plans.sql new file mode 100644 index 0000000..79c019c --- /dev/null +++ b/app/db/migrations/022_meal_plans.sql @@ -0,0 +1,8 @@ +-- 022_meal_plans.sql +CREATE TABLE meal_plans ( + id INTEGER PRIMARY KEY, + week_start TEXT NOT NULL, + meal_types TEXT NOT NULL DEFAULT '["dinner"]', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/app/db/migrations/023_meal_plan_slots.sql b/app/db/migrations/023_meal_plan_slots.sql new file mode 100644 index 0000000..f2926fa --- /dev/null +++ b/app/db/migrations/023_meal_plan_slots.sql @@ -0,0 +1,11 @@ +-- 023_meal_plan_slots.sql +CREATE TABLE meal_plan_slots ( + id INTEGER PRIMARY KEY, + plan_id INTEGER NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE, + day_of_week INTEGER NOT NULL CHECK(day_of_week BETWEEN 0 AND 6), + meal_type TEXT NOT NULL, + recipe_id INTEGER REFERENCES recipes(id), + servings REAL NOT NULL DEFAULT 2.0, + custom_label TEXT, + UNIQUE(plan_id, day_of_week, meal_type) +); diff --git a/app/db/migrations/024_prep_sessions.sql b/app/db/migrations/024_prep_sessions.sql new file mode 100644 index 0000000..bb313a2 --- /dev/null +++ b/app/db/migrations/024_prep_sessions.sql @@ -0,0 +1,10 @@ +-- 024_prep_sessions.sql +CREATE TABLE prep_sessions ( + id INTEGER PRIMARY KEY, + plan_id INTEGER NOT NULL REFERENCES meal_plans(id) ON DELETE CASCADE, + scheduled_date TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft' + CHECK(status IN ('draft','reviewed','done')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/app/db/migrations/025_prep_tasks.sql b/app/db/migrations/025_prep_tasks.sql new file mode 100644 index 0000000..d9541e3 --- /dev/null +++ b/app/db/migrations/025_prep_tasks.sql @@ -0,0 +1,15 @@ +-- 025_prep_tasks.sql +CREATE TABLE prep_tasks ( + id INTEGER PRIMARY KEY, + session_id INTEGER NOT NULL REFERENCES prep_sessions(id) ON DELETE CASCADE, + recipe_id INTEGER REFERENCES recipes(id), + slot_id INTEGER REFERENCES meal_plan_slots(id), + task_label TEXT NOT NULL, + duration_minutes INTEGER, + sequence_order INTEGER NOT NULL, + equipment TEXT, + is_parallel INTEGER NOT NULL DEFAULT 0, + notes TEXT, + user_edited INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); From 594fd3f3bf25d956f2c26cb34177cf353db4cc56 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 13:12:11 -0700 Subject: [PATCH 02/24] feat(tiers): move meal_planning to Free; add meal_plan_config/llm/llm_timing keys refs kiwi#68 --- app/tiers.py | 7 ++++++- tests/test_meal_plan_tiers.py | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 tests/test_meal_plan_tiers.py diff --git a/app/tiers.py b/app/tiers.py index 652544f..c3257ce 100644 --- a/app/tiers.py +++ b/app/tiers.py @@ -16,6 +16,8 @@ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({ "expiry_llm_matching", "receipt_ocr", "style_classifier", + "meal_plan_llm", + "meal_plan_llm_timing", }) # Feature → minimum tier required @@ -33,7 +35,10 @@ KIWI_FEATURES: dict[str, str] = { "receipt_ocr": "paid", # BYOK-unlockable "recipe_suggestions": "paid", # BYOK-unlockable "expiry_llm_matching": "paid", # BYOK-unlockable - "meal_planning": "paid", + "meal_planning": "free", + "meal_plan_config": "paid", # configurable meal types (breakfast/lunch/snack) + "meal_plan_llm": "paid", # LLM-assisted full-week plan generation; BYOK-unlockable + "meal_plan_llm_timing": "paid", # LLM time fill-in for recipes missing corpus times; BYOK-unlockable "dietary_profiles": "paid", "style_picker": "paid", "recipe_collections": "paid", diff --git a/tests/test_meal_plan_tiers.py b/tests/test_meal_plan_tiers.py new file mode 100644 index 0000000..16da202 --- /dev/null +++ b/tests/test_meal_plan_tiers.py @@ -0,0 +1,27 @@ +# tests/test_meal_plan_tiers.py +from app.tiers import can_use + + +def test_meal_planning_is_free(): + """Basic meal planning (dinner-only, manual) is available to free tier.""" + assert can_use("meal_planning", "free") is True + + +def test_meal_plan_config_requires_paid(): + """Configurable meal types (breakfast/lunch/snack) require Paid.""" + assert can_use("meal_plan_config", "free") is False + assert can_use("meal_plan_config", "paid") is True + + +def test_meal_plan_llm_byok_unlockable(): + """LLM plan generation is Paid but BYOK-unlockable on Free.""" + assert can_use("meal_plan_llm", "free", has_byok=False) is False + assert can_use("meal_plan_llm", "free", has_byok=True) is True + assert can_use("meal_plan_llm", "paid") is True + + +def test_meal_plan_llm_timing_byok_unlockable(): + """LLM time estimation is Paid but BYOK-unlockable on Free.""" + assert can_use("meal_plan_llm_timing", "free", has_byok=False) is False + assert can_use("meal_plan_llm_timing", "free", has_byok=True) is True + assert can_use("meal_plan_llm_timing", "paid") is True From 067b0821af4ecc5ee1217b20f5806de240f6dd50 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 13:12:41 -0700 Subject: [PATCH 03/24] feat(schemas): add meal plan Pydantic models --- app/models/schemas/meal_plan.py | 87 +++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 app/models/schemas/meal_plan.py diff --git a/app/models/schemas/meal_plan.py b/app/models/schemas/meal_plan.py new file mode 100644 index 0000000..9adff0c --- /dev/null +++ b/app/models/schemas/meal_plan.py @@ -0,0 +1,87 @@ +# app/models/schemas/meal_plan.py +"""Pydantic schemas for meal planning endpoints.""" +from __future__ import annotations + +from pydantic import BaseModel, Field + + +VALID_MEAL_TYPES = {"breakfast", "lunch", "dinner", "snack"} + + +class CreatePlanRequest(BaseModel): + week_start: str # ISO date string, e.g. "2026-04-14" (must be Monday) + meal_types: list[str] = Field(default_factory=lambda: ["dinner"]) + + +class UpsertSlotRequest(BaseModel): + recipe_id: int | None = None + servings: float = Field(2.0, gt=0) + custom_label: str | None = None + + +class SlotSummary(BaseModel): + id: int + plan_id: int + day_of_week: int + meal_type: str + recipe_id: int | None + recipe_title: str | None + servings: float + custom_label: str | None + + +class PlanSummary(BaseModel): + id: int + week_start: str + meal_types: list[str] + slots: list[SlotSummary] + created_at: str + + +class RetailerLink(BaseModel): + retailer: str + label: str + url: str + + +class GapItem(BaseModel): + ingredient_name: str + needed_raw: str | None # e.g. "2 cups" from recipe text + have_quantity: float | None # from pantry + have_unit: str | None + covered: bool # True = pantry has it + retailer_links: list[RetailerLink] = Field(default_factory=list) + + +class ShoppingListResponse(BaseModel): + plan_id: int + gap_items: list[GapItem] + covered_items: list[GapItem] + disclosure: str | None = None # affiliate disclosure text when links present + + +class PrepTaskSummary(BaseModel): + id: int + recipe_id: int | None + task_label: str + duration_minutes: int | None + sequence_order: int + equipment: str | None + is_parallel: bool + notes: str | None + user_edited: bool + + +class PrepSessionSummary(BaseModel): + id: int + plan_id: int + scheduled_date: str + status: str + tasks: list[PrepTaskSummary] + + +class UpdatePrepTaskRequest(BaseModel): + duration_minutes: int | None = None + sequence_order: int | None = None + notes: str | None = None + equipment: str | None = None From ffb34c9c6279406dce0c7bfc65fa5829a28ce3ea Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 13:13:18 -0700 Subject: [PATCH 04/24] feat(store): add meal plan, slot, prep session, and prep task CRUD methods --- app/db/store.py | 116 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/app/db/store.py b/app/db/store.py index d931b5e..0701b3a 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -44,7 +44,9 @@ class Store: "ingredients", "ingredient_names", "directions", "keywords", "element_coverage", # saved recipe columns - "style_tags"): + "style_tags", + # meal plan columns + "meal_types"): if key in d and isinstance(d[key], str): try: d[key] = json.loads(d[key]) @@ -1011,3 +1013,115 @@ class Store: (domain, category, page, result_count), ) self.conn.commit() + + # ── meal plans ──────────────────────────────────────────────────────── + + def create_meal_plan(self, week_start: str, meal_types: list[str]) -> dict: + return self._insert_returning( + "INSERT INTO meal_plans (week_start, meal_types) VALUES (?, ?) RETURNING *", + (week_start, json.dumps(meal_types)), + ) + + def get_meal_plan(self, plan_id: int) -> dict | None: + return self._fetch_one("SELECT * FROM meal_plans WHERE id = ?", (plan_id,)) + + def list_meal_plans(self) -> list[dict]: + return self._fetch_all("SELECT * FROM meal_plans ORDER BY week_start DESC") + + def upsert_slot( + self, + plan_id: int, + day_of_week: int, + meal_type: str, + recipe_id: int | None, + servings: float, + custom_label: str | None, + ) -> dict: + return self._insert_returning( + """INSERT INTO meal_plan_slots + (plan_id, day_of_week, meal_type, recipe_id, servings, custom_label) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(plan_id, day_of_week, meal_type) DO UPDATE SET + recipe_id = excluded.recipe_id, + servings = excluded.servings, + custom_label = excluded.custom_label + RETURNING *""", + (plan_id, day_of_week, meal_type, recipe_id, servings, custom_label), + ) + + def delete_slot(self, slot_id: int) -> None: + self.conn.execute("DELETE FROM meal_plan_slots WHERE id = ?", (slot_id,)) + self.conn.commit() + + def get_plan_slots(self, plan_id: int) -> list[dict]: + return self._fetch_all( + """SELECT s.*, r.name AS recipe_title + FROM meal_plan_slots s + LEFT JOIN recipes r ON r.id = s.recipe_id + WHERE s.plan_id = ? + ORDER BY s.day_of_week, s.meal_type""", + (plan_id,), + ) + + def get_plan_recipes(self, plan_id: int) -> list[dict]: + """Return full recipe rows for all recipes assigned to a plan.""" + return self._fetch_all( + """SELECT DISTINCT r.* + FROM meal_plan_slots s + JOIN recipes r ON r.id = s.recipe_id + WHERE s.plan_id = ? AND s.recipe_id IS NOT NULL""", + (plan_id,), + ) + + # ── prep sessions ───────────────────────────────────────────────────── + + def create_prep_session(self, plan_id: int, scheduled_date: str) -> dict: + return self._insert_returning( + "INSERT INTO prep_sessions (plan_id, scheduled_date) VALUES (?, ?) RETURNING *", + (plan_id, scheduled_date), + ) + + def get_prep_session_for_plan(self, plan_id: int) -> dict | None: + return self._fetch_one( + "SELECT * FROM prep_sessions WHERE plan_id = ? ORDER BY id DESC LIMIT 1", + (plan_id,), + ) + + def bulk_insert_prep_tasks(self, session_id: int, tasks: list[dict]) -> list[dict]: + """Insert multiple prep tasks and return them all.""" + inserted = [] + for t in tasks: + row = self._insert_returning( + """INSERT INTO prep_tasks + (session_id, recipe_id, slot_id, task_label, duration_minutes, + sequence_order, equipment, is_parallel, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *""", + ( + session_id, t.get("recipe_id"), t.get("slot_id"), + t["task_label"], t.get("duration_minutes"), + t["sequence_order"], t.get("equipment"), + int(t.get("is_parallel", False)), t.get("notes"), + ), + ) + inserted.append(row) + return inserted + + def get_prep_tasks(self, session_id: int) -> list[dict]: + return self._fetch_all( + "SELECT * FROM prep_tasks WHERE session_id = ? ORDER BY sequence_order", + (session_id,), + ) + + def update_prep_task(self, task_id: int, **kwargs: object) -> dict | None: + allowed = {"duration_minutes", "sequence_order", "notes", "equipment"} + updates = {k: v for k, v in kwargs.items() if k in allowed and v is not None} + if not updates: + return self._fetch_one("SELECT * FROM prep_tasks WHERE id = ?", (task_id,)) + set_clause = ", ".join(f"{k} = ?" for k in updates) + values = list(updates.values()) + [1, task_id] + self.conn.execute( + f"UPDATE prep_tasks SET {set_clause}, user_edited = ? WHERE id = ?", + values, + ) + self.conn.commit() + return self._fetch_one("SELECT * FROM prep_tasks WHERE id = ?", (task_id,)) From 4459b1ab7eddddd2daa0d2b96eb6c81466fb878a Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 13:14:08 -0700 Subject: [PATCH 05/24] feat(services): add shopping_list service with pantry diff refs kiwi#68 --- app/services/meal_plan/__init__.py | 1 + app/services/meal_plan/shopping_list.py | 88 +++++++++++++++++++ .../services/test_meal_plan_shopping_list.py | 51 +++++++++++ 3 files changed, 140 insertions(+) create mode 100644 app/services/meal_plan/__init__.py create mode 100644 app/services/meal_plan/shopping_list.py create mode 100644 tests/services/test_meal_plan_shopping_list.py diff --git a/app/services/meal_plan/__init__.py b/app/services/meal_plan/__init__.py new file mode 100644 index 0000000..245ab0b --- /dev/null +++ b/app/services/meal_plan/__init__.py @@ -0,0 +1 @@ +"""Meal planning service layer — no FastAPI imports (extraction-ready for cf-core).""" diff --git a/app/services/meal_plan/shopping_list.py b/app/services/meal_plan/shopping_list.py new file mode 100644 index 0000000..ea441ad --- /dev/null +++ b/app/services/meal_plan/shopping_list.py @@ -0,0 +1,88 @@ +# app/services/meal_plan/shopping_list.py +"""Compute a shopping list from a meal plan and current pantry inventory. + +Pure function — no DB or network calls. Takes plain dicts from the Store +and returns GapItem dataclasses. +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class GapItem: + ingredient_name: str + needed_raw: str | None # first quantity token from recipe text, e.g. "300g" + have_quantity: float | None # pantry quantity when partial match + have_unit: str | None + covered: bool + retailer_links: list = field(default_factory=list) # filled by API layer + + +_QUANTITY_RE = re.compile(r"^(\d+[\d./]*\s*(?:g|kg|ml|l|oz|lb|cup|cups|tsp|tbsp|tbsps|tsps)?)\b", re.I) + + +def _extract_quantity(ingredient_text: str) -> str | None: + """Pull the leading quantity string from a raw ingredient line.""" + m = _QUANTITY_RE.match(ingredient_text.strip()) + return m.group(1).strip() if m else None + + +def _normalise(name: str) -> str: + """Lowercase, strip possessives and plural -s for fuzzy matching.""" + return name.lower().strip().rstrip("s") + + +def compute_shopping_list( + recipes: list[dict], + inventory: list[dict], +) -> tuple[list[GapItem], list[GapItem]]: + """Return (gap_items, covered_items) for a list of recipe dicts + inventory dicts. + + Deduplicates by normalised ingredient name — the first recipe's quantity + string wins when the same ingredient appears in multiple recipes. + """ + if not recipes: + return [], [] + + # Build pantry lookup: normalised_name → inventory row + pantry: dict[str, dict] = {} + for item in inventory: + pantry[_normalise(item["name"])] = item + + # Collect unique ingredients with their first quantity token + seen: dict[str, str | None] = {} # normalised_name → needed_raw + for recipe in recipes: + names: list[str] = recipe.get("ingredient_names") or [] + raw_lines: list[str] = recipe.get("ingredients") or [] + for i, name in enumerate(names): + key = _normalise(name) + if key in seen: + continue + raw = raw_lines[i] if i < len(raw_lines) else "" + seen[key] = _extract_quantity(raw) + + gaps: list[GapItem] = [] + covered: list[GapItem] = [] + + for norm_name, needed_raw in seen.items(): + pantry_row = pantry.get(norm_name) + if pantry_row: + covered.append(GapItem( + ingredient_name=norm_name, + needed_raw=needed_raw, + have_quantity=pantry_row.get("quantity"), + have_unit=pantry_row.get("unit"), + covered=True, + )) + else: + gaps.append(GapItem( + ingredient_name=norm_name, + needed_raw=needed_raw, + have_quantity=None, + have_unit=None, + covered=False, + )) + + return gaps, covered diff --git a/tests/services/test_meal_plan_shopping_list.py b/tests/services/test_meal_plan_shopping_list.py new file mode 100644 index 0000000..77d6b50 --- /dev/null +++ b/tests/services/test_meal_plan_shopping_list.py @@ -0,0 +1,51 @@ +# tests/services/test_meal_plan_shopping_list.py +"""Unit tests for shopping_list.py — no network, no DB.""" +from __future__ import annotations + +import pytest +from app.services.meal_plan.shopping_list import GapItem, compute_shopping_list + + +def _recipe(ingredient_names: list[str], ingredients: list[str]) -> dict: + return {"ingredient_names": ingredient_names, "ingredients": ingredients} + + +def _inv_item(name: str, quantity: float, unit: str) -> dict: + return {"name": name, "quantity": quantity, "unit": unit} + + +def test_item_in_pantry_is_covered(): + recipes = [_recipe(["pasta"], ["500g pasta"])] + inventory = [_inv_item("pasta", 400, "g")] + gaps, covered = compute_shopping_list(recipes, inventory) + assert len(covered) == 1 + assert covered[0].ingredient_name == "pasta" + assert covered[0].covered is True + assert len(gaps) == 0 + + +def test_item_not_in_pantry_is_gap(): + recipes = [_recipe(["chicken breast"], ["300g chicken breast"])] + inventory = [] + gaps, covered = compute_shopping_list(recipes, inventory) + assert len(gaps) == 1 + assert gaps[0].ingredient_name == "chicken breast" + assert gaps[0].covered is False + assert gaps[0].needed_raw == "300g" + + +def test_duplicate_ingredient_across_recipes_deduplicates(): + recipes = [ + _recipe(["onion"], ["2 onions"]), + _recipe(["onion"], ["1 onion"]), + ] + inventory = [] + gaps, _ = compute_shopping_list(recipes, inventory) + names = [g.ingredient_name for g in gaps] + assert names.count("onion") == 1 + + +def test_empty_plan_returns_empty_lists(): + gaps, covered = compute_shopping_list([], []) + assert gaps == [] + assert covered == [] From 25027762cf9566762fe897635b41bcb4cfab0c54 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 13:14:54 -0700 Subject: [PATCH 06/24] =?UTF-8?q?feat(services):=20add=20prep=5Fscheduler?= =?UTF-8?q?=20=E2=80=94=20sequences=20batch=20cooking=20tasks=20by=20equip?= =?UTF-8?q?ment=20priority?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/meal_plan/prep_scheduler.py | 86 +++++++++++++++++++ .../services/test_meal_plan_prep_scheduler.py | 55 ++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 app/services/meal_plan/prep_scheduler.py create mode 100644 tests/services/test_meal_plan_prep_scheduler.py diff --git a/app/services/meal_plan/prep_scheduler.py b/app/services/meal_plan/prep_scheduler.py new file mode 100644 index 0000000..6d3088d --- /dev/null +++ b/app/services/meal_plan/prep_scheduler.py @@ -0,0 +1,86 @@ +# app/services/meal_plan/prep_scheduler.py +"""Sequence prep tasks for a batch cooking session. + +Pure function — no DB or network calls. Sorts tasks by equipment priority +(oven first to maximise oven utilisation) then assigns sequence_order. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +_EQUIPMENT_PRIORITY = {"oven": 0, "stovetop": 1, "cold": 2, "no-heat": 3} +_DEFAULT_PRIORITY = 4 + + +@dataclass +class PrepTask: + recipe_id: int | None + slot_id: int | None + task_label: str + duration_minutes: int | None + sequence_order: int + equipment: str | None + is_parallel: bool = False + notes: str | None = None + user_edited: bool = False + + +def _total_minutes(recipe: dict) -> int | None: + prep = recipe.get("prep_time") + cook = recipe.get("cook_time") + if prep is None and cook is None: + return None + return (prep or 0) + (cook or 0) + + +def _equipment(recipe: dict) -> str | None: + # Corpus recipes don't have an explicit equipment field; use test helper + # field if present, otherwise infer from cook_time (long = oven heuristic). + if "_equipment" in recipe: + return recipe["_equipment"] + minutes = _total_minutes(recipe) + if minutes and minutes >= 45: + return "oven" + return "stovetop" + + +def build_prep_tasks(slots: list[dict], recipes: list[dict]) -> list[PrepTask]: + """Return a sequenced list of PrepTask objects from plan slots + recipe rows. + + Algorithm: + 1. Build a recipe_id → recipe dict lookup. + 2. Create one task per slot that has a recipe assigned. + 3. Sort by equipment priority (oven first). + 4. Assign contiguous sequence_order starting at 1. + """ + if not slots or not recipes: + return [] + + recipe_map: dict[int, dict] = {r["id"]: r for r in recipes} + raw_tasks: list[tuple[int, PrepTask]] = [] # (priority, task) + + for slot in slots: + recipe_id = slot.get("recipe_id") + if not recipe_id: + continue + recipe = recipe_map.get(recipe_id) + if not recipe: + continue + + eq = _equipment(recipe) + priority = _EQUIPMENT_PRIORITY.get(eq or "", _DEFAULT_PRIORITY) + task = PrepTask( + recipe_id=recipe_id, + slot_id=slot.get("id"), + task_label=recipe.get("name", f"Recipe {recipe_id}"), + duration_minutes=_total_minutes(recipe), + sequence_order=0, # filled below + equipment=eq, + ) + raw_tasks.append((priority, task)) + + raw_tasks.sort(key=lambda t: t[0]) + for i, (_, task) in enumerate(raw_tasks, 1): + task.sequence_order = i + + return [t for _, t in raw_tasks] diff --git a/tests/services/test_meal_plan_prep_scheduler.py b/tests/services/test_meal_plan_prep_scheduler.py new file mode 100644 index 0000000..39db27c --- /dev/null +++ b/tests/services/test_meal_plan_prep_scheduler.py @@ -0,0 +1,55 @@ +# tests/services/test_meal_plan_prep_scheduler.py +"""Unit tests for prep_scheduler.py — no DB or network.""" +from __future__ import annotations + +import pytest +from app.services.meal_plan.prep_scheduler import PrepTask, build_prep_tasks + + +def _recipe(id_: int, name: str, prep_time: int | None, cook_time: int | None, equipment: str) -> dict: + return { + "id": id_, "name": name, + "prep_time": prep_time, "cook_time": cook_time, + "_equipment": equipment, # test helper field + } + + +def _slot(slot_id: int, recipe: dict, day: int = 0) -> dict: + return {"id": slot_id, "recipe_id": recipe["id"], "day_of_week": day, + "meal_type": "dinner", "servings": 2.0} + + +def test_builds_task_per_slot(): + recipe = _recipe(1, "Pasta", 10, 20, "stovetop") + tasks = build_prep_tasks( + slots=[_slot(1, recipe)], + recipes=[recipe], + ) + assert len(tasks) == 1 + assert tasks[0].task_label == "Pasta" + assert tasks[0].duration_minutes == 30 # prep + cook + + +def test_oven_tasks_scheduled_first(): + oven_recipe = _recipe(1, "Roast Chicken", 10, 60, "oven") + stove_recipe = _recipe(2, "Rice", 2, 20, "stovetop") + tasks = build_prep_tasks( + slots=[_slot(1, stove_recipe), _slot(2, oven_recipe)], + recipes=[stove_recipe, oven_recipe], + ) + orders = {t.task_label: t.sequence_order for t in tasks} + assert orders["Roast Chicken"] < orders["Rice"] + + +def test_missing_corpus_time_leaves_duration_none(): + recipe = _recipe(1, "Mystery Dish", None, None, "stovetop") + tasks = build_prep_tasks(slots=[_slot(1, recipe)], recipes=[recipe]) + assert tasks[0].duration_minutes is None + + +def test_sequence_order_is_contiguous_from_one(): + recipes = [_recipe(i, f"Recipe {i}", 10, 10, "stovetop") for i in range(1, 4)] + slots = [_slot(i, r) for i, r in enumerate(recipes, 1)] + tasks = build_prep_tasks(slots=slots, recipes=recipes) + orders = sorted(t.sequence_order for t in tasks) + assert orders == [1, 2, 3] From b9dd1427de9e277e9f8997417b10bed3a8a90609 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 13:15:28 -0700 Subject: [PATCH 07/24] feat(affiliates): register Kiwi grocery retailer programs at startup refs kiwi#74 --- app/main.py | 3 + app/services/meal_plan/affiliates.py | 108 +++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 app/services/meal_plan/affiliates.py diff --git a/app/main.py b/app/main.py index 42e536a..8121f00 100644 --- a/app/main.py +++ b/app/main.py @@ -9,6 +9,9 @@ from fastapi.middleware.cors import CORSMiddleware from app.api.routes import api_router from app.core.config import settings +from app.services.meal_plan.affiliates import register_kiwi_programs + +register_kiwi_programs() logger = logging.getLogger(__name__) diff --git a/app/services/meal_plan/affiliates.py b/app/services/meal_plan/affiliates.py new file mode 100644 index 0000000..b8085f7 --- /dev/null +++ b/app/services/meal_plan/affiliates.py @@ -0,0 +1,108 @@ +# app/services/meal_plan/affiliates.py +"""Register Kiwi-specific affiliate programs and provide search URL builders. + +Called once at API startup. Programs not yet in core.affiliates are registered +here. The actual affiliate IDs are read from environment variables at call +time, so the process can start before accounts are approved (plain URLs +returned when env vars are absent). +""" +from __future__ import annotations + +from urllib.parse import quote_plus + +from circuitforge_core.affiliates import AffiliateProgram, register_program, wrap_url + + +# ── URL builders ────────────────────────────────────────────────────────────── + +def _walmart_search(url: str, affiliate_id: str) -> str: + sep = "&" if "?" in url else "?" + return f"{url}{sep}affil=apa&affiliateId={affiliate_id}" + + +def _target_search(url: str, affiliate_id: str) -> str: + sep = "&" if "?" in url else "?" + return f"{url}{sep}afid={affiliate_id}" + + +def _thrive_search(url: str, affiliate_id: str) -> str: + sep = "&" if "?" in url else "?" + return f"{url}{sep}raf={affiliate_id}" + + +def _misfits_search(url: str, affiliate_id: str) -> str: + sep = "&" if "?" in url else "?" + return f"{url}{sep}ref={affiliate_id}" + + +# ── Registration ────────────────────────────────────────────────────────────── + +def register_kiwi_programs() -> None: + """Register Kiwi retailer programs. Safe to call multiple times (idempotent).""" + register_program(AffiliateProgram( + name="Walmart", + retailer_key="walmart", + env_var="WALMART_AFFILIATE_ID", + build_url=_walmart_search, + )) + register_program(AffiliateProgram( + name="Target", + retailer_key="target", + env_var="TARGET_AFFILIATE_ID", + build_url=_target_search, + )) + register_program(AffiliateProgram( + name="Thrive Market", + retailer_key="thrive", + env_var="THRIVE_AFFILIATE_ID", + build_url=_thrive_search, + )) + register_program(AffiliateProgram( + name="Misfits Market", + retailer_key="misfits", + env_var="MISFITS_AFFILIATE_ID", + build_url=_misfits_search, + )) + + +# ── Search URL helpers ───────────────────────────────────────────────────────── + +_SEARCH_TEMPLATES: dict[str, str] = { + "amazon": "https://www.amazon.com/s?k={q}", + "instacart": "https://www.instacart.com/store/search_v3/term?term={q}", + "walmart": "https://www.walmart.com/search?q={q}", + "target": "https://www.target.com/s?searchTerm={q}", + "thrive": "https://thrivemarket.com/search?q={q}", + "misfits": "https://www.misfitsmarket.com/shop?search={q}", +} + +KIWI_RETAILERS = list(_SEARCH_TEMPLATES.keys()) + + +def get_retailer_links(ingredient_name: str) -> list[dict]: + """Return affiliate-wrapped search links for *ingredient_name*. + + Returns a list of dicts: {"retailer": str, "label": str, "url": str}. + Falls back to plain search URL when no affiliate ID is configured. + """ + q = quote_plus(ingredient_name) + links = [] + for key, template in _SEARCH_TEMPLATES.items(): + plain_url = template.format(q=q) + try: + affiliate_url = wrap_url(plain_url, retailer=key) + except Exception: + affiliate_url = plain_url + links.append({"retailer": key, "label": _label(key), "url": affiliate_url}) + return links + + +def _label(key: str) -> str: + return { + "amazon": "Amazon", + "instacart": "Instacart", + "walmart": "Walmart", + "target": "Target", + "thrive": "Thrive Market", + "misfits": "Misfits Market", + }.get(key, key.title()) From 98087120ac67dabf5b58f04cf9e62eb3c265836b Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 13:44:01 -0700 Subject: [PATCH 08/24] =?UTF-8?q?feat(api):=20add=20/api/v1/meal-plans/=20?= =?UTF-8?q?endpoints=20=E2=80=94=20CRUD,=20shopping=20list,=20prep=20sessi?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refs kiwi#68 kiwi#71 --- app/api/endpoints/meal_plans.py | 263 ++++++++++++++++++++++++++++++++ tests/api/test_meal_plans.py | 116 ++++++++++++++ 2 files changed, 379 insertions(+) create mode 100644 app/api/endpoints/meal_plans.py create mode 100644 tests/api/test_meal_plans.py diff --git a/app/api/endpoints/meal_plans.py b/app/api/endpoints/meal_plans.py new file mode 100644 index 0000000..f1bf3f8 --- /dev/null +++ b/app/api/endpoints/meal_plans.py @@ -0,0 +1,263 @@ +# app/api/endpoints/meal_plans.py +"""Meal plan CRUD, shopping list, and prep session endpoints.""" +from __future__ import annotations + +import asyncio +from datetime import date + +from fastapi import APIRouter, Depends, HTTPException + +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.meal_plan import ( + CreatePlanRequest, + GapItem, + PlanSummary, + PrepSessionSummary, + PrepTaskSummary, + ShoppingListResponse, + SlotSummary, + UpdatePrepTaskRequest, + UpsertSlotRequest, + VALID_MEAL_TYPES, +) +from app.services.meal_plan.affiliates import get_retailer_links +from app.services.meal_plan.prep_scheduler import build_prep_tasks +from app.services.meal_plan.shopping_list import compute_shopping_list +from app.tiers import can_use + +router = APIRouter() + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _slot_summary(row: dict) -> SlotSummary: + return SlotSummary( + id=row["id"], + plan_id=row["plan_id"], + day_of_week=row["day_of_week"], + meal_type=row["meal_type"], + recipe_id=row.get("recipe_id"), + recipe_title=row.get("recipe_title"), + servings=row["servings"], + custom_label=row.get("custom_label"), + ) + + +def _plan_summary(plan: dict, slots: list[dict]) -> PlanSummary: + meal_types = plan.get("meal_types") or ["dinner"] + if isinstance(meal_types, str): + import json + meal_types = json.loads(meal_types) + return PlanSummary( + id=plan["id"], + week_start=plan["week_start"], + meal_types=meal_types, + slots=[_slot_summary(s) for s in slots], + created_at=plan["created_at"], + ) + + +def _prep_task_summary(row: dict) -> PrepTaskSummary: + return PrepTaskSummary( + id=row["id"], + recipe_id=row.get("recipe_id"), + task_label=row["task_label"], + duration_minutes=row.get("duration_minutes"), + sequence_order=row["sequence_order"], + equipment=row.get("equipment"), + is_parallel=bool(row.get("is_parallel", False)), + notes=row.get("notes"), + user_edited=bool(row.get("user_edited", False)), + ) + + +# ── plan CRUD ───────────────────────────────────────────────────────────────── + +@router.post("/", response_model=PlanSummary) +async def create_plan( + req: CreatePlanRequest, + session: CloudUser = Depends(get_session), + store: Store = Depends(get_store), +) -> PlanSummary: + # 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, req.week_start, meal_types) + slots = await asyncio.to_thread(store.get_plan_slots, plan["id"]) + return _plan_summary(plan, slots) + + +@router.get("/", response_model=list[dict]) +async def list_plans( + session: CloudUser = Depends(get_session), + store: Store = Depends(get_store), +) -> list[dict]: + return await asyncio.to_thread(store.list_meal_plans) + + +@router.get("/{plan_id}", response_model=PlanSummary) +async def get_plan( + plan_id: int, + 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.") + slots = await asyncio.to_thread(store.get_plan_slots, plan_id) + return _plan_summary(plan, slots) + + +# ── slots ───────────────────────────────────────────────────────────────────── + +@router.put("/{plan_id}/slots/{day_of_week}/{meal_type}", response_model=SlotSummary) +async def upsert_slot( + plan_id: int, + day_of_week: int, + meal_type: str, + req: UpsertSlotRequest, + session: CloudUser = Depends(get_session), + store: Store = Depends(get_store), +) -> SlotSummary: + plan = await asyncio.to_thread(store.get_meal_plan, plan_id) + if plan is None: + raise HTTPException(status_code=404, detail="Plan not found.") + row = await asyncio.to_thread( + store.upsert_slot, + plan_id, day_of_week, meal_type, + req.recipe_id, req.servings, req.custom_label, + ) + return _slot_summary(row) + + +@router.delete("/{plan_id}/slots/{slot_id}", status_code=204) +async def delete_slot( + plan_id: int, + slot_id: int, + session: CloudUser = Depends(get_session), + store: Store = Depends(get_store), +) -> None: + plan = await asyncio.to_thread(store.get_meal_plan, plan_id) + if plan is None: + raise HTTPException(status_code=404, detail="Plan not found.") + await asyncio.to_thread(store.delete_slot, slot_id) + + +# ── shopping list ───────────────────────────────────────────────────────────── + +@router.get("/{plan_id}/shopping-list", response_model=ShoppingListResponse) +async def get_shopping_list( + plan_id: int, + session: CloudUser = Depends(get_session), + store: Store = Depends(get_store), +) -> ShoppingListResponse: + plan = await asyncio.to_thread(store.get_meal_plan, plan_id) + if plan is None: + raise HTTPException(status_code=404, detail="Plan not found.") + + recipes = await asyncio.to_thread(store.get_plan_recipes, plan_id) + inventory = await asyncio.to_thread(store.list_inventory) + + gaps, covered = compute_shopping_list(recipes, inventory) + + # Enrich gap items with retailer links + def _to_schema(item, enrich: bool) -> GapItem: + links = get_retailer_links(item.ingredient_name) if enrich else [] + return GapItem( + ingredient_name=item.ingredient_name, + needed_raw=item.needed_raw, + have_quantity=item.have_quantity, + have_unit=item.have_unit, + covered=item.covered, + retailer_links=links, + ) + + gap_items = [_to_schema(g, enrich=True) for g in gaps] + covered_items = [_to_schema(c, enrich=False) for c in covered] + + disclosure = ( + "Some links may be affiliate links. Purchases through them support Kiwi development." + if gap_items else None + ) + + return ShoppingListResponse( + plan_id=plan_id, + gap_items=gap_items, + covered_items=covered_items, + disclosure=disclosure, + ) + + +# ── prep session ────────────────────────────────────────────────────────────── + +@router.post("/{plan_id}/prep-session", response_model=PrepSessionSummary) +async def create_prep_session( + plan_id: int, + session: CloudUser = Depends(get_session), + store: Store = Depends(get_store), +) -> PrepSessionSummary: + plan = await asyncio.to_thread(store.get_meal_plan, plan_id) + if plan is None: + raise HTTPException(status_code=404, detail="Plan not found.") + + slots = await asyncio.to_thread(store.get_plan_slots, plan_id) + recipes = await asyncio.to_thread(store.get_plan_recipes, plan_id) + prep_tasks = build_prep_tasks(slots=slots, recipes=recipes) + + scheduled_date = date.today().isoformat() + prep_session = await asyncio.to_thread( + store.create_prep_session, plan_id, scheduled_date + ) + session_id = prep_session["id"] + + task_dicts = [ + { + "recipe_id": t.recipe_id, + "slot_id": t.slot_id, + "task_label": t.task_label, + "duration_minutes": t.duration_minutes, + "sequence_order": t.sequence_order, + "equipment": t.equipment, + "is_parallel": t.is_parallel, + "notes": t.notes, + } + for t in prep_tasks + ] + inserted = await asyncio.to_thread(store.bulk_insert_prep_tasks, session_id, task_dicts) + + return PrepSessionSummary( + id=prep_session["id"], + plan_id=prep_session["plan_id"], + scheduled_date=prep_session["scheduled_date"], + status=prep_session["status"], + tasks=[_prep_task_summary(r) for r in inserted], + ) + + +@router.patch( + "/{plan_id}/prep-session/tasks/{task_id}", + response_model=PrepTaskSummary, +) +async def update_prep_task( + plan_id: int, + task_id: int, + req: UpdatePrepTaskRequest, + session: CloudUser = Depends(get_session), + store: Store = Depends(get_store), +) -> PrepTaskSummary: + updated = await asyncio.to_thread( + store.update_prep_task, + task_id, + duration_minutes=req.duration_minutes, + sequence_order=req.sequence_order, + notes=req.notes, + equipment=req.equipment, + ) + if updated is None: + raise HTTPException(status_code=404, detail="Task not found.") + return _prep_task_summary(updated) diff --git a/tests/api/test_meal_plans.py b/tests/api/test_meal_plans.py new file mode 100644 index 0000000..0cea609 --- /dev/null +++ b/tests/api/test_meal_plans.py @@ -0,0 +1,116 @@ +# tests/api/test_meal_plans.py +"""Integration tests for /api/v1/meal-plans/ endpoints.""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient + +from app.cloud_session import get_session +from app.db.session import get_store +from app.main import app + +client = TestClient(app) + + +def _make_session(tier: str = "free") -> MagicMock: + m = MagicMock() + m.tier = tier + m.has_byok = False + return m + + +def _make_store() -> MagicMock: + m = MagicMock() + m.create_meal_plan.return_value = { + "id": 1, "week_start": "2026-04-14", + "meal_types": ["dinner"], "created_at": "2026-04-12T10:00:00", + } + m.list_meal_plans.return_value = [] + m.get_meal_plan.return_value = None + m.get_plan_slots.return_value = [] + m.upsert_slot.return_value = { + "id": 1, "plan_id": 1, "day_of_week": 0, "meal_type": "dinner", + "recipe_id": 42, "recipe_title": "Pasta", "servings": 2.0, "custom_label": None, + } + m.get_inventory.return_value = [] + m.get_plan_recipes.return_value = [] + m.get_prep_session_for_plan.return_value = None + m.create_prep_session.return_value = { + "id": 1, "plan_id": 1, "scheduled_date": "2026-04-13", + "status": "draft", "created_at": "2026-04-12T10:00:00", + } + m.get_prep_tasks.return_value = [] + m.bulk_insert_prep_tasks.return_value = [] + return m + + +@pytest.fixture() +def free_session(): + session = _make_session("free") + store = _make_store() + app.dependency_overrides[get_session] = lambda: session + app.dependency_overrides[get_store] = lambda: store + yield store + app.dependency_overrides.clear() + + +@pytest.fixture() +def paid_session(): + session = _make_session("paid") + store = _make_store() + app.dependency_overrides[get_session] = lambda: session + app.dependency_overrides[get_store] = lambda: store + yield store + app.dependency_overrides.clear() + + +def test_create_plan_free_tier_locks_to_dinner(free_session): + resp = client.post("/api/v1/meal-plans/", json={ + "week_start": "2026-04-14", "meal_types": ["breakfast", "dinner"] + }) + assert resp.status_code == 200 + # Free tier forced to dinner-only regardless of request + free_session.create_meal_plan.assert_called_once_with("2026-04-14", ["dinner"]) + + +def test_create_plan_paid_tier_respects_meal_types(paid_session): + resp = client.post("/api/v1/meal-plans/", json={ + "week_start": "2026-04-14", "meal_types": ["breakfast", "lunch", "dinner"] + }) + assert resp.status_code == 200 + paid_session.create_meal_plan.assert_called_once_with( + "2026-04-14", ["breakfast", "lunch", "dinner"] + ) + + +def test_list_plans_returns_200(free_session): + resp = client.get("/api/v1/meal-plans/") + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_upsert_slot_returns_200(free_session): + free_session.get_meal_plan.return_value = { + "id": 1, "week_start": "2026-04-14", "meal_types": ["dinner"], + "created_at": "2026-04-12T10:00:00", + } + resp = client.put( + "/api/v1/meal-plans/1/slots/0/dinner", + json={"recipe_id": 42, "servings": 2.0}, + ) + assert resp.status_code == 200 + + +def test_get_shopping_list_returns_200(free_session): + free_session.get_meal_plan.return_value = { + "id": 1, "week_start": "2026-04-14", "meal_types": ["dinner"], + "created_at": "2026-04-12T10:00:00", + } + resp = client.get("/api/v1/meal-plans/1/shopping-list") + assert resp.status_code == 200 + body = resp.json() + assert "gap_items" in body + assert "covered_items" in body From 536eedfd6cf77e3492463d1c41a097649610994c Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 13:44:08 -0700 Subject: [PATCH 09/24] feat(routes): register meal-plans router at /api/v1/meal-plans refs kiwi#68 --- app/api/routes.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/app/api/routes.py b/app/api/routes.py index ec9d25c..e0ea172 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1,16 +1,17 @@ from fastapi import APIRouter -from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes +from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, household, saved_recipes, meal_plans 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"]) \ No newline at end of file +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"]) \ No newline at end of file From bfc63f1fc91bff676824bb23cc5a885233ce716a Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 13:44:27 -0700 Subject: [PATCH 10/24] feat(services): add planner.py orchestration helpers --- app/services/meal_plan/planner.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 app/services/meal_plan/planner.py diff --git a/app/services/meal_plan/planner.py b/app/services/meal_plan/planner.py new file mode 100644 index 0000000..ca1fdea --- /dev/null +++ b/app/services/meal_plan/planner.py @@ -0,0 +1,26 @@ +# app/services/meal_plan/planner.py +"""Plan and slot orchestration — thin layer over Store. + +No FastAPI imports. Provides helpers used by the API endpoint. +""" +from __future__ import annotations + +from app.db.store import Store +from app.models.schemas.meal_plan import VALID_MEAL_TYPES + + +def create_plan(store: Store, week_start: str, meal_types: list[str]) -> dict: + """Create a plan, filtering meal_types to valid values only.""" + valid = [t for t in meal_types if t in VALID_MEAL_TYPES] + if not valid: + valid = ["dinner"] + return store.create_meal_plan(week_start, valid) + + +def get_plan_with_slots(store: Store, plan_id: int) -> dict | None: + """Return a plan row with its slots list attached, or None.""" + plan = store.get_meal_plan(plan_id) + if plan is None: + return None + slots = store.get_plan_slots(plan_id) + return {**plan, "slots": slots} From 482666907bbed8590a9dcaeb4163d1111ce2c5a6 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 13:51:50 -0700 Subject: [PATCH 11/24] fix(meal-planner): validate meal_type path param, enforce store whitelist safety, add week_start date validation, make PrepTask frozen - upsert_slot: raise 422 immediately if meal_type not in VALID_MEAL_TYPES - update_prep_task: assert whitelist safety contract after dict comprehension - CreatePlanRequest: week_start typed as date with must_be_monday validator; str() cast at call site - PrepTask: frozen=True; build_prep_tasks rewired to use (priority, kwargs) tuples so frozen instances are built with correct sequence_order in one pass (no post-construction mutation) - Move deferred import json to file-level in meal_plans.py - Fix test dates: "2026-04-14" was a Tuesday; updated request bodies to "2026-04-13" (Monday) --- app/api/endpoints/meal_plans.py | 6 ++-- app/db/store.py | 1 + app/models/schemas/meal_plan.py | 13 +++++++-- app/services/meal_plan/prep_scheduler.py | 37 ++++++++++++++---------- tests/api/test_meal_plans.py | 8 ++--- 5 files changed, 41 insertions(+), 24 deletions(-) diff --git a/app/api/endpoints/meal_plans.py b/app/api/endpoints/meal_plans.py index f1bf3f8..eaed30f 100644 --- a/app/api/endpoints/meal_plans.py +++ b/app/api/endpoints/meal_plans.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json from datetime import date from fastapi import APIRouter, Depends, HTTPException @@ -48,7 +49,6 @@ def _slot_summary(row: dict) -> SlotSummary: def _plan_summary(plan: dict, slots: list[dict]) -> PlanSummary: meal_types = plan.get("meal_types") or ["dinner"] if isinstance(meal_types, str): - import json meal_types = json.loads(meal_types) return PlanSummary( id=plan["id"], @@ -87,7 +87,7 @@ async def create_plan( else: meal_types = ["dinner"] - plan = await asyncio.to_thread(store.create_meal_plan, req.week_start, meal_types) + plan = await asyncio.to_thread(store.create_meal_plan, str(req.week_start), meal_types) slots = await asyncio.to_thread(store.get_plan_slots, plan["id"]) return _plan_summary(plan, slots) @@ -124,6 +124,8 @@ async def upsert_slot( session: CloudUser = Depends(get_session), store: Store = Depends(get_store), ) -> SlotSummary: + if meal_type not in VALID_MEAL_TYPES: + raise HTTPException(status_code=422, detail=f"Invalid meal_type '{meal_type}'.") plan = await asyncio.to_thread(store.get_meal_plan, plan_id) if plan is None: raise HTTPException(status_code=404, detail="Plan not found.") diff --git a/app/db/store.py b/app/db/store.py index 0701b3a..83dd49a 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -1115,6 +1115,7 @@ class Store: def update_prep_task(self, task_id: int, **kwargs: object) -> dict | None: allowed = {"duration_minutes", "sequence_order", "notes", "equipment"} updates = {k: v for k, v in kwargs.items() if k in allowed and v is not None} + assert all(k in allowed for k in updates), f"Unexpected column(s): {set(updates) - allowed}" if not updates: return self._fetch_one("SELECT * FROM prep_tasks WHERE id = ?", (task_id,)) set_clause = ", ".join(f"{k} = ?" for k in updates) diff --git a/app/models/schemas/meal_plan.py b/app/models/schemas/meal_plan.py index 9adff0c..6e1a678 100644 --- a/app/models/schemas/meal_plan.py +++ b/app/models/schemas/meal_plan.py @@ -2,16 +2,25 @@ """Pydantic schemas for meal planning endpoints.""" from __future__ import annotations -from pydantic import BaseModel, Field +from datetime import date as _date + +from pydantic import BaseModel, Field, field_validator VALID_MEAL_TYPES = {"breakfast", "lunch", "dinner", "snack"} class CreatePlanRequest(BaseModel): - week_start: str # ISO date string, e.g. "2026-04-14" (must be Monday) + week_start: _date meal_types: list[str] = Field(default_factory=lambda: ["dinner"]) + @field_validator("week_start") + @classmethod + def must_be_monday(cls, v: _date) -> _date: + if v.weekday() != 0: + raise ValueError("week_start must be a Monday (weekday 0)") + return v + class UpsertSlotRequest(BaseModel): recipe_id: int | None = None diff --git a/app/services/meal_plan/prep_scheduler.py b/app/services/meal_plan/prep_scheduler.py index 6d3088d..e627f65 100644 --- a/app/services/meal_plan/prep_scheduler.py +++ b/app/services/meal_plan/prep_scheduler.py @@ -6,13 +6,13 @@ Pure function — no DB or network calls. Sorts tasks by equipment priority """ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass _EQUIPMENT_PRIORITY = {"oven": 0, "stovetop": 1, "cold": 2, "no-heat": 3} _DEFAULT_PRIORITY = 4 -@dataclass +@dataclass(frozen=True) class PrepTask: recipe_id: int | None slot_id: int | None @@ -57,7 +57,7 @@ def build_prep_tasks(slots: list[dict], recipes: list[dict]) -> list[PrepTask]: return [] recipe_map: dict[int, dict] = {r["id"]: r for r in recipes} - raw_tasks: list[tuple[int, PrepTask]] = [] # (priority, task) + raw_tasks: list[tuple[int, dict]] = [] # (priority, kwargs) for slot in slots: recipe_id = slot.get("recipe_id") @@ -69,18 +69,23 @@ def build_prep_tasks(slots: list[dict], recipes: list[dict]) -> list[PrepTask]: eq = _equipment(recipe) priority = _EQUIPMENT_PRIORITY.get(eq or "", _DEFAULT_PRIORITY) - task = PrepTask( - recipe_id=recipe_id, - slot_id=slot.get("id"), - task_label=recipe.get("name", f"Recipe {recipe_id}"), - duration_minutes=_total_minutes(recipe), - sequence_order=0, # filled below - equipment=eq, - ) - raw_tasks.append((priority, task)) + raw_tasks.append((priority, { + "recipe_id": recipe_id, + "slot_id": slot.get("id"), + "task_label": recipe.get("name", f"Recipe {recipe_id}"), + "duration_minutes": _total_minutes(recipe), + "equipment": eq, + })) raw_tasks.sort(key=lambda t: t[0]) - for i, (_, task) in enumerate(raw_tasks, 1): - task.sequence_order = i - - return [t for _, t in raw_tasks] + return [ + PrepTask( + recipe_id=kw["recipe_id"], + slot_id=kw["slot_id"], + task_label=kw["task_label"], + duration_minutes=kw["duration_minutes"], + sequence_order=i, + equipment=kw["equipment"], + ) + for i, (_, kw) in enumerate(raw_tasks, 1) + ] diff --git a/tests/api/test_meal_plans.py b/tests/api/test_meal_plans.py index 0cea609..176f0c4 100644 --- a/tests/api/test_meal_plans.py +++ b/tests/api/test_meal_plans.py @@ -69,20 +69,20 @@ def paid_session(): def test_create_plan_free_tier_locks_to_dinner(free_session): resp = client.post("/api/v1/meal-plans/", json={ - "week_start": "2026-04-14", "meal_types": ["breakfast", "dinner"] + "week_start": "2026-04-13", "meal_types": ["breakfast", "dinner"] }) assert resp.status_code == 200 # Free tier forced to dinner-only regardless of request - free_session.create_meal_plan.assert_called_once_with("2026-04-14", ["dinner"]) + free_session.create_meal_plan.assert_called_once_with("2026-04-13", ["dinner"]) def test_create_plan_paid_tier_respects_meal_types(paid_session): resp = client.post("/api/v1/meal-plans/", json={ - "week_start": "2026-04-14", "meal_types": ["breakfast", "lunch", "dinner"] + "week_start": "2026-04-13", "meal_types": ["breakfast", "lunch", "dinner"] }) assert resp.status_code == 200 paid_session.create_meal_plan.assert_called_once_with( - "2026-04-14", ["breakfast", "lunch", "dinner"] + "2026-04-13", ["breakfast", "lunch", "dinner"] ) From 4865498db91c0514c0aac8cf9f5ecb27340c6b3f Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 13:55:14 -0700 Subject: [PATCH 12/24] feat(frontend): add mealPlanAPI client with TypeScript types --- frontend/src/services/api.ts | 106 +++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 857c3f9..bc1d7d6 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -645,6 +645,112 @@ export const savedRecipesAPI = { }, } +// --- Meal Plan types --- + +export interface MealPlanSlot { + id: number + plan_id: number + day_of_week: number // 0 = Monday + meal_type: string + recipe_id: number | null + recipe_title: string | null + servings: number + custom_label: string | null +} + +export interface MealPlan { + id: number + week_start: string // ISO date, e.g. "2026-04-13" + meal_types: string[] + slots: MealPlanSlot[] + created_at: string +} + +export interface RetailerLink { + retailer: string + label: string + url: string +} + +export interface GapItem { + ingredient_name: string + needed_raw: string | null + have_quantity: number | null + have_unit: string | null + covered: boolean + retailer_links: RetailerLink[] +} + +export interface ShoppingList { + plan_id: number + gap_items: GapItem[] + covered_items: GapItem[] + disclosure: string | null +} + +export interface PrepTask { + id: number + recipe_id: number | null + task_label: string + duration_minutes: number | null + sequence_order: number + equipment: string | null + is_parallel: boolean + notes: string | null + user_edited: boolean +} + +export interface PrepSession { + id: number + plan_id: number + scheduled_date: string + status: 'draft' | 'reviewed' | 'done' + tasks: PrepTask[] +} + +// --- Meal Plan API --- + +export const mealPlanAPI = { + async list(): Promise { + const resp = await api.get('/meal-plans/') + return resp.data + }, + + async create(weekStart: string, mealTypes: string[]): Promise { + const resp = await api.post('/meal-plans/', { week_start: weekStart, meal_types: mealTypes }) + return resp.data + }, + + async get(planId: number): Promise { + const resp = await api.get(`/meal-plans/${planId}`) + return resp.data + }, + + async upsertSlot(planId: number, dayOfWeek: number, mealType: string, data: { recipe_id?: number | null; servings?: number; custom_label?: string | null }): Promise { + const resp = await api.put(`/meal-plans/${planId}/slots/${dayOfWeek}/${mealType}`, data) + return resp.data + }, + + async deleteSlot(planId: number, slotId: number): Promise { + await api.delete(`/meal-plans/${planId}/slots/${slotId}`) + }, + + async getShoppingList(planId: number): Promise { + const resp = await api.get(`/meal-plans/${planId}/shopping-list`) + return resp.data + }, + + async getPrepSession(planId: number): Promise { + const resp = await api.get(`/meal-plans/${planId}/prep-session`) + return resp.data + }, + + async updatePrepTask(planId: number, taskId: number, data: Partial>): Promise { + const resp = await api.patch(`/meal-plans/${planId}/prep-session/tasks/${taskId}`, data) + return resp.data + }, +} + // ========== Browser Types ========== export interface BrowserDomain { From 543c64ea30b1346ed0c466131973e078a8d2afd2 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 13:57:40 -0700 Subject: [PATCH 13/24] feat(frontend): add mealPlan Pinia store with immutable slot updates --- frontend/src/stores/mealPlan.ts | 135 ++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 frontend/src/stores/mealPlan.ts diff --git a/frontend/src/stores/mealPlan.ts b/frontend/src/stores/mealPlan.ts new file mode 100644 index 0000000..2c50324 --- /dev/null +++ b/frontend/src/stores/mealPlan.ts @@ -0,0 +1,135 @@ +// frontend/src/stores/mealPlan.ts +/** + * Meal Plan Store + * + * Manages the active week plan, shopping list, and prep session. + * Uses immutable update patterns — never mutates store state in place. + */ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { + mealPlanAPI, + type MealPlan, + type MealPlanSlot, + type ShoppingList, + type PrepSession, + type PrepTask, +} from '../services/api' + +export const useMealPlanStore = defineStore('mealPlan', () => { + const plans = ref([]) + const activePlan = ref(null) + const shoppingList = ref(null) + const prepSession = ref(null) + const loading = ref(false) + const shoppingListLoading = ref(false) + const prepLoading = ref(false) + + const slots = computed(() => activePlan.value?.slots ?? []) + + function getSlot(dayOfWeek: number, mealType: string): MealPlanSlot | undefined { + return slots.value.find(s => s.day_of_week === dayOfWeek && s.meal_type === mealType) + } + + async function loadPlans() { + loading.value = true + try { + plans.value = await mealPlanAPI.list() + } finally { + loading.value = false + } + } + + async function createPlan(weekStart: string, mealTypes: string[]): Promise { + const plan = await mealPlanAPI.create(weekStart, mealTypes) + plans.value = [plan, ...plans.value] + activePlan.value = plan + shoppingList.value = null + prepSession.value = null + return plan + } + + async function setActivePlan(planId: number) { + loading.value = true + try { + activePlan.value = await mealPlanAPI.get(planId) + shoppingList.value = null + prepSession.value = null + } finally { + loading.value = false + } + } + + async function upsertSlot(dayOfWeek: number, mealType: string, data: { recipe_id?: number | null; servings?: number; custom_label?: string | null }): Promise { + if (!activePlan.value) return + const slot = await mealPlanAPI.upsertSlot(activePlan.value.id, dayOfWeek, mealType, data) + const current = activePlan.value + const idx = current.slots.findIndex( + s => s.day_of_week === dayOfWeek && s.meal_type === mealType + ) + activePlan.value = { + ...current, + slots: idx >= 0 + ? [...current.slots.slice(0, idx), slot, ...current.slots.slice(idx + 1)] + : [...current.slots, slot], + } + shoppingList.value = null + prepSession.value = null + } + + async function clearSlot(dayOfWeek: number, mealType: string): Promise { + if (!activePlan.value) return + const slot = getSlot(dayOfWeek, mealType) + if (!slot) return + await mealPlanAPI.deleteSlot(activePlan.value.id, slot.id) + activePlan.value = { + ...activePlan.value, + slots: activePlan.value.slots.filter(s => s.id !== slot.id), + } + shoppingList.value = null + prepSession.value = null + } + + async function loadShoppingList(): Promise { + if (!activePlan.value) return + shoppingListLoading.value = true + try { + shoppingList.value = await mealPlanAPI.getShoppingList(activePlan.value.id) + } finally { + shoppingListLoading.value = false + } + } + + async function loadPrepSession(): Promise { + if (!activePlan.value) return + prepLoading.value = true + try { + prepSession.value = await mealPlanAPI.getPrepSession(activePlan.value.id) + } finally { + prepLoading.value = false + } + } + + async function updatePrepTask(taskId: number, data: Partial>): Promise { + if (!activePlan.value || !prepSession.value) return + const updated = await mealPlanAPI.updatePrepTask(activePlan.value.id, taskId, data) + const idx = prepSession.value.tasks.findIndex(t => t.id === taskId) + if (idx >= 0) { + prepSession.value = { + ...prepSession.value, + tasks: [ + ...prepSession.value.tasks.slice(0, idx), + updated, + ...prepSession.value.tasks.slice(idx + 1), + ], + } + } + } + + return { + plans, activePlan, shoppingList, prepSession, + loading, shoppingListLoading, prepLoading, slots, + getSlot, loadPlans, createPlan, setActivePlan, + upsertSlot, clearSlot, loadShoppingList, loadPrepSession, updatePrepTask, + } +}) From a7fc441105bc78420856a4ad8f81b110414e0c61 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 13:57:47 -0700 Subject: [PATCH 14/24] feat(frontend): add MealPlanGrid compact-expandable week grid component --- frontend/src/components/MealPlanGrid.vue | 126 +++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 frontend/src/components/MealPlanGrid.vue diff --git a/frontend/src/components/MealPlanGrid.vue b/frontend/src/components/MealPlanGrid.vue new file mode 100644 index 0000000..6fdf0e6 --- /dev/null +++ b/frontend/src/components/MealPlanGrid.vue @@ -0,0 +1,126 @@ + + + + + + From 67b521559e5d1ed99fca7fd818a2a854fb529bfd Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 13:57:48 -0700 Subject: [PATCH 15/24] feat(frontend): add ShoppingListPanel with pantry diff and affiliate links --- frontend/src/components/ShoppingListPanel.vue | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 frontend/src/components/ShoppingListPanel.vue diff --git a/frontend/src/components/ShoppingListPanel.vue b/frontend/src/components/ShoppingListPanel.vue new file mode 100644 index 0000000..8cd0042 --- /dev/null +++ b/frontend/src/components/ShoppingListPanel.vue @@ -0,0 +1,112 @@ + + + + + + From faaa6fbf8639bdbb95b383f921e1eb6d53dcf9f0 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 13:57:48 -0700 Subject: [PATCH 16/24] feat(frontend): add PrepSessionView with editable task durations --- frontend/src/components/PrepSessionView.vue | 115 ++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 frontend/src/components/PrepSessionView.vue diff --git a/frontend/src/components/PrepSessionView.vue b/frontend/src/components/PrepSessionView.vue new file mode 100644 index 0000000..9407abc --- /dev/null +++ b/frontend/src/components/PrepSessionView.vue @@ -0,0 +1,115 @@ + + + + + + From 2baa8c49a9ebc06241b5606d5d5b9d94c3909a9f Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 13:57:55 -0700 Subject: [PATCH 17/24] feat(frontend): add MealPlan tab with grid, shopping list, and prep schedule closes kiwi#68, kiwi#71 --- frontend/src/App.vue | 29 ++++- frontend/src/components/MealPlanView.vue | 155 +++++++++++++++++++++++ 2 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/MealPlanView.vue diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 327b6e3..94a822d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -46,6 +46,18 @@ Receipts + + + @@ -163,12 +189,13 @@ 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 FeedbackButton from './components/FeedbackButton.vue' import { useInventoryStore } from './stores/inventory' import { useEasterEggs } from './composables/useEasterEggs' import { householdAPI } from './services/api' -type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings' +type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings' | 'mealplan' const currentTab = ref('inventory') const sidebarCollapsed = ref(false) diff --git a/frontend/src/components/MealPlanView.vue b/frontend/src/components/MealPlanView.vue new file mode 100644 index 0000000..dc83856 --- /dev/null +++ b/frontend/src/components/MealPlanView.vue @@ -0,0 +1,155 @@ + + + + + + From 5f094eb37a89364d3427bd7eb2c2f56940c8b095 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 13:58:03 -0700 Subject: [PATCH 18/24] =?UTF-8?q?feat(services/bsl):=20add=20llm=5Ftiming?= =?UTF-8?q?=20=E2=80=94=20estimate=20cook=20times=20via=20LLM=20for=20miss?= =?UTF-8?q?ing=20corpus=20data=20(Paid/BYOK)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/meal_plan/llm_timing.py | 61 ++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 app/services/meal_plan/llm_timing.py diff --git a/app/services/meal_plan/llm_timing.py b/app/services/meal_plan/llm_timing.py new file mode 100644 index 0000000..7847c69 --- /dev/null +++ b/app/services/meal_plan/llm_timing.py @@ -0,0 +1,61 @@ +# app/services/meal_plan/llm_timing.py +# BSL 1.1 — LLM feature +"""Estimate cook times for recipes missing corpus prep/cook time fields. + +Used only when tier allows `meal_plan_llm_timing`. Falls back gracefully +when LLMRouter is unavailable. +""" +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + +_TIMING_PROMPT = """\ +You are a practical cook. Given a recipe name and its ingredients, estimate: +1. prep_time: minutes of active prep work (chopping, mixing, etc.) +2. cook_time: minutes of cooking (oven, stovetop, etc.) + +Respond with ONLY two integers on separate lines: +prep_time +cook_time + +If you cannot estimate, respond with: +0 +0 +""" + + +def estimate_timing(recipe_name: str, ingredients: list[str], router) -> tuple[int | None, int | None]: + """Return (prep_minutes, cook_minutes) for a recipe using LLMRouter. + + Returns (None, None) if the router is unavailable or the response is + unparseable. Never raises. + + Args: + recipe_name: Name of the recipe. + ingredients: List of raw ingredient strings from the corpus. + router: An LLMRouter instance (from circuitforge_core.llm). + """ + if router is None: + return None, None + + ingredient_list = "\n".join(f"- {i}" for i in (ingredients or [])[:15]) + prompt = f"Recipe: {recipe_name}\n\nIngredients:\n{ingredient_list}" + + try: + response = router.complete( + system=_TIMING_PROMPT, + user=prompt, + max_tokens=16, + temperature=0.0, + ) + lines = response.strip().splitlines() + prep = int(lines[0].strip()) if lines else 0 + cook = int(lines[1].strip()) if len(lines) > 1 else 0 + if prep == 0 and cook == 0: + return None, None + return prep or None, cook or None + except Exception as exc: + logger.debug("LLM timing estimation failed for %r: %s", recipe_name, exc) + return None, None From 062b5d16a10e7af35d577761e21ad43d9916d762 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 13:58:04 -0700 Subject: [PATCH 19/24] =?UTF-8?q?feat(services/bsl):=20add=20llm=5Fplanner?= =?UTF-8?q?=20=E2=80=94=20LLM-assisted=20full-week=20meal=20plan=20generat?= =?UTF-8?q?ion=20(Paid/BYOK)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/meal_plan/llm_planner.py | 87 +++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 app/services/meal_plan/llm_planner.py diff --git a/app/services/meal_plan/llm_planner.py b/app/services/meal_plan/llm_planner.py new file mode 100644 index 0000000..bc017e5 --- /dev/null +++ b/app/services/meal_plan/llm_planner.py @@ -0,0 +1,87 @@ +# app/services/meal_plan/llm_planner.py +# BSL 1.1 — LLM feature +"""LLM-assisted full-week meal plan generation. + +Returns suggestions for human review — never writes to the DB directly. +The API endpoint presents the suggestions and waits for user approval +before calling store.upsert_slot(). +""" +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + +_PLAN_SYSTEM = """\ +You are a practical meal planning assistant. Given a pantry inventory and +dietary preferences, suggest a week of dinners (or other configured meals). + +Prioritise ingredients that are expiring soon. Prefer variety across the week. +Respect all dietary restrictions. + +Respond with a JSON array only — no prose, no markdown fences. +Each item: {"day": 0-6, "meal_type": "dinner", "recipe_id": , "suggestion": ""} + +day 0 = Monday, day 6 = Sunday. +If you cannot match a known recipe_id, set recipe_id to null and provide a suggestion name. +""" + + +@dataclass(frozen=True) +class PlanSuggestion: + day: int # 0 = Monday + meal_type: str + recipe_id: int | None + suggestion: str # human-readable name + + +def generate_plan( + pantry_items: list[str], + meal_types: list[str], + dietary_notes: str, + router, +) -> list[PlanSuggestion]: + """Return a list of PlanSuggestion for user review. + + Never writes to DB — caller must upsert slots after user approves. + Returns an empty list if router is None or response is unparseable. + """ + if router is None: + return [] + + pantry_text = "\n".join(f"- {item}" for item in pantry_items[:50]) + meal_text = ", ".join(meal_types) + user_msg = ( + f"Meal types: {meal_text}\n" + f"Dietary notes: {dietary_notes or 'none'}\n\n" + f"Pantry (partial):\n{pantry_text}" + ) + + try: + response = router.complete( + system=_PLAN_SYSTEM, + user=user_msg, + max_tokens=512, + temperature=0.7, + ) + items = json.loads(response.strip()) + suggestions = [] + for item in items: + if not isinstance(item, dict): + continue + day = item.get("day") + meal_type = item.get("meal_type", "dinner") + if not isinstance(day, int) or day < 0 or day > 6: + continue + suggestions.append(PlanSuggestion( + day=day, + meal_type=meal_type, + recipe_id=item.get("recipe_id"), + suggestion=str(item.get("suggestion", "")), + )) + return suggestions + except Exception as exc: + logger.debug("LLM plan generation failed: %s", exc) + return [] From f54127a8cc06c51c9c7f15d6c6931539ca41eff3 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 14:04:53 -0700 Subject: [PATCH 20/24] fix(meal-planner): add GET prep-session endpoint, fix list_plans schema, replace assert with ValueError - Add GET /{plan_id}/prep-session endpoint so frontend can retrieve existing sessions without creating - Fix list_plans response_model from list[dict] to list[PlanSummary] with proper _plan_summary() mapping - Replace assert in store.update_prep_task with ValueError (assert is stripped under python -O) - Add day_of_week 0-6 validation to upsert_slot endpoint - Remove MagicMock sqlite artifact files left by pytest (already in .gitignore) --- app/api/endpoints/meal_plans.py | 35 ++++++++++++++++++++++++++++++--- app/db/store.py | 4 +++- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/app/api/endpoints/meal_plans.py b/app/api/endpoints/meal_plans.py index eaed30f..e1d6cbc 100644 --- a/app/api/endpoints/meal_plans.py +++ b/app/api/endpoints/meal_plans.py @@ -92,12 +92,17 @@ async def create_plan( return _plan_summary(plan, slots) -@router.get("/", response_model=list[dict]) +@router.get("/", response_model=list[PlanSummary]) async def list_plans( session: CloudUser = Depends(get_session), store: Store = Depends(get_store), -) -> list[dict]: - return await asyncio.to_thread(store.list_meal_plans) +) -> list[PlanSummary]: + plans = await asyncio.to_thread(store.list_meal_plans) + result = [] + for p in plans: + slots = await asyncio.to_thread(store.get_plan_slots, p["id"]) + result.append(_plan_summary(p, slots)) + return result @router.get("/{plan_id}", response_model=PlanSummary) @@ -124,6 +129,8 @@ async def upsert_slot( session: CloudUser = Depends(get_session), store: Store = Depends(get_store), ) -> SlotSummary: + if day_of_week < 0 or day_of_week > 6: + raise HTTPException(status_code=422, detail="day_of_week must be 0-6.") if meal_type not in VALID_MEAL_TYPES: raise HTTPException(status_code=422, detail=f"Invalid meal_type '{meal_type}'.") plan = await asyncio.to_thread(store.get_meal_plan, plan_id) @@ -197,6 +204,28 @@ async def get_shopping_list( # ── prep session ────────────────────────────────────────────────────────────── +@router.get("/{plan_id}/prep-session", response_model=PrepSessionSummary) +async def get_prep_session( + plan_id: int, + session: CloudUser = Depends(get_session), + store: Store = Depends(get_store), +) -> PrepSessionSummary: + plan = await asyncio.to_thread(store.get_meal_plan, plan_id) + if plan is None: + raise HTTPException(status_code=404, detail="Plan not found.") + prep_session = await asyncio.to_thread(store.get_prep_session_for_plan, plan_id) + if prep_session is None: + raise HTTPException(status_code=404, detail="No prep session for this plan.") + raw_tasks = await asyncio.to_thread(store.get_prep_tasks, prep_session["id"]) + return PrepSessionSummary( + id=prep_session["id"], + plan_id=plan_id, + scheduled_date=prep_session["scheduled_date"], + status=prep_session["status"], + tasks=[_prep_task_summary(t) for t in raw_tasks], + ) + + @router.post("/{plan_id}/prep-session", response_model=PrepSessionSummary) async def create_prep_session( plan_id: int, diff --git a/app/db/store.py b/app/db/store.py index 83dd49a..a095c55 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -1115,7 +1115,9 @@ class Store: def update_prep_task(self, task_id: int, **kwargs: object) -> dict | None: allowed = {"duration_minutes", "sequence_order", "notes", "equipment"} updates = {k: v for k, v in kwargs.items() if k in allowed and v is not None} - assert all(k in allowed for k in updates), f"Unexpected column(s): {set(updates) - allowed}" + invalid = set(updates) - allowed + if invalid: + raise ValueError(f"Unexpected column(s) in update_prep_task: {invalid}") if not updates: return self._fetch_one("SELECT * FROM prep_tasks WHERE id = ?", (task_id,)) set_clause = ", ".join(f"{k} = ?" for k in updates) From 4281b0ce19b630e61a2fbd8bb5f21a837d1908a7 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 14:07:13 -0700 Subject: [PATCH 21/24] =?UTF-8?q?feat(services/bsl):=20add=20llm=5Frouter?= =?UTF-8?q?=20=E2=80=94=20cf-text=20via=20cf-orch=20on=20cloud,=20LLMRoute?= =?UTF-8?q?r=20(ollama/vllm)=20local=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refs kiwi#68 --- app/services/meal_plan/llm_router.py | 96 ++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 app/services/meal_plan/llm_router.py diff --git a/app/services/meal_plan/llm_router.py b/app/services/meal_plan/llm_router.py new file mode 100644 index 0000000..4475b52 --- /dev/null +++ b/app/services/meal_plan/llm_router.py @@ -0,0 +1,96 @@ +# app/services/meal_plan/llm_router.py +# BSL 1.1 — LLM feature +"""Provide a router-compatible LLM client for meal plan generation tasks. + +Cloud (CF_ORCH_URL set): + Allocates a cf-text service via cf-orch (3B-7B GGUF, ~2GB VRAM). + Returns an _OrchTextRouter that wraps the cf-text HTTP endpoint + with a .complete(system, user, **kwargs) interface. + +Local / self-hosted (no CF_ORCH_URL): + Returns an LLMRouter instance which tries ollama, vllm, or any + backend configured in ~/.config/circuitforge/llm.yaml. + +Both paths expose the same interface so llm_timing.py and llm_planner.py +need no knowledge of the backend. +""" +from __future__ import annotations + +import logging +import os +from contextlib import nullcontext + +logger = logging.getLogger(__name__) + +# cf-orch service name and VRAM budget for meal plan LLM tasks. +# These are lighter than recipe_llm (4.0 GB) — cf-text handles them. +_SERVICE_TYPE = "cf-text" +_TTL_S = 120.0 +_CALLER = "kiwi-meal-plan" + + +class _OrchTextRouter: + """Thin adapter that makes a cf-text HTTP endpoint look like LLMRouter.""" + + def __init__(self, base_url: str) -> None: + self._base_url = base_url.rstrip("/") + + def complete( + self, + system: str = "", + user: str = "", + max_tokens: int = 512, + temperature: float = 0.7, + **_kwargs, + ) -> str: + from openai import OpenAI + client = OpenAI(base_url=self._base_url + "/v1", api_key="any") + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": user}) + try: + model = client.models.list().data[0].id + except Exception: + model = "local" + resp = client.chat.completions.create( + model=model, + messages=messages, + max_tokens=max_tokens, + temperature=temperature, + ) + return resp.choices[0].message.content or "" + + +def get_meal_plan_router(): + """Return an LLM client for meal plan tasks. + + Tries cf-orch cf-text allocation first (cloud); falls back to LLMRouter + (local ollama/vllm). Returns None if no backend is available. + """ + cf_orch_url = os.environ.get("CF_ORCH_URL") + if cf_orch_url: + try: + from circuitforge_orch.client import CFOrchClient + client = CFOrchClient(cf_orch_url) + ctx = client.allocate( + service=_SERVICE_TYPE, + ttl_s=_TTL_S, + caller=_CALLER, + ) + alloc = ctx.__enter__() + if alloc is not None: + return _OrchTextRouter(alloc.url), ctx + except Exception as exc: + logger.debug("cf-orch cf-text allocation failed, falling back to LLMRouter: %s", exc) + + # Local fallback: LLMRouter (ollama / vllm / openai-compat) + try: + from circuitforge_core.llm.router import LLMRouter + return LLMRouter(), nullcontext(None) + except FileNotFoundError: + logger.debug("LLMRouter: no llm.yaml and no LLM env vars — meal plan LLM disabled") + return None, nullcontext(None) + except Exception as exc: + logger.debug("LLMRouter init failed: %s", exc) + return None, nullcontext(None) From e52c406d0ab5de665fb11ca2c02eb477b963f652 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 14:07:32 -0700 Subject: [PATCH 22/24] docs(bsl): document cf-text/LLMRouter routing chain in llm_timing and llm_planner --- app/services/meal_plan/llm_planner.py | 4 ++++ app/services/meal_plan/llm_timing.py | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/services/meal_plan/llm_planner.py b/app/services/meal_plan/llm_planner.py index bc017e5..11859b4 100644 --- a/app/services/meal_plan/llm_planner.py +++ b/app/services/meal_plan/llm_planner.py @@ -5,6 +5,10 @@ Returns suggestions for human review — never writes to the DB directly. The API endpoint presents the suggestions and waits for user approval before calling store.upsert_slot(). + +Routing: pass a router from get_meal_plan_router() in llm_router.py. +Cloud: cf-text via cf-orch (3B-7B GGUF, ~2GB VRAM). +Local: LLMRouter (ollama / vllm / openai-compat per llm.yaml). """ from __future__ import annotations diff --git a/app/services/meal_plan/llm_timing.py b/app/services/meal_plan/llm_timing.py index 7847c69..8918b8b 100644 --- a/app/services/meal_plan/llm_timing.py +++ b/app/services/meal_plan/llm_timing.py @@ -3,7 +3,11 @@ """Estimate cook times for recipes missing corpus prep/cook time fields. Used only when tier allows `meal_plan_llm_timing`. Falls back gracefully -when LLMRouter is unavailable. +when no LLM backend is available. + +Routing: pass a router from get_meal_plan_router() in llm_router.py. +Cloud: cf-text via cf-orch (3B GGUF, ~2GB VRAM). +Local: LLMRouter (ollama / vllm / openai-compat per llm.yaml). """ from __future__ import annotations From 19c0664637a5d7e392ac53328f54e0a19fcc6805 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 14:16:24 -0700 Subject: [PATCH 23/24] fix(review): address code review findings before merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - update_prep_task: move whitelist guard above filter so invalid column check runs on raw kwargs (was dead code — set(filtered) - allowed is always empty); fixes latent SQL injection path for future callers - main.py: move register_kiwi_programs() into lifespan context manager so it runs once at startup, not at module import time - MealPlanView.vue: remove debug console.log stubs from onSlotClick and onAddMealType (follow-up issue handlers, not ready for production) --- app/db/store.py | 4 ++-- app/main.py | 3 +-- frontend/src/components/MealPlanView.vue | 4 +--- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/app/db/store.py b/app/db/store.py index a095c55..576ac55 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -1114,10 +1114,10 @@ class Store: def update_prep_task(self, task_id: int, **kwargs: object) -> dict | None: allowed = {"duration_minutes", "sequence_order", "notes", "equipment"} - updates = {k: v for k, v in kwargs.items() if k in allowed and v is not None} - invalid = set(updates) - allowed + invalid = set(kwargs) - allowed # check raw kwargs BEFORE filtering if invalid: raise ValueError(f"Unexpected column(s) in update_prep_task: {invalid}") + updates = {k: v for k, v in kwargs.items() if v is not None} if not updates: return self._fetch_one("SELECT * FROM prep_tasks WHERE id = ?", (task_id,)) set_clause = ", ".join(f"{k} = ?" for k in updates) diff --git a/app/main.py b/app/main.py index 8121f00..c5ccec3 100644 --- a/app/main.py +++ b/app/main.py @@ -11,8 +11,6 @@ from app.api.routes import api_router from app.core.config import settings from app.services.meal_plan.affiliates import register_kiwi_programs -register_kiwi_programs() - logger = logging.getLogger(__name__) @@ -20,6 +18,7 @@ logger = logging.getLogger(__name__) async def lifespan(app: FastAPI): logger.info("Starting Kiwi API...") settings.ensure_dirs() + register_kiwi_programs() # Start LLM background task scheduler from app.tasks.scheduler import get_scheduler diff --git a/frontend/src/components/MealPlanView.vue b/frontend/src/components/MealPlanView.vue index dc83856..7f6264e 100644 --- a/frontend/src/components/MealPlanView.vue +++ b/frontend/src/components/MealPlanView.vue @@ -110,14 +110,12 @@ async function onSelectPlan(planId: number) { if (planId) await store.setActivePlan(planId) } -function onSlotClick({ dayOfWeek, mealType }: { dayOfWeek: number; mealType: string }) { +function onSlotClick(_: { dayOfWeek: number; mealType: string }) { // Recipe picker integration filed as follow-up - console.log('[MealPlan] slot-click', { dayOfWeek, mealType }) } function onAddMealType() { // Add meal type picker — Paid gate enforced by backend - console.log('[MealPlan] add-meal-type') } From bd73ca0b6dde1c2c9d3f0af54a620add69f8d598 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 14 Apr 2026 14:57:16 -0700 Subject: [PATCH 24/24] 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) --- tests/api/test_recipe_build_endpoints.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/api/test_recipe_build_endpoints.py b/tests/api/test_recipe_build_endpoints.py index f40ed4c..8d21a89 100644 --- a/tests/api/test_recipe_build_endpoints.py +++ b/tests/api/test_recipe_build_endpoints.py @@ -4,14 +4,14 @@ from fastapi.testclient import TestClient @pytest.fixture -def client(tmp_path): +def client(tmp_path, monkeypatch): """FastAPI test client with a seeded in-memory DB.""" import os - os.environ["KIWI_DB_PATH"] = str(tmp_path / "test.db") + db_path = tmp_path / "test.db" os.environ["CLOUD_MODE"] = "false" - from app.main import app + # Seed DB before app imports so migrations run and data is present from app.db.store import Store - store = Store(tmp_path / "test.db") + store = Store(db_path) store.conn.execute( "INSERT INTO products (name, barcode) VALUES (?,?)", ("chicken breast", None) ) @@ -25,6 +25,11 @@ def client(tmp_path): "INSERT INTO inventory_items (product_id, location, status) VALUES (2,'pantry','available')" ) store.conn.commit() + store.close() + # Patch the module-level DB path used by local-mode session resolution + import app.cloud_session as _cs + monkeypatch.setattr(_cs, "_LOCAL_KIWI_DB", db_path) + from app.main import app return TestClient(app) @@ -65,7 +70,7 @@ def test_post_build_returns_recipe(client): }) assert resp.status_code == 200 data = resp.json() - assert data["id"] == -1 + assert data["id"] > 0 # persisted to DB with real integer ID assert len(data["directions"]) > 0