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
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue