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: