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:
parent
4db447edad
commit
07dab10c4b
4 changed files with 371 additions and 9 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue