plan generator: per-week diet/vibe preference + preset chips

The killer-feature one Cobb wanted on /plan — a free-form textarea
above the generate button so the household can bias the week's plan
toward "high protein low carb" / "carb load" / "light recovery" /
etc. Plus 8 preset chips that one-click-fill the textarea with common
templates.

Schema:
- Migration 023 adds preference_prompt VARCHAR(1000) to
  cauldron_meal_plans (idempotent ALTER ADD COLUMN IF NOT EXISTS)
- Persisted per-week, per-household. Survives re-rolls so the same
  preference applies unless explicitly changed.

Forge:
- generate_plan accepts optional preference kwarg
- _build_plan_prompt splices in HOUSEHOLD PREFERENCE block when set:
  "BIAS your AI-chosen slots toward recipes from the pool that match
  it... preference does NOT override picks — every pick still appears.
  It DOES change which other recipes you choose to fill the rest."
- Examples in the prompt cover diet, occasion, shopping constraints,
  vibe categories so Sonnet has full coverage of what users might mean.

Server:
- POST /api/plan/generate accepts {preference: "..."} body, persists
  to plan row before kicking off Sonnet
- POST /api/plan/regenerate (re-roll): if body has preference, persist
  + use it; else reuse the persisted preference. Lets re-rolls iterate
  on the same vibe.

UI (/plan):
- Textarea visible when no slots yet, with maxlength=1000 and a
  placeholder showing example prompts
- 8 preset chips: 🥩 high protein / 🍞 carb load / 🥗 light & lean /
  🌱 vegetarian / 🍲 comfort food /  quick / 💧 recovery / 🌍 global —
  click fills the textarea with a curated phrase Cobb can edit
- After generation: read-only "vibe this week:" callout above the
  action buttons so the household can see what was used
- Locked plan also shows the preference (audit / nostalgia)
- Generate + re-roll JS now sends JSON body with preference

Cost: ~30 extra tokens in the prompt when preference is set, ~0 when
unset. No additional Sonnet calls.
This commit is contained in:
Kayos 2026-04-30 20:00:01 -07:00
parent eed7f94c25
commit 820d65171b
4 changed files with 167 additions and 5 deletions

View file

@ -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:

View file

@ -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"

View file

@ -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

View file

@ -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;
}
</style>
<div class="page-head">
@ -115,12 +177,34 @@
{% if plan.locked_at %}
<p>this week's plan is locked. it'll archive Sunday night and a fresh week opens Monday.</p>
{% if plan.preference_prompt %}
<div class="pref-readout">vibe this week: <em>"{{ plan.preference_prompt }}"</em></div>
{% endif %}
{% elif not plan.slots %}
<p>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.</p>
<div class="pref-block">
<label class="pref-label" for="pref-input">this week's vibe (optional)</label>
<textarea id="pref-input" class="pref-input" rows="2" maxlength="1000" placeholder="e.g. high protein low carb · carb load · light recovery week · no fish · vegetarian-leaning · spicy comfort food">{{ plan.preference_prompt or '' }}</textarea>
<div class="pref-presets">
<button type="button" class="pref-chip" onclick="setPref('high protein, low carb — lean and gym-friendly')">🥩 high protein</button>
<button type="button" class="pref-chip" onclick="setPref('carb load this week, training hard')">🍞 carb load</button>
<button type="button" class="pref-chip" onclick="setPref('light and lean, simple weeknight meals')">🥗 light &amp; lean</button>
<button type="button" class="pref-chip" onclick="setPref('vegetarian-leaning, more produce-forward')">🌱 vegetarian</button>
<button type="button" class="pref-chip" onclick="setPref('comfort food week, hearty and warming')">🍲 comfort food</button>
<button type="button" class="pref-chip" onclick="setPref('quick and easy — 30 minutes or less per meal')">⚡ quick</button>
<button type="button" class="pref-chip" onclick="setPref('recovery week — gentle, easily digestible meals')">💧 recovery</button>
<button type="button" class="pref-chip" onclick="setPref('global flavors — bias toward varied cuisines this week')">🌍 global</button>
</div>
</div>
<button class="gen-cta" type="button" id="gen-btn" onclick="generatePlan(this)">🪄 generate this week's plan</button>
<div class="gen-meta" id="gen-meta">sonnet · ~30s</div>
{% else %}
<p>plan's set.</p>
{% if plan.preference_prompt %}
<div class="pref-readout">vibe this week: <em>"{{ plan.preference_prompt }}"</em></div>
{% endif %}
<div class="btn-row">
<button class="btn btn-purple" type="button" onclick="lockPlan(this)">🔒 lock this week</button>
{% if plan.generated_by_sub == current_user_sub %}
@ -168,13 +252,27 @@ async function lockPlan(btn) {
}
}
function setPref(text) {
const el = document.getElementById('pref-input');
if (el) { el.value = text; el.focus(); }
}
function readPref() {
const el = document.getElementById('pref-input');
return el ? el.value.trim() : '';
}
async function generatePlan(btn) {
btn.disabled = true;
btn.innerHTML = '🪄 summoning…';
const meta = document.getElementById('gen-meta');
if (meta) meta.textContent = 'sonnet building plan — hold tight';
try {
const r = await fetch('/api/plan/generate', { method: 'POST' });
const r = await fetch('/api/plan/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preference: readPref() }),
});
if (r.status === 409) {
// Someone beat us to it — just reload to see their plan
location.reload();
@ -193,10 +291,16 @@ async function generatePlan(btn) {
}
async function rerollPlan(btn) {
// Re-roll keeps the prior preference unless the user opens the
// textarea (only visible pre-generate). For now just reuse persisted.
if (!confirm('re-roll the week? slots will be replaced.')) return;
btn.disabled = true; btn.textContent = '…';
try {
const r = await fetch('/api/plan/regenerate', { method: 'POST' });
const r = await fetch('/api/plan/regenerate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}), // empty body = reuse persisted preference
});
if (!r.ok) {
const data = await r.json().catch(() => ({}));
throw new Error(data.detail || data.error || r.status);