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:
parent
eed7f94c25
commit
820d65171b
4 changed files with 167 additions and 5 deletions
|
|
@ -399,6 +399,15 @@ MIGRATIONS = [
|
||||||
FOREIGN KEY (job_id) REFERENCES cauldron_recipe_dedupe_jobs(id) ON DELETE CASCADE
|
FOREIGN KEY (job_id) REFERENCES cauldron_recipe_dedupe_jobs(id) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) 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())
|
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:
|
def lock_plan(self, plan_id: int, *, sub: str, reason: str = "user") -> dict:
|
||||||
"""Lock a plan if not already locked. Returns updated plan dict."""
|
"""Lock a plan if not already locked. Returns updated plan dict."""
|
||||||
with self.conn() as c, c.cursor() as cur:
|
with self.conn() as c, c.cursor() as cur:
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ class Forge:
|
||||||
recipes: list[dict],
|
recipes: list[dict],
|
||||||
slots: int = 7,
|
slots: int = 7,
|
||||||
week_start: str,
|
week_start: str,
|
||||||
|
preference: str | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Ask Sonnet for a {slots}-day plan. Returns a list of slot dicts
|
"""Ask Sonnet for a {slots}-day plan. Returns a list of slot dicts
|
||||||
|
|
@ -109,6 +110,7 @@ class Forge:
|
||||||
|
|
||||||
prompt = self._build_plan_prompt(
|
prompt = self._build_plan_prompt(
|
||||||
picks=picks, recipes=recipes, slots=slots, week_start=week_start,
|
picks=picks, recipes=recipes, slots=slots, week_start=week_start,
|
||||||
|
preference=preference,
|
||||||
)
|
)
|
||||||
result = self.run(prompt, model=model or "sonnet")
|
result = self.run(prompt, model=model or "sonnet")
|
||||||
parsed = _extract_plan_slots(result)
|
parsed = _extract_plan_slots(result)
|
||||||
|
|
@ -161,7 +163,7 @@ class Forge:
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@staticmethod
|
@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 = []
|
pool_lines = []
|
||||||
for r in recipes:
|
for r in recipes:
|
||||||
slug = r.get("slug") or ""
|
slug = r.get("slug") or ""
|
||||||
|
|
@ -194,13 +196,29 @@ class Forge:
|
||||||
picks_block = "\n".join(pick_lines) if pick_lines else "(none)"
|
picks_block = "\n".join(pick_lines) if pick_lines else "(none)"
|
||||||
pool_block = "\n".join(pool_lines)
|
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 (
|
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 dinner plan "
|
||||||
f"for the week of {week_start}.\n\n"
|
f"for the week of {week_start}.\n\n"
|
||||||
f"POOL (all available recipes):\n{pool_block}\n\n"
|
f"POOL (all available recipes):\n{pool_block}\n\n"
|
||||||
f"PICKS (recipes the family pre-selected — every pick MUST appear "
|
f"PICKS (recipes the family pre-selected — every pick MUST appear "
|
||||||
f"if pool size >= {slots}; no repeats unless pool < {slots}):\n"
|
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: "
|
"Output JSON ONLY, no prose: "
|
||||||
'{"slots": [{"day": "monday", "recipe_slug": "...", '
|
'{"slots": [{"day": "monday", "recipe_slug": "...", '
|
||||||
'"picker_subs": [...] or [], "reason": "..."}, ...]}\n\n'
|
'"picker_subs": [...] or [], "reason": "..."}, ...]}\n\n'
|
||||||
|
|
@ -208,7 +226,8 @@ class Forge:
|
||||||
f"- Use exactly {slots} recipes\n"
|
f"- Use exactly {slots} recipes\n"
|
||||||
"- Distribute picks evenly across the week — don't bunch them\n"
|
"- Distribute picks evenly across the week — don't bunch them\n"
|
||||||
"- \"reason\" is a one-line user-facing rationale "
|
"- \"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 "
|
"- \"picker_subs\" is the array of authentik_sub strings of family "
|
||||||
"members who picked this recipe (empty list if AI-chosen)\n"
|
"members who picked this recipe (empty list if AI-chosen)\n"
|
||||||
"- Day order: monday..sunday\n"
|
"- Day order: monday..sunday\n"
|
||||||
|
|
|
||||||
|
|
@ -653,6 +653,15 @@ def create_app() -> Flask:
|
||||||
"plan": _plan_payload(plan),
|
"plan": _plan_payload(plan),
|
||||||
}), 409
|
}), 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)
|
# Pull picks (with picker_subs) + recipe pool (slug+name+tags only)
|
||||||
picks = db.list_household_picks_with_pickers(hid)
|
picks = db.list_household_picks_with_pickers(hid)
|
||||||
rows = db.list_indexed_recipes(hid, limit=2000, offset=0)
|
rows = db.list_indexed_recipes(hid, limit=2000, offset=0)
|
||||||
|
|
@ -676,6 +685,7 @@ def create_app() -> Flask:
|
||||||
slots = forge.generate_plan(
|
slots = forge.generate_plan(
|
||||||
picks=picks, recipes=recipes,
|
picks=picks, recipes=recipes,
|
||||||
slots=7, week_start=this_monday.isoformat(),
|
slots=7, week_start=this_monday.isoformat(),
|
||||||
|
preference=plan.get("preference_prompt"),
|
||||||
)
|
)
|
||||||
except ForgeError as e:
|
except ForgeError as e:
|
||||||
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
||||||
|
|
@ -719,6 +729,14 @@ def create_app() -> Flask:
|
||||||
db.delete_plan_slots(plan["id"])
|
db.delete_plan_slots(plan["id"])
|
||||||
db.clear_plan_generated(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
|
# Now fall through to the same logic as generate
|
||||||
picks = db.list_household_picks_with_pickers(hid)
|
picks = db.list_household_picks_with_pickers(hid)
|
||||||
rows = db.list_indexed_recipes(hid, limit=2000, offset=0)
|
rows = db.list_indexed_recipes(hid, limit=2000, offset=0)
|
||||||
|
|
@ -742,6 +760,7 @@ def create_app() -> Flask:
|
||||||
slots = forge.generate_plan(
|
slots = forge.generate_plan(
|
||||||
picks=picks, recipes=recipes,
|
picks=picks, recipes=recipes,
|
||||||
slots=7, week_start=this_monday.isoformat(),
|
slots=7, week_start=this_monday.isoformat(),
|
||||||
|
preference=plan.get("preference_prompt"),
|
||||||
)
|
)
|
||||||
except ForgeError as e:
|
except ForgeError as e:
|
||||||
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,68 @@
|
||||||
font-size: 11px; letter-spacing: .15em; text-transform: uppercase;
|
font-size: 11px; letter-spacing: .15em; text-transform: uppercase;
|
||||||
margin-top: 4px;
|
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>
|
</style>
|
||||||
|
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
|
|
@ -115,12 +177,34 @@
|
||||||
|
|
||||||
{% if plan.locked_at %}
|
{% if plan.locked_at %}
|
||||||
<p>this week's plan is locked. it'll archive Sunday night and a fresh week opens Monday.</p>
|
<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 %}
|
{% 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>
|
<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 & 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>
|
<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>
|
<div class="gen-meta" id="gen-meta">sonnet · ~30s</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>plan's set.</p>
|
<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">
|
<div class="btn-row">
|
||||||
<button class="btn btn-purple" type="button" onclick="lockPlan(this)">🔒 lock this week</button>
|
<button class="btn btn-purple" type="button" onclick="lockPlan(this)">🔒 lock this week</button>
|
||||||
{% if plan.generated_by_sub == current_user_sub %}
|
{% 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) {
|
async function generatePlan(btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '🪄 summoning…';
|
btn.innerHTML = '🪄 summoning…';
|
||||||
const meta = document.getElementById('gen-meta');
|
const meta = document.getElementById('gen-meta');
|
||||||
if (meta) meta.textContent = 'sonnet building plan — hold tight';
|
if (meta) meta.textContent = 'sonnet building plan — hold tight';
|
||||||
try {
|
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) {
|
if (r.status === 409) {
|
||||||
// Someone beat us to it — just reload to see their plan
|
// Someone beat us to it — just reload to see their plan
|
||||||
location.reload();
|
location.reload();
|
||||||
|
|
@ -193,10 +291,16 @@ async function generatePlan(btn) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rerollPlan(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;
|
if (!confirm('re-roll the week? slots will be replaced.')) return;
|
||||||
btn.disabled = true; btn.textContent = '…';
|
btn.disabled = true; btn.textContent = '…';
|
||||||
try {
|
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) {
|
if (!r.ok) {
|
||||||
const data = await r.json().catch(() => ({}));
|
const data = await r.json().catch(() => ({}));
|
||||||
throw new Error(data.detail || data.error || r.status);
|
throw new Error(data.detail || data.error || r.status);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue