diff --git a/cauldron/db.py b/cauldron/db.py index a2f19ae..3a38712 100644 --- a/cauldron/db.py +++ b/cauldron/db.py @@ -399,6 +399,15 @@ MIGRATIONS = [ FOREIGN KEY (job_id) REFERENCES cauldron_recipe_dedupe_jobs(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """, + # 023 — Per-week diet/vibe preference on the plan generator. Free-form + # text the user types before generating ("high protein low carb", + # "carb load this week", "light recovery, no fish") that biases + # Sonnet's slot picks. Picks still take precedence — preference only + # influences AI-chosen slots when the pool is bigger than picks. + """ + ALTER TABLE cauldron_meal_plans + ADD COLUMN IF NOT EXISTS preference_prompt VARCHAR(1000) + """, ] @@ -595,6 +604,17 @@ class DB: ) return dict(cur.fetchone()) + def set_plan_preference(self, plan_id: int, preference: str | None) -> None: + """Persist the per-week diet/vibe preference. Empty/whitespace-only + strings are stored as NULL so the prompt only includes the section + when the user actually filled it in.""" + clean = (preference or "").strip()[:1000] or None + with self.conn() as c, c.cursor() as cur: + cur.execute( + "UPDATE cauldron_meal_plans SET preference_prompt=%s WHERE id=%s", + (clean, 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 3cb019f..c839e4c 100644 --- a/cauldron/forge.py +++ b/cauldron/forge.py @@ -76,6 +76,7 @@ class Forge: recipes: list[dict], slots: int = 7, week_start: str, + preference: str | None = None, model: str | None = None, ) -> list[dict]: """Ask Sonnet for a {slots}-day plan. Returns a list of slot dicts @@ -109,6 +110,7 @@ class Forge: prompt = self._build_plan_prompt( picks=picks, recipes=recipes, slots=slots, week_start=week_start, + preference=preference, ) result = self.run(prompt, model=model or "sonnet") parsed = _extract_plan_slots(result) @@ -161,7 +163,7 @@ class Forge: return out @staticmethod - def _build_plan_prompt(*, picks, recipes, slots, week_start) -> str: + def _build_plan_prompt(*, picks, recipes, slots, week_start, preference=None) -> str: pool_lines = [] for r in recipes: slug = r.get("slug") or "" @@ -194,13 +196,29 @@ class Forge: picks_block = "\n".join(pick_lines) if pick_lines else "(none)" pool_block = "\n".join(pool_lines) + pref_clean = (preference or "").strip() + pref_block = "" + if pref_clean: + pref_block = ( + f"\nHOUSEHOLD PREFERENCE FOR THIS WEEK:\n \"{pref_clean}\"\n\n" + "When the preference is set, BIAS your AI-chosen slots toward " + "recipes from the pool that match it. The preference may describe " + "diet (\"high protein, low carb\"), occasion (\"light meals, " + "recovery week\"), shopping constraints (\"no fish, out of " + "season\"), or vibe (\"carb load, training hard\"). The " + "preference does NOT override picks — every pick still appears. " + "It DOES change which other recipes from the pool you choose to " + "fill the remaining slots.\n" + ) + return ( f"You are a family meal planner. Build a {slots}-day dinner plan " 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_block}\n\n" + f"{picks_block}\n" + f"{pref_block}" "Output JSON ONLY, no prose: " '{"slots": [{"day": "monday", "recipe_slug": "...", ' '"picker_subs": [...] or [], "reason": "..."}, ...]}\n\n' @@ -208,7 +226,8 @@ class Forge: f"- Use exactly {slots} recipes\n" "- Distribute picks evenly across the week — don't bunch them\n" "- \"reason\" is a one-line user-facing rationale " - "(e.g., \"balances heavy and light meals\", \"honors abby's pick\")\n" + "(e.g., \"balances heavy and light meals\", \"honors abby's pick\", " + "\"high-protein lean — pairs with the gym week\")\n" "- \"picker_subs\" is the array of authentik_sub strings of family " "members who picked this recipe (empty list if AI-chosen)\n" "- Day order: monday..sunday\n" diff --git a/cauldron/server.py b/cauldron/server.py index ed264b7..964a91a 100644 --- a/cauldron/server.py +++ b/cauldron/server.py @@ -653,6 +653,15 @@ def create_app() -> Flask: "plan": _plan_payload(plan), }), 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. + 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] + # Pull picks (with picker_subs) + recipe pool (slug+name+tags only) picks = db.list_household_picks_with_pickers(hid) rows = db.list_indexed_recipes(hid, limit=2000, offset=0) @@ -676,6 +685,7 @@ def create_app() -> Flask: slots = forge.generate_plan( picks=picks, recipes=recipes, slots=7, week_start=this_monday.isoformat(), + preference=plan.get("preference_prompt"), ) except ForgeError as e: return jsonify({"error": "forge_failed", "detail": str(e)}), 502 @@ -719,6 +729,14 @@ 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. + 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] + # Now fall through to the same logic as generate picks = db.list_household_picks_with_pickers(hid) rows = db.list_indexed_recipes(hid, limit=2000, offset=0) @@ -742,6 +760,7 @@ def create_app() -> Flask: slots = forge.generate_plan( picks=picks, recipes=recipes, slots=7, week_start=this_monday.isoformat(), + preference=plan.get("preference_prompt"), ) 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 a44aa18..6ca7fd8 100644 --- a/cauldron/templates/plan.html +++ b/cauldron/templates/plan.html @@ -77,6 +77,68 @@ font-size: 11px; letter-spacing: .15em; text-transform: uppercase; margin-top: 4px; } + + .pref-block { + margin: 14px 0; + padding: 14px; + background: var(--bg-2); + border: 1px solid var(--line); + border-radius: 8px; + } + .pref-label { + display: block; + color: var(--purple); font-family: var(--mono); + font-size: 11px; letter-spacing: .2em; text-transform: uppercase; + margin-bottom: 6px; + } + .pref-input { + width: 100%; + padding: 10px 12px; + background: var(--surface); + color: var(--bone); + border: 1px solid var(--line); + border-radius: 6px; + font-family: var(--sans); font-size: .95em; + line-height: 1.4; + resize: vertical; + min-height: 56px; + } + .pref-input:focus { outline: none; border-color: var(--purple-bright); } + .pref-presets { + display: flex; flex-wrap: wrap; gap: 6px; + margin-top: 10px; + } + .pref-chip { + padding: 6px 12px; + background: var(--bg); + color: var(--bone-dim); + border: 1px solid var(--line); + border-radius: 999px; + font-family: var(--sans); font-size: .85em; + cursor: pointer; + min-height: 32px; + transition: all .12s ease; + } + .pref-chip:hover { + background: var(--purple-deep); + border-color: var(--purple-dim); + color: var(--bone); + } + .pref-chip:active { transform: scale(.97); } + + .pref-readout { + color: var(--bone-dim); + font-family: var(--serif); + font-size: .95em; + margin: 8px 0; + padding: 8px 12px; + border-left: 2px solid var(--purple-dim); + background: rgba(45, 29, 74, .15); + } + .pref-readout em { + color: var(--bone); + font-style: italic; + }
this week's plan is locked. it'll archive Sunday night and a fresh week opens Monday.
+ {% if plan.preference_prompt %} +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.
+ +plan's set.
+ {% if plan.preference_prompt %} +