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

@ -1547,7 +1547,12 @@ class DB:
# --- recipe enrichment ------------------------------------------------
ENRICH_VERSION = 1
# Bump when the meta schema or prompt changes meaningfully so existing
# rows get re-enriched on next walk (or on user-clicks "force re-enrich").
# v2: added calories, protein_g, carbs_g, fat_g per-serving estimates
# v3: added contains.{dairy,gluten,nuts,peanuts,eggs,shellfish,fish,soy,
# sesame,pork} allergen booleans
ENRICH_VERSION = 3
def get_recipe_meta(self, household_id: int, recipe_slug: str) -> dict | None:
with self.conn() as c, c.cursor() as cur:
@ -1560,6 +1565,185 @@ class DB:
row = cur.fetchone()
return dict(row) if row else None
def household_picker_profiles(
self, household_id: int, *, lookback_days: int = 365
) -> dict[str, dict]:
"""For each member of the household, aggregate the recipes they've
explicitly picked (current cauldron_meal_picks + historical
meal_plan_slots.picker_subs across the lookback window). Cross
with cauldron_recipe_meta to derive their picking pattern:
{
"cobb@sulkta.com": {
"display_name": "cobb",
"total_picks": 24,
"cuisines": {"asian": 6, "mexican": 4, "italian": 3, ...},
"proteins": {"chicken": 8, "beef": 5, "fish": 2, ...},
"comfort_tiers": {"weeknight-easy": 15, "hearty-comfort": 4, ...},
"tags": {"high-protein": 9, "weeknight": 11, "spicy": 7, ...},
},
...
}
Used by the plan generator to bias AI-chosen slots toward what
each member has historically wanted, even when nobody actively
pins for the upcoming week. Picks-attribution is golden signal
the user explicitly said 'I want this.'"""
import json as _json
# Step 1: gather (sub, recipe_slug) pairs from both sources
attributions: list[tuple[str, str]] = []
with self.conn() as c, c.cursor() as cur:
# Current picks (high-signal, recent activity)
cur.execute(
"""SELECT mp.authentik_sub AS sub, mp.recipe_slug
FROM cauldron_meal_picks mp
JOIN cauldron_household_members m
ON m.authentik_sub = mp.authentik_sub
WHERE m.household_id = %s""",
(household_id,),
)
for r in cur.fetchall():
attributions.append((r["sub"], r["recipe_slug"]))
# Historical picker_subs in plan slots (what actually got cooked
# because someone wanted it)
cur.execute(
"""SELECT s.recipe_slug, s.picker_subs
FROM cauldron_meal_plan_slots s
JOIN cauldron_meal_plans p ON p.id = s.plan_id
WHERE p.household_id = %s
AND p.week_start >= CURDATE() - INTERVAL %s DAY
AND s.picker_subs IS NOT NULL""",
(household_id, lookback_days),
)
for r in cur.fetchall():
blob = r["picker_subs"]
if isinstance(blob, str):
try:
blob = _json.loads(blob)
except Exception:
continue
if not isinstance(blob, list):
continue
for sub in blob:
if isinstance(sub, str) and sub:
attributions.append((sub, r["recipe_slug"]))
if not attributions:
return {}
# Step 2: pull recipe meta for any slug in the attribution set
slugs = list({s for _, s in attributions})
meta_by_slug: dict[str, dict] = {}
if slugs:
placeholders = ",".join(["%s"] * len(slugs))
with self.conn() as c, c.cursor() as cur:
cur.execute(
f"""SELECT recipe_slug, meta_json
FROM cauldron_recipe_meta
WHERE household_id=%s AND recipe_slug IN ({placeholders})""",
(household_id, *slugs),
)
for r in cur.fetchall():
blob = r["meta_json"]
if isinstance(blob, str):
try:
meta_by_slug[r["recipe_slug"]] = _json.loads(blob)
except Exception:
pass
elif isinstance(blob, dict):
meta_by_slug[r["recipe_slug"]] = blob
# Step 3: pull display names for the subs we found
subs = list({s for s, _ in attributions})
display_by_sub: dict[str, str] = {}
if subs:
placeholders = ",".join(["%s"] * len(subs))
with self.conn() as c, c.cursor() as cur:
cur.execute(
f"""SELECT authentik_sub, email, display_name
FROM cauldron_users
WHERE authentik_sub IN ({placeholders})""",
tuple(subs),
)
for r in cur.fetchall():
nm = r["display_name"] or (r["email"] or "").split("@")[0] or r["authentik_sub"]
display_by_sub[r["authentik_sub"]] = nm
# Step 4: aggregate per user
from collections import Counter
per_user: dict[str, dict] = {}
for sub, slug in attributions:
meta = meta_by_slug.get(slug) or {}
prof = per_user.setdefault(sub, {
"display_name": display_by_sub.get(sub, sub),
"total_picks": 0,
"cuisines": Counter(),
"proteins": Counter(),
"comfort_tiers": Counter(),
"tags": Counter(),
})
prof["total_picks"] += 1
cuisine = meta.get("cuisine")
if cuisine and cuisine not in ("unknown", "other"):
prof["cuisines"][cuisine] += 1
prot = meta.get("primary_protein")
if prot and prot != "none":
prof["proteins"][prot] += 1
tier = meta.get("comfort_tier")
if tier:
prof["comfort_tiers"][tier] += 1
for t in (meta.get("tags") or [])[:8]:
if t:
prof["tags"][t] += 1
# Step 5: convert Counters to plain top-N dicts for prompt-friendly output
out: dict[str, dict] = {}
for sub, prof in per_user.items():
out[sub] = {
"display_name": prof["display_name"],
"total_picks": prof["total_picks"],
"cuisines": dict(prof["cuisines"].most_common(5)),
"proteins": dict(prof["proteins"].most_common(5)),
"comfort_tiers": dict(prof["comfort_tiers"].most_common(3)),
"tags": dict(prof["tags"].most_common(8)),
}
return out
def household_recipe_history(
self, household_id: int, *, lookback_days: int = 180
) -> dict[str, dict]:
"""For each recipe slug that's appeared in this household's meal plans
within the lookback window, return:
{recipe_slug: {last_planned: date, count_30d: int, count_180d: int}}
Used by the plan generator to surface rotation context to Sonnet
("don't suggest the same recipe 3 weeks running"). Joins on
meal_plan_slots meal_plans household."""
with self.conn() as c, c.cursor() as cur:
cur.execute(
"""
SELECT
s.recipe_slug,
MAX(p.week_start) AS last_planned,
SUM(CASE WHEN p.week_start >= CURDATE() - INTERVAL 30 DAY THEN 1 ELSE 0 END) AS count_30d,
SUM(CASE WHEN p.week_start >= CURDATE() - INTERVAL %s DAY THEN 1 ELSE 0 END) AS count_long
FROM cauldron_meal_plan_slots s
JOIN cauldron_meal_plans p ON p.id = s.plan_id
WHERE p.household_id = %s
AND p.week_start >= CURDATE() - INTERVAL %s DAY
GROUP BY s.recipe_slug
""",
(lookback_days, household_id, lookback_days),
)
out: dict[str, dict] = {}
for r in cur.fetchall():
out[r["recipe_slug"]] = {
"last_planned": r["last_planned"],
"count_30d": int(r["count_30d"] or 0),
"count_long": int(r["count_long"] or 0),
}
return out
def list_recipe_meta_for_household(self, household_id: int) -> list[dict]:
"""Used by the plan generator to splice meta into the recipe pool prompt."""
with self.conn() as c, c.cursor() as cur:

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")),
}

View file

@ -669,10 +669,11 @@ def create_app() -> Flask:
db.set_plan_preference(plan["id"], preference)
plan["preference_prompt"] = preference[:1000]
# Pull picks + recipe pool. The pool now splices in cauldron_recipe_meta
# (Sonnet-generated per-recipe attributes — cuisine, complexity, macros,
# meal_type, primary_protein/carb, comfort_tier, summary) so the planner
# can match preferences to actual recipe characteristics, not just names.
# Pull picks + recipe pool. The pool splices in:
# 1. cauldron_recipe_meta (Sonnet-generated per-recipe attributes)
# 2. cook history from cauldron_meal_plan_slots (rotation context)
# Both let the planner match preferences to actual characteristics
# AND avoid serving the same recipe 3 weeks running.
picks = db.list_household_picks_with_pickers(hid)
rows = db.list_indexed_recipes(hid, limit=2000, offset=0)
meta_rows = db.list_recipe_meta_for_household(hid)
@ -686,6 +687,7 @@ def create_app() -> Flask:
pass
elif isinstance(blob, dict):
meta_by_slug[mr["recipe_slug"]] = blob
history_by_slug = db.household_recipe_history(hid, lookback_days=180)
recipes = []
for r in rows:
tags = []
@ -701,16 +703,42 @@ def create_app() -> Flask:
m = meta_by_slug.get(r["slug"])
if m:
entry["meta"] = m
h = history_by_slug.get(r["slug"])
if h:
# Compute weeks-ago for the prompt (relative human time
# is more useful to Sonnet than ISO dates)
last = h.get("last_planned")
weeks_ago: int | None = None
if last is not None:
today = date.today()
delta_days = (today - last).days if hasattr(last, "days") is False else 0
try:
delta_days = (today - last).days
except Exception:
delta_days = 0
weeks_ago = max(0, delta_days // 7)
entry["history"] = {
"weeks_ago": weeks_ago,
"count_30d": h.get("count_30d") or 0,
"count_long": h.get("count_long") or 0,
}
recipes.append(entry)
if not recipes:
return jsonify({"error": "no_recipes_indexed"}), 409
# Per-user picking profiles — what each member has historically
# pinned, joined with recipe meta. Lets the planner bias AI-chosen
# slots toward each member's actual preferences, not just blanket
# the household's collective average.
picker_profiles = db.household_picker_profiles(hid, lookback_days=365)
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,
)
except ForgeError as e:
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
@ -781,11 +809,18 @@ def create_app() -> Flask:
if not recipes:
return jsonify({"error": "no_recipes_indexed"}), 409
# Per-user picking profiles — what each member has historically
# pinned, joined with recipe meta. Lets the planner bias AI-chosen
# slots toward each member's actual preferences, not just blanket
# the household's collective average.
picker_profiles = db.household_picker_profiles(hid, lookback_days=365)
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,
)
except ForgeError as e:
return jsonify({"error": "forge_failed", "detail": str(e)}), 502