diff --git a/cauldron/db.py b/cauldron/db.py index bfa329d..5a1912a 100644 --- a/cauldron/db.py +++ b/cauldron/db.py @@ -454,6 +454,22 @@ MIGRATIONS = [ FOREIGN KEY (started_by_sub) REFERENCES cauldron_users(authentik_sub) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """, + # 026 — Per-week structured planning constraints. daily_targets_json + # holds the macro budget the household wants to hit per day + # ({calories, protein_g, carbs_g, fat_g} — Sonnet sums per slot and + # divides by 7). exclusions_json is a list of contains.* allergen + # keys to exclude entirely from the AI-chosen slots + # (["dairy", "shellfish"]). Both nullable; the existing free-form + # preference_prompt is kept for vibe-only weeks where macros aren't + # explicit. + """ + ALTER TABLE cauldron_meal_plans + ADD COLUMN IF NOT EXISTS daily_targets_json JSON + """, + """ + ALTER TABLE cauldron_meal_plans + ADD COLUMN IF NOT EXISTS exclusions_json JSON + """, ] @@ -661,6 +677,52 @@ class DB: (clean, plan_id), ) + def set_plan_targets_and_exclusions( + self, + plan_id: int, + *, + targets: dict | None, + exclusions: list | None, + ) -> None: + """Persist the per-week macro targets ({calories, protein_g, carbs_g, + fat_g} — null fields mean 'no target') and allergen exclusion list + (subset of contains.* keys). Either or both can be None.""" + import json as _json + # Normalize: drop empty/zero values so the prompt only renders the + # ones that matter + clean_targets: dict | None = None + if isinstance(targets, dict): + ct = {} + for k in ("calories", "protein_g", "carbs_g", "fat_g"): + v = targets.get(k) + try: + n = int(v) if v not in (None, "", 0) else 0 + if n > 0: + ct[k] = n + except (TypeError, ValueError): + continue + clean_targets = ct or None + clean_exclusions: list | None = None + if isinstance(exclusions, list): + allowed = {"dairy", "gluten", "nuts", "peanuts", "eggs", + "shellfish", "fish", "soy", "sesame", "pork"} + ce = sorted({ + str(x).strip().lower() for x in exclusions + if isinstance(x, str) and x.strip().lower() in allowed + }) + clean_exclusions = ce or None + with self.conn() as c, c.cursor() as cur: + cur.execute( + """UPDATE cauldron_meal_plans + SET daily_targets_json=%s, exclusions_json=%s + WHERE id=%s""", + ( + _json.dumps(clean_targets) if clean_targets else None, + _json.dumps(clean_exclusions) if clean_exclusions else None, + plan_id, + ), + ) + def lock_plan(self, plan_id: int, *, sub: str, reason: str = "user") -> dict: """Lock a plan if not already locked. Returns updated plan dict.""" with self.conn() as c, c.cursor() as cur: diff --git a/cauldron/forge.py b/cauldron/forge.py index 60df5e3..88394de 100644 --- a/cauldron/forge.py +++ b/cauldron/forge.py @@ -78,6 +78,8 @@ class Forge: week_start: str, preference: str | None = None, picker_profiles: dict | None = None, + daily_targets: dict | None = None, + exclusions: list | None = None, model: str | None = None, ) -> list[dict]: """Ask Sonnet for a {slots}-day plan. Returns a list of slot dicts @@ -112,6 +114,7 @@ class Forge: 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, ) result = self.run(prompt, model=model or "sonnet") parsed = _extract_plan_slots(result) @@ -165,7 +168,8 @@ class Forge: @staticmethod def _build_plan_prompt(*, picks, recipes, slots, week_start, preference=None, - picker_profiles=None) -> str: + picker_profiles=None, daily_targets=None, + exclusions=None) -> str: pool_lines = [] for r in recipes: slug = r.get("slug") or "" @@ -299,6 +303,52 @@ class Forge: "Picks still take precedence over profile bias.\n" ) + # Numeric daily targets — sum up over the 7-day plan and aim + # for the cumulative budget. Per-recipe macros come from + # cauldron_recipe_meta (Sonnet-estimated). + targets_block = "" + if isinstance(daily_targets, dict) and daily_targets: + target_lines = [] + for k, label in (("calories", "cal"), ("protein_g", "g protein"), + ("carbs_g", "g carbs"), ("fat_g", "g fat")): + v = daily_targets.get(k) + try: + if v and int(v) > 0: + target_lines.append(f"{int(v)}{label}/day ({int(v) * slots}{label}/week)") + except (TypeError, ValueError): + continue + if target_lines: + targets_block = ( + "\nDAILY MACRO TARGETS (sum across the 7-day plan):\n - " + + "\n - ".join(target_lines) + "\n\n" + "Rule: build the plan so the SUM of per-serving macros across " + "all 7 slots lands within ±15% of the weekly totals shown above. " + "If recipe macros are null/missing, treat as average (use the " + "summary + name to estimate). Trade off slots if needed — push " + "high-protein dishes to make protein, push lighter dishes if " + "calories are running over. Picks STILL must appear; balance " + "with the AI-chosen slots.\n" + ) + + # Allergen / dietary exclusions — strict filter, not bias + excl_block = "" + if isinstance(exclusions, list) and exclusions: + excl_clean = sorted({ + str(x).strip().lower() for x in exclusions + if isinstance(x, str) and x.strip() + }) + if excl_clean: + excl_block = ( + "\nSTRICT EXCLUSIONS — recipes containing any of these MUST be " + f"avoided in AI-chosen slots:\n {', '.join(excl_clean)}\n\n" + "Each pool entry shows allergen flags as 'has:dairy,gluten,...'. " + "Do NOT pick a recipe whose has: list intersects the exclusions " + "above. If a HOUSEHOLD PICK violates the exclusion, include it " + "anyway (picks are explicit user choices) but flag the conflict " + "in that slot's reason field (e.g., \"contains dairy — " + "household pick\").\n" + ) + pref_clean = (preference or "").strip() pref_block = "" if pref_clean: @@ -323,6 +373,8 @@ class Forge: f"{picks_block}\n" f"{profile_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' diff --git a/cauldron/server.py b/cauldron/server.py index cf6b1f3..ce632dd 100644 --- a/cauldron/server.py +++ b/cauldron/server.py @@ -592,6 +592,29 @@ def create_app() -> Flask: db.enrich_plan_with_slots(plan) 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"): + v = plan.get(k) + if isinstance(v, str): + try: + plan[k] = _json_loads(v) + except Exception: + 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["targets_label"] = "" + if targets: + bits = [] + for k, suffix in (("calories", "cal"), ("protein_g", "g pro"), + ("carbs_g", "g carb"), ("fat_g", "g fat")): + v = targets.get(k) + if v: + bits.append(f"{v}{suffix}") + plan["targets_label"] = " · ".join(bits) + "/day" if bits else "" + plan["exclusions_label"] = ", ".join(exclusions) if exclusions else "" + plan["targets_dict"] = targets or {} + plan["exclusions_list"] = exclusions or [] + # Resolve display names for any subs we render — locked_by + every # picker referenced by any slot. One round-trip; small set. sub_display: dict[str, str] = _resolve_sub_displays(db, plan) @@ -661,13 +684,24 @@ def create_app() -> Flask: }), 409 # Persist the per-week preference (free-form: "high protein low - # carb", "carb load this week", etc.) before kicking off Sonnet - # so a re-roll uses the same preference unless explicitly changed. + # carb", "carb load this week", etc.) + numeric daily targets + + # allergen exclusions before kicking off Sonnet so a re-roll + # uses the same constraints unless explicitly changed. body = request.get_json(silent=True) or {} preference = (body.get("preference") or "").strip() if preference: db.set_plan_preference(plan["id"], preference) 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 + 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, + ) + # Re-fetch so the local plan dict has the persisted values + plan = db.get_or_create_plan(hid, this_monday) # Pull picks + recipe pool. The pool splices in: # 1. cauldron_recipe_meta (Sonnet-generated per-recipe attributes) @@ -733,12 +767,28 @@ 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 + plan_targets = plan.get("daily_targets_json") + if isinstance(plan_targets, str): + try: + plan_targets = _json_loads(plan_targets) + except Exception: + plan_targets = None + plan_exclusions = plan.get("exclusions_json") + if isinstance(plan_exclusions, str): + try: + plan_exclusions = _json_loads(plan_exclusions) + except Exception: + plan_exclusions = None + try: slots = forge.generate_plan( picks=picks, recipes=recipes, slots=7, week_start=this_monday.isoformat(), preference=plan.get("preference_prompt"), 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, ) except ForgeError as e: return jsonify({"error": "forge_failed", "detail": str(e)}), 502 @@ -782,13 +832,23 @@ def create_app() -> Flask: db.delete_plan_slots(plan["id"]) db.clear_plan_generated(plan["id"]) - # Re-roll honors preference: if the body sets one, persist + use. - # Otherwise reuse the persisted preference from the prior generate. + # Re-roll honors preference + targets + exclusions: if the body + # sets any, persist + use. Otherwise reuse the persisted values + # from the prior generate. body = request.get_json(silent=True) or {} preference = (body.get("preference") or "").strip() if preference: db.set_plan_preference(plan["id"], preference) 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 + 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, + ) + plan = db.get_or_create_plan(hid, this_monday) # Now fall through to the same logic as generate picks = db.list_household_picks_with_pickers(hid) @@ -815,12 +875,28 @@ 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 + plan_targets = plan.get("daily_targets_json") + if isinstance(plan_targets, str): + try: + plan_targets = _json_loads(plan_targets) + except Exception: + plan_targets = None + plan_exclusions = plan.get("exclusions_json") + if isinstance(plan_exclusions, str): + try: + plan_exclusions = _json_loads(plan_exclusions) + except Exception: + plan_exclusions = None + try: slots = forge.generate_plan( picks=picks, recipes=recipes, slots=7, week_start=this_monday.isoformat(), preference=plan.get("preference_prompt"), 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, ) except ForgeError as e: return jsonify({"error": "forge_failed", "detail": str(e)}), 502 diff --git a/cauldron/templates/plan.html b/cauldron/templates/plan.html index 6ca7fd8..8491dc3 100644 --- a/cauldron/templates/plan.html +++ b/cauldron/templates/plan.html @@ -139,6 +139,73 @@ color: var(--bone); font-style: italic; } + + .pref-advanced { + margin-top: 14px; + border-top: 1px solid var(--line); + padding-top: 10px; + } + .pref-advanced summary { + cursor: pointer; user-select: none; + color: var(--purple); font-family: var(--mono); + font-size: 11px; letter-spacing: .15em; text-transform: uppercase; + padding: 4px 0; + } + .pref-advanced summary:hover { color: var(--purple-bright); } + .pref-advanced-body { margin-top: 10px; } + + .target-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-top: 6px; + } + @media (min-width: 600px) { + .target-row { grid-template-columns: 1fr 1fr 1fr 1fr; } + } + .target-cell { + display: flex; + align-items: center; + gap: 6px; + } + .target-input { + width: 70px; + padding: 6px 8px; + background: var(--surface); + color: var(--bone); + border: 1px solid var(--line); + border-radius: 4px; + font-family: var(--mono); font-size: .9em; + text-align: right; + } + .target-input:focus { outline: none; border-color: var(--purple-bright); } + .target-unit { + color: var(--muted); font-family: var(--mono); + font-size: 11px; letter-spacing: .1em; + } + .target-presets, .excl-row { + display: flex; flex-wrap: wrap; gap: 6px; + margin-top: 8px; + } + + .pref-exclusions { margin-top: 14px; } + .excl-chip { + display: inline-flex; align-items: center; gap: 6px; + padding: 5px 10px; + background: var(--bg); + border: 1px solid var(--line); + border-radius: 999px; + font-family: var(--sans); font-size: .85em; + color: var(--bone-dim); + cursor: pointer; user-select: none; + min-height: 30px; + } + .excl-chip input { margin: 0; cursor: pointer; } + .excl-chip:has(input:checked) { + background: rgba(232, 96, 106, .12); + border-color: rgba(232, 96, 106, .3); + color: var(--crit); + }
no plan yet. summon claude to build one from the {{ pick_count }} pinned pick{{ '' if pick_count == 1 else 's' }} + the rest of the grimoire.
@@ -196,6 +269,56 @@