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
|
||||
) 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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 & 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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue