plan UI: numeric daily macro targets + allergen exclusions

The next-tier of structured planning constraints, building on the
free-form preference textarea. Now the household can set hard rules
like '2200 cal/day, 150g protein, no dairy or shellfish' and the
planner enforces them.

Schema (migrations 026 + 027):
- daily_targets_json on cauldron_meal_plans: {calories?, protein_g?,
  carbs_g?, fat_g?} per-day budget. Sonnet sums per-recipe macros
  across the 7-day plan and aims for ±15% of the weekly total.
- exclusions_json on cauldron_meal_plans: list of contains.* keys
  (subset of {dairy, gluten, nuts, peanuts, eggs, shellfish, fish,
  soy, sesame, pork}). Hard filter on AI-chosen slots; picks still
  apply but get flagged with the conflict in their reason field.

DB:
- set_plan_targets_and_exclusions normalizes inputs (drops zeros,
  validates against allowed enum values, lowercases).
- The plan_view path decodes the JSON columns server-side and adds
  display labels (`targets_label`, `exclusions_label`) and the parsed
  dict/list (`targets_dict`, `exclusions_list`) for the template.

Forge:
- generate_plan accepts daily_targets dict + exclusions list.
- New DAILY MACRO TARGETS prompt block — instructs Sonnet to sum
  per-serving macros across all 7 slots and land within ±15% of
  weekly totals; tradeoff slots when needed.
- New STRICT EXCLUSIONS prompt block — recipes whose has: list
  intersects exclusions are forbidden in AI-chosen slots. Picks that
  conflict still appear (explicit user choice) but get flagged in
  the slot reason ("contains dairy — household pick").

Server:
- /api/plan/generate accepts {preference, targets, exclusions}.
  Persists all three before kicking off Sonnet so re-rolls reuse them.
- /api/plan/regenerate same — empty body reuses persisted constraints.

UI (/plan):
- New <details> section "numeric targets + allergen exclusions
  (optional)" tucked under the existing vibe textarea + presets.
- 4 numeric inputs (cal/protein/carbs/fat per day) with quick-set
  preset chips: balanced 2200 / protein lean 2400 / carb load 2600 /
  cut 1800 / clear.
- 10 allergen checkbox chips with red-pill styling when checked
  (uses CSS :has(input:checked) — modern browsers).
- Hydration: persisted targets pre-fill the inputs, persisted
  exclusions pre-check the boxes.
- After generation, three readouts above the action buttons show
  active vibe / macros / exclusions.

Cobb's exact ask "2200 cal/day, protein and carb balanced, no dairy"
now maps to: targets={calories: 2200, protein_g: 150, carbs_g: 250}
+ exclusions=[dairy] + free-form preference (optional). All persist.
Re-rolls iterate within those constraints automatically.
This commit is contained in:
Kayos 2026-04-30 20:31:28 -07:00
parent 4db447edad
commit 07dab10c4b
4 changed files with 371 additions and 9 deletions

View file

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

View file

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

View file

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

View file

@ -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);
}
</style>
<div class="page-head">
@ -180,6 +247,12 @@
{% if plan.preference_prompt %}
<div class="pref-readout">vibe this week: <em>"{{ plan.preference_prompt }}"</em></div>
{% endif %}
{% if plan.targets_label %}
<div class="pref-readout">macros: <em>{{ plan.targets_label }}</em></div>
{% endif %}
{% if plan.exclusions_label %}
<div class="pref-readout">excluding: <em>{{ plan.exclusions_label }}</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>
@ -196,6 +269,56 @@
<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>
<details class="pref-advanced">
<summary>numeric targets + allergen exclusions (optional)</summary>
<div class="pref-advanced-body">
<div class="pref-targets">
<label class="pref-label">daily macro targets</label>
<div class="target-row">
<span class="target-cell">
<input type="number" id="target-cal" class="target-input" placeholder="2200" min="0" max="10000" step="50" value="{{ plan.targets_dict.calories or '' }}">
<span class="target-unit">cal/day</span>
</span>
<span class="target-cell">
<input type="number" id="target-pro" class="target-input" placeholder="150" min="0" max="500" step="5" value="{{ plan.targets_dict.protein_g or '' }}">
<span class="target-unit">g protein/day</span>
</span>
<span class="target-cell">
<input type="number" id="target-carb" class="target-input" placeholder="250" min="0" max="800" step="5" value="{{ plan.targets_dict.carbs_g or '' }}">
<span class="target-unit">g carbs/day</span>
</span>
<span class="target-cell">
<input type="number" id="target-fat" class="target-input" placeholder="80" min="0" max="300" step="5" value="{{ plan.targets_dict.fat_g or '' }}">
<span class="target-unit">g fat/day</span>
</span>
</div>
<div class="target-presets">
<button type="button" class="pref-chip" onclick="setTargets(2200,150,250,80)">balanced 2200</button>
<button type="button" class="pref-chip" onclick="setTargets(2400,200,200,80)">protein lean 2400</button>
<button type="button" class="pref-chip" onclick="setTargets(2600,140,350,70)">carb load 2600</button>
<button type="button" class="pref-chip" onclick="setTargets(1800,140,140,80)">cut 1800</button>
<button type="button" class="pref-chip" onclick="setTargets(0,0,0,0)">clear</button>
</div>
</div>
<div class="pref-exclusions">
<label class="pref-label">strict exclusions (allergen / dietary)</label>
<div class="excl-row">
<label class="excl-chip"><input type="checkbox" class="excl-check" value="dairy"> dairy</label>
<label class="excl-chip"><input type="checkbox" class="excl-check" value="gluten"> gluten</label>
<label class="excl-chip"><input type="checkbox" class="excl-check" value="nuts"> tree nuts</label>
<label class="excl-chip"><input type="checkbox" class="excl-check" value="peanuts"> peanuts</label>
<label class="excl-chip"><input type="checkbox" class="excl-check" value="eggs"> eggs</label>
<label class="excl-chip"><input type="checkbox" class="excl-check" value="shellfish"> shellfish</label>
<label class="excl-chip"><input type="checkbox" class="excl-check" value="fish"> fish</label>
<label class="excl-chip"><input type="checkbox" class="excl-check" value="soy"> soy</label>
<label class="excl-chip"><input type="checkbox" class="excl-check" value="sesame"> sesame</label>
<label class="excl-chip"><input type="checkbox" class="excl-check" value="pork"> pork</label>
</div>
</div>
</div>
</details>
</div>
<button class="gen-cta" type="button" id="gen-btn" onclick="generatePlan(this)">🪄 generate this week's plan</button>
@ -205,6 +328,12 @@
{% if plan.preference_prompt %}
<div class="pref-readout">vibe this week: <em>"{{ plan.preference_prompt }}"</em></div>
{% endif %}
{% if plan.targets_label %}
<div class="pref-readout">macros: <em>{{ plan.targets_label }}</em></div>
{% endif %}
{% if plan.exclusions_label %}
<div class="pref-readout">excluding: <em>{{ plan.exclusions_label }}</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 %}
@ -257,11 +386,50 @@ function setPref(text) {
if (el) { el.value = text; el.focus(); }
}
// Pre-check exclusion checkboxes from server-rendered persisted state
(function hydrateExclusions(){
const persisted = {{ plan.exclusions_list | tojson if plan.exclusions_list else '[]' }};
if (!Array.isArray(persisted) || !persisted.length) return;
const set = new Set(persisted);
document.querySelectorAll('.excl-check').forEach(el => {
if (set.has(el.value)) el.checked = true;
});
})();
function readPref() {
const el = document.getElementById('pref-input');
return el ? el.value.trim() : '';
}
function setTargets(cal, pro, carb, fat) {
const map = {'target-cal': cal, 'target-pro': pro, 'target-carb': carb, 'target-fat': fat};
for (const id in map) {
const el = document.getElementById(id);
if (!el) continue;
el.value = map[id] > 0 ? map[id] : '';
}
}
function readTargets() {
const t = {};
for (const [id, key] of [
['target-cal','calories'], ['target-pro','protein_g'],
['target-carb','carbs_g'], ['target-fat','fat_g']
]) {
const el = document.getElementById(id);
if (!el) continue;
const n = parseInt(el.value, 10);
if (Number.isFinite(n) && n > 0) t[key] = n;
}
return t;
}
function readExclusions() {
const out = [];
document.querySelectorAll('.excl-check:checked').forEach(el => out.push(el.value));
return out;
}
async function generatePlan(btn) {
btn.disabled = true;
btn.innerHTML = '🪄 summoning…';
@ -271,7 +439,11 @@ async function generatePlan(btn) {
const r = await fetch('/api/plan/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preference: readPref() }),
body: JSON.stringify({
preference: readPref(),
targets: readTargets(),
exclusions: readExclusions(),
}),
});
if (r.status === 409) {
// Someone beat us to it — just reload to see their plan
@ -291,15 +463,15 @@ 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.
// Re-roll reuses persisted preference + targets + exclusions unless
// the user opens those fields (currently only visible pre-generate).
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}), // empty body = reuse persisted preference
body: JSON.stringify({}), // empty body = reuse persisted constraints
});
if (!r.ok) {
const data = await r.json().catch(() => ({}));