plan: cook history + per-serving macros + allergens + picker profiles

Tier-1 data additions for the planner — turning the AI from a title-
matching guesser into a structured-data consumer. ENRICH_VERSION bumped
2→3 so existing meta gets refreshed with the new fields on next walk.

(A) Cook history. db.household_recipe_history aggregates recipe slug
    → {last_planned_date, count_30d, count_long} from cauldron_meal_
    plan_slots over a 180-day window. The plan generator's pool prompt
    now renders each recipe with rotation context: "last:8w-ago 0×/30d
    1×/180d". New planner rule: ROTATION — demote recipes shown 2+
    times in 30d unless they're picks; never repeat the same slug
    within the 7-day plan. New planner rule: VARIETY — don't fill 5
    of 7 slots with the same primary_protein or cuisine.

(B) Per-serving macros in enrichment. forge.enrich_recipe now asks
    Sonnet for calories, protein_g, carbs_g, fat_g per serving (rough
    USDA-grade estimates from ingredient list + yields). Renders into
    the pool prompt as "~480cal protein=32g carbs=45g fat=18g". Lets
    "high protein week" become a quantitative filter instead of a
    title-keyword match.

(C) Allergen booleans. New contains.* block in enrichment:
    {dairy, gluten, nuts, peanuts, eggs, shellfish, fish, soy, sesame,
    pork} — bool per allergen, conservatively defaulting to TRUE when
    uncertain since false negatives can hurt people. Pool prompt
    renders as "has:dairy,gluten,eggs". Foundation for upcoming
    "no dairy this week" exclusion-list UI on /plan.

(D) Picker profiles. db.household_picker_profiles unions current
    cauldron_meal_picks + historical meal_plan_slots.picker_subs over
    365 days, joins with cauldron_recipe_meta, aggregates per-user:
        {display_name, total_picks, cuisines, proteins, comfort_tiers,
         tags} — top-N counters each. Plan generator includes a new
    PICKER PROFILES block in the prompt:
        - cobb (sub=cobb@sulkta.com, 24 picks):
            cuisines=[asian:6, mexican:4, italian:3] ·
            proteins=[chicken:8, beef:5, fish:2] ·
            tags=[weeknight:11, high-protein:9, spicy:7]
    Sonnet uses these to bias AI-chosen slots toward each member's
    actual demonstrated taste — golden signal that's been sitting in
    the database the whole time. Picks still override profile bias.

Cost: cook history is a single SQL aggregate (free, sub-100ms). New
macro+allergen fields fold into the existing ~5s/recipe Sonnet call
with maybe 30 more output tokens. Picker profiles are 2-3 SQL queries
totaling sub-200ms even at scale. No new network round-trips.

Net effect once Cobb runs /enrich-recipes against ENRICH_VERSION 3:
plan generator has structured macros + allergen flags + cook-history
rotation context + per-user preferences to work with. The free-form
preference textarea ("high protein, no dairy") becomes a real query
against actual data, not just a Sonnet vibe-prompt.
This commit is contained in:
Kayos 2026-04-30 20:23:13 -07:00
parent 10849e0e95
commit 4db447edad
3 changed files with 370 additions and 14 deletions

View file

@ -77,6 +77,7 @@ class Forge:
slots: int = 7,
week_start: str,
preference: str | None = None,
picker_profiles: dict | None = None,
model: str | None = None,
) -> list[dict]:
"""Ask Sonnet for a {slots}-day plan. Returns a list of slot dicts
@ -110,7 +111,7 @@ class Forge:
prompt = self._build_plan_prompt(
picks=picks, recipes=recipes, slots=slots, week_start=week_start,
preference=preference,
preference=preference, picker_profiles=picker_profiles,
)
result = self.run(prompt, model=model or "sonnet")
parsed = _extract_plan_slots(result)
@ -163,7 +164,8 @@ class Forge:
return out
@staticmethod
def _build_plan_prompt(*, picks, recipes, slots, week_start, preference=None) -> str:
def _build_plan_prompt(*, picks, recipes, slots, week_start, preference=None,
picker_profiles=None) -> str:
pool_lines = []
for r in recipes:
slug = r.get("slug") or ""
@ -201,11 +203,42 @@ class Forge:
meta_tags = meta.get("tags") or []
if meta_tags:
extras.append("/".join(meta_tags[:5]))
if meta.get("summary"):
# Inline 1-line summary helps Sonnet match preferences
summary = str(meta["summary"])[:140]
pool_lines.append(f"- {slug}: {name} [{' · '.join(extras)}]\n {summary}")
continue
if meta.get("calories"):
extras.append(f"~{meta['calories']}cal")
if meta.get("protein_g"):
extras.append(f"protein={meta['protein_g']}g")
if meta.get("carbs_g"):
extras.append(f"carbs={meta['carbs_g']}g")
if meta.get("fat_g"):
extras.append(f"fat={meta['fat_g']}g")
# Allergen flags — short-circuit list of "what's in this"
contains = meta.get("contains") or {}
if isinstance(contains, dict):
flags = [k for k, v in contains.items() if v]
if flags:
extras.append("has:" + ",".join(flags))
# Rotation history — let Sonnet avoid 3-weeks-in-a-row repeats
history = r.get("history") or {}
if history:
wa = history.get("weeks_ago")
c30 = history.get("count_30d") or 0
cl = history.get("count_long") or 0
hist_bits = []
if wa is not None:
hist_bits.append(f"last:{wa}w-ago" if wa > 0 else "last:this-week")
if c30:
hist_bits.append(f"{c30}×/30d")
if cl:
hist_bits.append(f"{cl}×/180d")
if hist_bits:
extras.append(" ".join(hist_bits))
if meta and meta.get("summary"):
# Inline 1-line summary helps Sonnet match preferences
summary = str(meta["summary"])[:140]
pool_lines.append(f"- {slug}: {name} [{' · '.join(extras)}]\n {summary}")
continue
extra_str = f" [{' · '.join(extras)}]" if extras else ""
pool_lines.append(f"- {slug}: {name}{extra_str}")
@ -222,6 +255,50 @@ class Forge:
picks_block = "\n".join(pick_lines) if pick_lines else "(none)"
pool_block = "\n".join(pool_lines)
# Picker profiles: per-member historical picking patterns. Helps
# Sonnet bias AI-chosen slots toward each member's actual taste.
profile_block = ""
if picker_profiles:
lines: list[str] = []
for sub, prof in picker_profiles.items():
if not isinstance(prof, dict):
continue
name = prof.get("display_name") or sub
total = prof.get("total_picks") or 0
bits = []
cuisines = prof.get("cuisines") or {}
if cuisines:
bits.append("cuisines=[" + ", ".join(
f"{k}:{v}" for k, v in list(cuisines.items())[:4]
) + "]")
proteins = prof.get("proteins") or {}
if proteins:
bits.append("proteins=[" + ", ".join(
f"{k}:{v}" for k, v in list(proteins.items())[:4]
) + "]")
tags = prof.get("tags") or {}
if tags:
bits.append("tags=[" + ", ".join(
f"{k}:{v}" for k, v in list(tags.items())[:5]
) + "]")
tier = prof.get("comfort_tiers") or {}
if tier:
bits.append("tier=[" + ", ".join(
f"{k}:{v}" for k, v in list(tier.items())[:2]
) + "]")
if bits:
lines.append(f" - {name} (sub={sub}, {total} picks): " + " · ".join(bits))
if lines:
profile_block = (
"\nPICKER PROFILES — per-member historical picking patterns:\n"
+ "\n".join(lines) + "\n\n"
"Use these to bias AI-chosen slots toward each member's "
"preferences. e.g., if Cobb's profile shows cuisines=[asian:6, "
"mexican:4] and proteins=[chicken:8], lean toward asian-chicken "
"recipes for the AI-filled slots when other constraints permit. "
"Picks still take precedence over profile bias.\n"
)
pref_clean = (preference or "").strip()
pref_block = ""
if pref_clean:
@ -244,6 +321,7 @@ class Forge:
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"
f"{profile_block}"
f"{pref_block}"
"Output JSON ONLY, no prose: "
'{"slots": [{"day": "monday", "recipe_slug": "...", '
@ -251,9 +329,16 @@ class Forge:
"Rules:\n"
f"- Use exactly {slots} recipes\n"
"- Distribute picks evenly across the week — don't bunch them\n"
"- ROTATION: prefer recipes with `last:NNw-ago` further in the past "
"or no history shown. If a recipe has been served 2+ times in 30d "
"(`2×/30d` or higher), DEMOTE it strongly unless it's a household "
"pick. Never repeat the same recipe slug within this 7-day plan.\n"
"- VARIETY: don't fill 5 of 7 slots with the same primary_protein or "
"the same cuisine. Mix it up across the week.\n"
"- \"reason\" is a one-line user-facing rationale "
"(e.g., \"balances heavy and light meals\", \"honors abby's pick\", "
"\"high-protein lean — pairs with the gym week\")\n"
"\"high-protein lean — pairs with the gym week\", "
"\"haven't had this in 8 weeks — fresh on the rotation\")\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"
@ -467,13 +552,42 @@ class Forge:
' "veg_forward": "<veg-forward|mixed|meat-forward>",\n'
' "comfort_tier": "<weeknight-easy|hearty-comfort|fancy-occasion|kid-friendly|date-night|crowd-pleaser>",\n'
' "season_fit": [<one or more of "spring","summer","fall","winter","year-round">],\n'
' "calories": <int per-serving estimate or null>,\n'
' "protein_g": <int per-serving estimate or null>,\n'
' "carbs_g": <int per-serving estimate or null>,\n'
' "fat_g": <int per-serving estimate or null>,\n'
' "contains": {\n'
' "dairy": <bool>, // milk/cream/butter/cheese/yogurt/whey\n'
' "gluten": <bool>, // wheat/barley/rye/regular pasta/bread/flour (not GF)\n'
' "nuts": <bool>, // tree nuts (almonds, cashews, pecans, walnuts, ...)\n'
' "peanuts": <bool>, // tracked separately from tree nuts\n'
' "eggs": <bool>,\n'
' "shellfish": <bool>, // shrimp, crab, lobster, scallops, ...\n'
' "fish": <bool>,\n'
' "soy": <bool>, // soy sauce, tofu, tempeh, edamame\n'
' "sesame": <bool>,\n'
' "pork": <bool> // for halal/kosher-ish filters\n'
' },\n'
' "summary": "<one-line vibe — what KIND of meal is this>",\n'
' "best_for": "<short phrase: when is this the right pick>"\n'
"}\n\n"
"Rules:\n"
"- Return ONLY the JSON object, no markdown fences, no prose.\n"
"- Be concrete: 'high-protein' goes in tags ONLY if the recipe genuinely "
"qualifies (significant meat/eggs/dairy/protein source per serving).\n"
"qualifies (≥30g protein per serving is a useful threshold).\n"
"- Macros (calories, protein_g, carbs_g, fat_g): best-effort PER-SERVING "
"estimate from the ingredient list and yields. Use rough USDA averages — "
"we want signal not precision. If yields aren't clear, assume 4 servings. "
"If the recipe is a sauce/seasoning/drink with no useful per-serving notion, "
"set them to null.\n"
"- contains.* booleans: TRUE if the ingredient appears anywhere in the "
"recipe (even small amounts — these drive allergen filters, not "
"macro thresholds). dairy=true for butter, cream, cheese, milk, yogurt, "
"ghee, whey, casein. gluten=true for regular flour/bread/pasta/soy "
"sauce/beer/seitan; FALSE only when explicitly gluten-free or naturally "
"GF. soy=true for soy sauce, tofu, tempeh, edamame, miso. Conservative "
"default: when uncertain, set TRUE (false negatives can cause allergic "
"reactions; false positives just narrow choices).\n"
"- estimated_minutes: best guess from prep + cook implied by steps. Dishes "
"needing rise/marinade time count that time.\n"
"- complexity: 'easy' = ≤30 min + ≤7 ingredients + simple technique; "
@ -555,6 +669,24 @@ def _extract_recipe_meta(forge_result: dict) -> dict:
except (TypeError, ValueError):
return default
def _int_or_none(v):
if v is None:
return None
try:
n = int(v)
return n if n > 0 else None
except (TypeError, ValueError):
return None
contains_raw = inner.get("contains") or {}
if not isinstance(contains_raw, dict):
contains_raw = {}
contains = {
k: bool(contains_raw.get(k))
for k in ("dairy", "gluten", "nuts", "peanuts", "eggs",
"shellfish", "fish", "soy", "sesame", "pork")
}
return {
"tags": _str_list(inner.get("tags")),
"cuisine": _str(inner.get("cuisine"), "unknown"),
@ -566,6 +698,11 @@ def _extract_recipe_meta(forge_result: dict) -> dict:
"veg_forward": _str(inner.get("veg_forward"), "mixed"),
"comfort_tier": _str(inner.get("comfort_tier"), "weeknight-easy"),
"season_fit": _str_list(inner.get("season_fit")) or ["year-round"],
"calories": _int_or_none(inner.get("calories")),
"protein_g": _int_or_none(inner.get("protein_g")),
"carbs_g": _int_or_none(inner.get("carbs_g")),
"fat_g": _int_or_none(inner.get("fat_g")),
"contains": contains,
"summary": _str_long(inner.get("summary")),
"best_for": _str_long(inner.get("best_for")),
}