From a6a28ef6e4a2717e4cddb5c7a926e688fa656630 Mon Sep 17 00:00:00 2001 From: Kayos Date: Thu, 30 Apr 2026 21:44:56 -0700 Subject: [PATCH] =?UTF-8?q?plan:=20multi-meal=20slots=20(breakfast/lunch/d?= =?UTF-8?q?inner)=20+=20rename=20agent=20=E2=86=92=20Sage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Big plan-generator addition: each plan can now span multiple meal types, not just dinner. Default stays dinner-only for back-compat; opt-in via checkboxes on /plan. Schema (migrations 028-031): - cauldron_meal_plan_slots gains meal_type ENUM('breakfast','lunch', 'dinner','snack','dessert','side') NOT NULL DEFAULT 'dinner'. - Old UNIQUE key (plan_id, day) โ†’ (plan_id, day, meal_type) so a Monday can have breakfast AND lunch AND dinner slots. - cauldron_meal_plans gains meal_types_json (which meals to plan for that week โ€” list of strings, defaults to ["dinner"]). Forge: - generate_plan accepts meal_types list. Output schema gains meal_type per slot. Validates expected_total = slots * len(meal_types) and rejects duplicate (day, meal_type) pairs. - _build_plan_prompt renders MEAL TYPES TO PLAN block, instructing Sonnet to match recipe meta.meal_type to slot type (breakfast slot โ†’ recipe whose meta tags it as breakfast). Falls back gracefully when the pool is thin for a particular meal type. Server: - /api/plan/generate + regenerate accept body.meal_types, persist via db.set_plan_meal_types. - plan_view decodes meal_types_json into plan["meal_types_list"] and builds plan["meal_types_label"] for the readout. UI (/plan): - New checkbox row at the top of the pref-block: ๐Ÿณ breakfast / ๐Ÿฅช lunch / ๐Ÿฝ๏ธ dinner. Defaults to whatever's persisted (or just dinner). - Day cards now group multiple meal_type slots per day with small meal-type tags above each recipe row. Single-meal plans render the same way they always did (no tag shown when only one meal_type). - readMealTypes() in JS reads checkboxes and ships in the body. DB: - save_plan_slots accepts meal_type per slot, defaults to 'dinner'. - list_plan_slots orders by day then meal_type via MEAL_ORDER. == UX rename: "claude" / "sonnet" โ†’ "Sage" across all user-visible copy. Sage doubles as kitchen-herb (theme fit) and wise advisor (planner role). The internal field name `sonnet_decision` on consolidate + dedupe proposals is unchanged (it's a data field, not user-facing). Renames touched plan, consolidate, dedupe_recipes, list, me, enrich_recipes, sterilize templates. Cobb can swap to Mim or his own name later โ€” easy global s/sage/whatever/g. == The /list 'clear' button removed earlier today (b4cb48b) โ€” not re-introduced. --- cauldron/db.py | 77 ++++++++++++++++++--- cauldron/forge.py | 92 ++++++++++++++++++++----- cauldron/server.py | 32 ++++++++- cauldron/templates/consolidate.html | 4 +- cauldron/templates/dedupe_recipes.html | 6 +- cauldron/templates/enrich_recipes.html | 2 +- cauldron/templates/me.html | 6 +- cauldron/templates/plan.html | 94 +++++++++++++++++++++----- cauldron/templates/sterilize.html | 4 +- 9 files changed, 260 insertions(+), 57 deletions(-) diff --git a/cauldron/db.py b/cauldron/db.py index 203743b..bd1adaa 100644 --- a/cauldron/db.py +++ b/cauldron/db.py @@ -470,6 +470,33 @@ MIGRATIONS = [ ALTER TABLE cauldron_meal_plans ADD COLUMN IF NOT EXISTS exclusions_json JSON """, + # 028 โ€” Multi-meal planning. Slots gain meal_type so a single plan + # row can hold breakfast + lunch + dinner across the same 7 days. + # Default 'dinner' so existing rows keep their semantics. + """ + ALTER TABLE cauldron_meal_plan_slots + ADD COLUMN IF NOT EXISTS meal_type + ENUM('breakfast','lunch','dinner','snack','dessert','side') + NOT NULL DEFAULT 'dinner' + """, + # 029 โ€” Old unique key was (plan_id, day) โ€” now needs to include + # meal_type so a Monday can have breakfast AND lunch AND dinner. + # Drop-and-add, idempotent: catch the "doesn't exist" if already done. + """ + ALTER TABLE cauldron_meal_plan_slots + DROP INDEX uk_plan_day + """, + """ + ALTER TABLE cauldron_meal_plan_slots + ADD UNIQUE KEY uk_plan_day_meal (plan_id, day, meal_type) + """, + # 030 โ€” Per-plan selection of which meal types to generate. List of + # strings: ["breakfast","lunch","dinner"]. Default dinner-only so + # generation is back-compat for users who haven't opted in. + """ + ALTER TABLE cauldron_meal_plans + ADD COLUMN IF NOT EXISTS meal_types_json JSON + """, ] @@ -677,6 +704,27 @@ class DB: (clean, plan_id), ) + def set_plan_meal_types(self, plan_id: int, meal_types: list | None) -> None: + """Persist which meal types this plan should generate. None / empty + list defaults to ['dinner'] at generation time.""" + import json as _json + clean: list | None = None + if isinstance(meal_types, list) and meal_types: + allowed = set(self.MEAL_ORDER) + ce = [] + for m in meal_types: + if isinstance(m, str) and m.strip().lower() in allowed: + ce.append(m.strip().lower()) + # Preserve order: breakfast, lunch, dinner, snack, dessert, side + order = {m: i for i, m in enumerate(self.MEAL_ORDER)} + ce = sorted(set(ce), key=lambda m: order[m]) + clean = ce or None + with self.conn() as c, c.cursor() as cur: + cur.execute( + "UPDATE cauldron_meal_plans SET meal_types_json=%s WHERE id=%s", + (_json.dumps(clean) if clean else None, plan_id), + ) + def set_plan_targets_and_exclusions( self, plan_id: int, @@ -779,14 +827,16 @@ class DB: # in calendar order. PLAN_DAYS = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday") + MEAL_ORDER = ("breakfast", "lunch", "dinner", "snack", "dessert", "side") + def list_plan_slots(self, plan_id: int) -> list[dict]: - """All slots for a plan, ordered Mon..Sun. picker_subs is decoded - from JSON to a list (or [] if null).""" + """All slots for a plan, ordered Mon..Sun then breakfastโ†’dinner. + picker_subs is decoded from JSON to a list (or [] if null).""" import json as _json with self.conn() as c, c.cursor() as cur: cur.execute( """ - SELECT id, plan_id, day, recipe_slug, recipe_name, source, + SELECT id, plan_id, day, meal_type, recipe_slug, recipe_name, source, picker_subs, reason, notes, created_at FROM cauldron_meal_plan_slots WHERE plan_id = %s @@ -809,30 +859,39 @@ class DB: r["notes"] = _json.loads(n) except Exception: r["notes"] = None - order = {d: i for i, d in enumerate(self.PLAN_DAYS)} - rows.sort(key=lambda r: order.get((r.get("day") or "").lower(), 99)) + day_order = {d: i for i, d in enumerate(self.PLAN_DAYS)} + meal_order = {m: i for i, m in enumerate(self.MEAL_ORDER)} + rows.sort(key=lambda r: ( + day_order.get((r.get("day") or "").lower(), 99), + meal_order.get((r.get("meal_type") or "dinner").lower(), 99), + )) return rows def save_plan_slots(self, plan_id: int, slots: list[dict]) -> int: """INSERT IGNORE every slot. Returns count actually inserted โ€” callers can use this to detect race contention (zero rows = someone - else already saved this plan).""" + else already saved this plan). Each slot must carry a meal_type + (defaults to 'dinner' for back-compat).""" import json as _json if not slots: return 0 inserted = 0 with self.conn() as c, c.cursor() as cur: for s in slots: + meal = (s.get("meal_type") or "dinner").lower() + if meal not in self.MEAL_ORDER: + meal = "dinner" cur.execute( """ INSERT IGNORE INTO cauldron_meal_plan_slots - (plan_id, day, recipe_slug, recipe_name, source, - picker_subs, reason, notes) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + (plan_id, day, meal_type, recipe_slug, recipe_name, + source, picker_subs, reason, notes) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( plan_id, (s.get("day") or "").lower()[:10], + meal, s["recipe_slug"][:255], (s.get("recipe_name") or s["recipe_slug"])[:500], s.get("source") or ("pick" if s.get("picker_subs") else "mealie"), diff --git a/cauldron/forge.py b/cauldron/forge.py index 9447328..3e7cd48 100644 --- a/cauldron/forge.py +++ b/cauldron/forge.py @@ -80,6 +80,7 @@ class Forge: picker_profiles: dict | None = None, daily_targets: dict | None = None, exclusions: list | None = None, + meal_types: list | None = None, model: str | None = None, ) -> list[dict]: """Ask Sonnet for a {slots}-day plan. Returns a list of slot dicts @@ -111,17 +112,37 @@ class Forge: if slug: valid_by_slug.setdefault(slug, p.get("name") or slug) + # Default to dinner-only if not specified โ€” back-compat with the + # original single-meal-per-day behavior. + meal_types_clean: list[str] = [] + allowed_meals = {"breakfast", "lunch", "dinner", "snack", "dessert", "side"} + if isinstance(meal_types, list): + for m in meal_types: + if isinstance(m, str) and m.strip().lower() in allowed_meals: + if m.strip().lower() not in meal_types_clean: + meal_types_clean.append(m.strip().lower()) + if not meal_types_clean: + meal_types_clean = ["dinner"] + # Stable order: breakfast โ†’ lunch โ†’ dinner โ†’ snack โ†’ dessert โ†’ side + meal_order = ["breakfast", "lunch", "dinner", "snack", "dessert", "side"] + meal_types_clean.sort(key=lambda m: meal_order.index(m)) + expected_total = slots * len(meal_types_clean) + prompt = self._build_plan_prompt( picks=picks, recipes=recipes, slots=slots, week_start=week_start, preference=preference, picker_profiles=picker_profiles, daily_targets=daily_targets, exclusions=exclusions, + meal_types=meal_types_clean, ) result = self.run(prompt, model=model or "sonnet") parsed = _extract_plan_slots(result) if not isinstance(parsed, list): raise ForgeError("model output: 'slots' must be a list") - if len(parsed) != slots: - raise ForgeError(f"model output: got {len(parsed)} slots, expected {slots}") + if len(parsed) != expected_total: + raise ForgeError( + f"model output: got {len(parsed)} slots, expected {expected_total} " + f"({slots} days ร— {len(meal_types_clean)} meals)" + ) # Pick attribution lookup keyed by slug โ†’ list[sub] pick_subs_by_slug: dict[str, list[str]] = {} @@ -131,17 +152,21 @@ class Forge: pick_subs_by_slug[slug] = list(p.get("picker_subs") or []) out = [] - seen_days: set[str] = set() + seen: set[tuple[str, str]] = set() # (day, meal_type) uniqueness for raw in parsed: if not isinstance(raw, dict): raise ForgeError("model output: each slot must be an object") day = (raw.get("day") or "").strip().lower() + meal = (raw.get("meal_type") or "dinner").strip().lower() slug = (raw.get("recipe_slug") or "").strip() if day not in _DAYS: raise ForgeError(f"model output: bad day '{day}'") - if day in seen_days: - raise ForgeError(f"model output: duplicate day '{day}'") - seen_days.add(day) + if meal not in meal_types_clean: + raise ForgeError(f"model output: unexpected meal_type '{meal}' (expected one of {meal_types_clean})") + key = (day, meal) + if key in seen: + raise ForgeError(f"model output: duplicate slot {day}/{meal}") + seen.add(key) if not slug or slug not in valid_by_slug: raise ForgeError(f"model output: unknown recipe_slug '{slug}'") @@ -158,6 +183,7 @@ class Forge: out.append({ "day": day, + "meal_type": meal, "recipe_slug": slug, "recipe_name": valid_by_slug[slug], "picker_subs": picker_subs, @@ -169,7 +195,7 @@ class Forge: @staticmethod def _build_plan_prompt(*, picks, recipes, slots, week_start, preference=None, picker_profiles=None, daily_targets=None, - exclusions=None) -> str: + exclusions=None, meal_types=None) -> str: pool_lines = [] for r in recipes: slug = r.get("slug") or "" @@ -364,29 +390,61 @@ class Forge: "fill the remaining slots.\n" ) + # Multi-meal block: when meal_types is more than just dinner, the + # output is Nร—7 slots with explicit meal_type. Each pool entry has + # a meta.meal_type tag โ€” Sonnet must match those (a "breakfast" + # slot gets a recipe whose meta.meal_type is "breakfast" or + # something close like "lunch" if no breakfast match). + meal_types_clean = list(meal_types) if isinstance(meal_types, list) and meal_types else ["dinner"] + is_multi_meal = len(meal_types_clean) > 1 + meal_block = "" + if is_multi_meal: + meal_block = ( + f"\nMEAL TYPES TO PLAN: {', '.join(meal_types_clean)}\n" + "Each day must have exactly one slot per meal type listed above.\n" + "When picking a recipe for a meal slot, prefer recipes whose " + "meta.meal_type matches the slot. e.g. for a 'breakfast' slot, " + "look at the pool entries with `meta meal_type=breakfast` or those " + "tagged 'breakfast'/'brunch'/'morning' in their meta tags. Don't " + "use a 'dessert' meal_type recipe for a dinner slot. If the pool " + "is thin for a meal type, fall back to general pool entries that " + "could plausibly fit (e.g. a hearty breakfast salad for lunch).\n" + ) + output_shape = ( + '{"slots": [{"day": "monday", "meal_type": "breakfast", ' + '"recipe_slug": "...", "picker_subs": [...] or [], "reason": "..."}, ...]}' + ) + else: + output_shape = ( + '{"slots": [{"day": "monday", "meal_type": "dinner", ' + '"recipe_slug": "...", "picker_subs": [...] or [], "reason": "..."}, ...]}' + ) + expected_total = slots * len(meal_types_clean) + meal_types_label = "/".join(meal_types_clean) if is_multi_meal else "dinner" + return ( - f"You are a family meal planner. Build a {slots}-day dinner plan " + f"You are a family meal planner. Build a {slots}-day plan ({meal_types_label}) " f"for the week of {week_start}.\n\n" f"POOL (all available recipes):\n{pool_block}\n\n" - f"PICKS (recipes the family pre-selected โ€” every pick MUST appear " - f"if pool size >= {slots}; no repeats unless pool < {slots}):\n" + f"PICKS (recipes the family pre-selected โ€” every pick MUST appear in " + f"some appropriate slot if pool size allows; no repeats):\n" f"{picks_block}\n" f"{profile_block}" + f"{meal_block}" f"{pref_block}" f"{targets_block}" f"{excl_block}" - "Output JSON ONLY, no prose: " - '{"slots": [{"day": "monday", "recipe_slug": "...", ' - '"picker_subs": [...] or [], "reason": "..."}, ...]}\n\n' + f"Output JSON ONLY, no prose: {output_shape}\n\n" "Rules:\n" - f"- Use exactly {slots} recipes\n" + f"- Use exactly {expected_total} slots ({slots} days ร— {len(meal_types_clean)} meals)\n" "- Distribute picks evenly across the week โ€” don't bunch them\n" "- ROTATION: prefer recipes with `last:NNw-ago` further in the past " "or no history shown. If a recipe has been served 2+ times in 30d " "(`2ร—/30d` or higher), DEMOTE it strongly unless it's a household " - "pick. Never repeat the same recipe slug within this 7-day plan.\n" - "- VARIETY: don't fill 5 of 7 slots with the same primary_protein or " - "the same cuisine. Mix it up across the week.\n" + "pick. Never repeat the same recipe slug within this plan.\n" + "- VARIETY: don't fill 5 of 7 dinner slots with the same primary_protein " + "or the same cuisine. Mix it up across the week.\n" + "- meal_type MUST be one of the listed meal types and match the slot.\n" "- \"reason\" is a one-line user-facing rationale " "(e.g., \"balances heavy and light meals\", \"honors abby's pick\", " "\"high-protein lean โ€” pairs with the gym week\", " diff --git a/cauldron/server.py b/cauldron/server.py index d3fe9f9..e2dcd0b 100644 --- a/cauldron/server.py +++ b/cauldron/server.py @@ -617,7 +617,7 @@ def create_app() -> Flask: pick_count = len(db.list_household_pick_slugs(hid)) # Decode JSON columns for the template + build readout labels - for k in ("daily_targets_json", "exclusions_json"): + for k in ("daily_targets_json", "exclusions_json", "meal_types_json"): v = plan.get(k) if isinstance(v, str): try: @@ -626,6 +626,9 @@ def create_app() -> Flask: plan[k] = None targets = plan.get("daily_targets_json") if isinstance(plan.get("daily_targets_json"), dict) else None exclusions = plan.get("exclusions_json") if isinstance(plan.get("exclusions_json"), list) else None + plan_meal_types = plan.get("meal_types_json") if isinstance(plan.get("meal_types_json"), list) else None + plan["meal_types_list"] = plan_meal_types or ["dinner"] + plan["meal_types_label"] = " + ".join(plan["meal_types_list"]) if len(plan["meal_types_list"]) > 1 else "" plan["targets_label"] = "" if targets: bits = [] @@ -761,12 +764,16 @@ def create_app() -> Flask: plan["preference_prompt"] = preference[:1000] targets_body = body.get("targets") if isinstance(body.get("targets"), dict) else None exclusions_body = body.get("exclusions") if isinstance(body.get("exclusions"), list) else None + meal_types_body = body.get("meal_types") if isinstance(body.get("meal_types"), list) else None if targets_body is not None or exclusions_body is not None: db.set_plan_targets_and_exclusions( plan["id"], targets=targets_body, exclusions=exclusions_body, ) + if meal_types_body is not None: + db.set_plan_meal_types(plan["id"], meal_types_body) + if targets_body is not None or exclusions_body is not None or meal_types_body is not None: # Re-fetch so the local plan dict has the persisted values plan = db.get_or_create_plan(hid, this_monday) @@ -834,7 +841,8 @@ def create_app() -> Flask: # the household's collective average. picker_profiles = db.household_picker_profiles(hid, lookback_days=365) - # Numeric daily targets + allergen exclusions parsed from the persisted plan row + # Numeric daily targets + allergen exclusions + meal_types parsed + # from the persisted plan row plan_targets = plan.get("daily_targets_json") if isinstance(plan_targets, str): try: @@ -847,6 +855,12 @@ def create_app() -> Flask: plan_exclusions = _json_loads(plan_exclusions) except Exception: plan_exclusions = None + plan_meal_types = plan.get("meal_types_json") + if isinstance(plan_meal_types, str): + try: + plan_meal_types = _json_loads(plan_meal_types) + except Exception: + plan_meal_types = None try: slots = forge.generate_plan( @@ -856,6 +870,7 @@ def create_app() -> Flask: picker_profiles=picker_profiles, daily_targets=plan_targets if isinstance(plan_targets, dict) else None, exclusions=plan_exclusions if isinstance(plan_exclusions, list) else None, + meal_types=plan_meal_types if isinstance(plan_meal_types, list) else None, ) except ForgeError as e: return jsonify({"error": "forge_failed", "detail": str(e)}), 502 @@ -909,12 +924,16 @@ def create_app() -> Flask: plan["preference_prompt"] = preference[:1000] targets_body = body.get("targets") if isinstance(body.get("targets"), dict) else None exclusions_body = body.get("exclusions") if isinstance(body.get("exclusions"), list) else None + meal_types_body = body.get("meal_types") if isinstance(body.get("meal_types"), list) else None if targets_body is not None or exclusions_body is not None: db.set_plan_targets_and_exclusions( plan["id"], targets=targets_body, exclusions=exclusions_body, ) + if meal_types_body is not None: + db.set_plan_meal_types(plan["id"], meal_types_body) + if targets_body is not None or exclusions_body is not None or meal_types_body is not None: plan = db.get_or_create_plan(hid, this_monday) # Now fall through to the same logic as generate @@ -942,7 +961,7 @@ def create_app() -> Flask: # the household's collective average. picker_profiles = db.household_picker_profiles(hid, lookback_days=365) - # Persisted numeric targets + allergen exclusions for this re-roll + # Persisted numeric targets + allergen exclusions + meal_types for re-roll plan_targets = plan.get("daily_targets_json") if isinstance(plan_targets, str): try: @@ -955,6 +974,12 @@ def create_app() -> Flask: plan_exclusions = _json_loads(plan_exclusions) except Exception: plan_exclusions = None + plan_meal_types = plan.get("meal_types_json") + if isinstance(plan_meal_types, str): + try: + plan_meal_types = _json_loads(plan_meal_types) + except Exception: + plan_meal_types = None try: slots = forge.generate_plan( @@ -964,6 +989,7 @@ def create_app() -> Flask: picker_profiles=picker_profiles, daily_targets=plan_targets if isinstance(plan_targets, dict) else None, exclusions=plan_exclusions if isinstance(plan_exclusions, list) else None, + meal_types=plan_meal_types if isinstance(plan_meal_types, list) else None, ) except ForgeError as e: return jsonify({"error": "forge_failed", "detail": str(e)}), 502 diff --git a/cauldron/templates/consolidate.html b/cauldron/templates/consolidate.html index e143ccc..8669d9b 100644 --- a/cauldron/templates/consolidate.html +++ b/cauldron/templates/consolidate.html @@ -67,7 +67,7 @@

foods consolidate

scan your mealie household foods, cluster lookalikes by similarity, - let sonnet pick the survivor + the rest become aliases on it. mealie + let sage pick the survivor + the rest become aliases on it. mealie rewrites every recipe ingredient to point at the canonical row.
@@ -83,7 +83,7 @@

no run yet. kick one off?

- walks your household catalog, clusters by name similarity, asks sonnet for the survivor of each cluster. + walks your household catalog, clusters by name similarity, asks sage for the survivor of each cluster. first run might take 5-15 min depending on cluster count.

diff --git a/cauldron/templates/dedupe_recipes.html b/cauldron/templates/dedupe_recipes.html index 8917280..9ff2b53 100644 --- a/cauldron/templates/dedupe_recipes.html +++ b/cauldron/templates/dedupe_recipes.html @@ -60,7 +60,7 @@
// dedupe ยท find duplicate recipes

recipe dedupe

- scan your household recipes for duplicates by name similarity. sonnet + scan your household recipes for duplicates by name similarity. sage looks at ingredients + step counts + source URLs to decide whether similar-named recipes are actually the same dish. you confirm per cluster โ€” DELETE in Mealie is permanent. @@ -77,7 +77,7 @@