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:
parent
10849e0e95
commit
4db447edad
3 changed files with 370 additions and 14 deletions
|
|
@ -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")),
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue