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
186
cauldron/db.py
186
cauldron/db.py
|
|
@ -1547,7 +1547,12 @@ class DB:
|
||||||
|
|
||||||
# --- recipe enrichment ------------------------------------------------
|
# --- 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:
|
def get_recipe_meta(self, household_id: int, recipe_slug: str) -> dict | None:
|
||||||
with self.conn() as c, c.cursor() as cur:
|
with self.conn() as c, c.cursor() as cur:
|
||||||
|
|
@ -1560,6 +1565,185 @@ class DB:
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
return dict(row) if row else None
|
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]:
|
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."""
|
"""Used by the plan generator to splice meta into the recipe pool prompt."""
|
||||||
with self.conn() as c, c.cursor() as cur:
|
with self.conn() as c, c.cursor() as cur:
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ class Forge:
|
||||||
slots: int = 7,
|
slots: int = 7,
|
||||||
week_start: str,
|
week_start: str,
|
||||||
preference: str | None = None,
|
preference: str | None = None,
|
||||||
|
picker_profiles: dict | None = None,
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Ask Sonnet for a {slots}-day plan. Returns a list of slot dicts
|
"""Ask Sonnet for a {slots}-day plan. Returns a list of slot dicts
|
||||||
|
|
@ -110,7 +111,7 @@ class Forge:
|
||||||
|
|
||||||
prompt = self._build_plan_prompt(
|
prompt = self._build_plan_prompt(
|
||||||
picks=picks, recipes=recipes, slots=slots, week_start=week_start,
|
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")
|
result = self.run(prompt, model=model or "sonnet")
|
||||||
parsed = _extract_plan_slots(result)
|
parsed = _extract_plan_slots(result)
|
||||||
|
|
@ -163,7 +164,8 @@ class Forge:
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@staticmethod
|
@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 = []
|
pool_lines = []
|
||||||
for r in recipes:
|
for r in recipes:
|
||||||
slug = r.get("slug") or ""
|
slug = r.get("slug") or ""
|
||||||
|
|
@ -201,11 +203,42 @@ class Forge:
|
||||||
meta_tags = meta.get("tags") or []
|
meta_tags = meta.get("tags") or []
|
||||||
if meta_tags:
|
if meta_tags:
|
||||||
extras.append("/".join(meta_tags[:5]))
|
extras.append("/".join(meta_tags[:5]))
|
||||||
if meta.get("summary"):
|
if meta.get("calories"):
|
||||||
# Inline 1-line summary helps Sonnet match preferences
|
extras.append(f"~{meta['calories']}cal")
|
||||||
summary = str(meta["summary"])[:140]
|
if meta.get("protein_g"):
|
||||||
pool_lines.append(f"- {slug}: {name} [{' · '.join(extras)}]\n {summary}")
|
extras.append(f"protein={meta['protein_g']}g")
|
||||||
continue
|
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 ""
|
extra_str = f" [{' · '.join(extras)}]" if extras else ""
|
||||||
pool_lines.append(f"- {slug}: {name}{extra_str}")
|
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)"
|
picks_block = "\n".join(pick_lines) if pick_lines else "(none)"
|
||||||
pool_block = "\n".join(pool_lines)
|
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_clean = (preference or "").strip()
|
||||||
pref_block = ""
|
pref_block = ""
|
||||||
if pref_clean:
|
if pref_clean:
|
||||||
|
|
@ -244,6 +321,7 @@ class Forge:
|
||||||
f"PICKS (recipes the family pre-selected — every pick MUST appear "
|
f"PICKS (recipes the family pre-selected — every pick MUST appear "
|
||||||
f"if pool size >= {slots}; no repeats unless pool < {slots}):\n"
|
f"if pool size >= {slots}; no repeats unless pool < {slots}):\n"
|
||||||
f"{picks_block}\n"
|
f"{picks_block}\n"
|
||||||
|
f"{profile_block}"
|
||||||
f"{pref_block}"
|
f"{pref_block}"
|
||||||
"Output JSON ONLY, no prose: "
|
"Output JSON ONLY, no prose: "
|
||||||
'{"slots": [{"day": "monday", "recipe_slug": "...", '
|
'{"slots": [{"day": "monday", "recipe_slug": "...", '
|
||||||
|
|
@ -251,9 +329,16 @@ class Forge:
|
||||||
"Rules:\n"
|
"Rules:\n"
|
||||||
f"- Use exactly {slots} recipes\n"
|
f"- Use exactly {slots} recipes\n"
|
||||||
"- Distribute picks evenly across the week — don't bunch them\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 "
|
"- \"reason\" is a one-line user-facing rationale "
|
||||||
"(e.g., \"balances heavy and light meals\", \"honors abby's pick\", "
|
"(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 "
|
"- \"picker_subs\" is the array of authentik_sub strings of family "
|
||||||
"members who picked this recipe (empty list if AI-chosen)\n"
|
"members who picked this recipe (empty list if AI-chosen)\n"
|
||||||
"- Day order: monday..sunday\n"
|
"- Day order: monday..sunday\n"
|
||||||
|
|
@ -467,13 +552,42 @@ class Forge:
|
||||||
' "veg_forward": "<veg-forward|mixed|meat-forward>",\n'
|
' "veg_forward": "<veg-forward|mixed|meat-forward>",\n'
|
||||||
' "comfort_tier": "<weeknight-easy|hearty-comfort|fancy-occasion|kid-friendly|date-night|crowd-pleaser>",\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'
|
' "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'
|
' "summary": "<one-line vibe — what KIND of meal is this>",\n'
|
||||||
' "best_for": "<short phrase: when is this the right pick>"\n'
|
' "best_for": "<short phrase: when is this the right pick>"\n'
|
||||||
"}\n\n"
|
"}\n\n"
|
||||||
"Rules:\n"
|
"Rules:\n"
|
||||||
"- Return ONLY the JSON object, no markdown fences, no prose.\n"
|
"- Return ONLY the JSON object, no markdown fences, no prose.\n"
|
||||||
"- Be concrete: 'high-protein' goes in tags ONLY if the recipe genuinely "
|
"- 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 "
|
"- estimated_minutes: best guess from prep + cook implied by steps. Dishes "
|
||||||
"needing rise/marinade time count that time.\n"
|
"needing rise/marinade time count that time.\n"
|
||||||
"- complexity: 'easy' = ≤30 min + ≤7 ingredients + simple technique; "
|
"- complexity: 'easy' = ≤30 min + ≤7 ingredients + simple technique; "
|
||||||
|
|
@ -555,6 +669,24 @@ def _extract_recipe_meta(forge_result: dict) -> dict:
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return default
|
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 {
|
return {
|
||||||
"tags": _str_list(inner.get("tags")),
|
"tags": _str_list(inner.get("tags")),
|
||||||
"cuisine": _str(inner.get("cuisine"), "unknown"),
|
"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"),
|
"veg_forward": _str(inner.get("veg_forward"), "mixed"),
|
||||||
"comfort_tier": _str(inner.get("comfort_tier"), "weeknight-easy"),
|
"comfort_tier": _str(inner.get("comfort_tier"), "weeknight-easy"),
|
||||||
"season_fit": _str_list(inner.get("season_fit")) or ["year-round"],
|
"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")),
|
"summary": _str_long(inner.get("summary")),
|
||||||
"best_for": _str_long(inner.get("best_for")),
|
"best_for": _str_long(inner.get("best_for")),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -669,10 +669,11 @@ def create_app() -> Flask:
|
||||||
db.set_plan_preference(plan["id"], preference)
|
db.set_plan_preference(plan["id"], preference)
|
||||||
plan["preference_prompt"] = preference[:1000]
|
plan["preference_prompt"] = preference[:1000]
|
||||||
|
|
||||||
# Pull picks + recipe pool. The pool now splices in cauldron_recipe_meta
|
# Pull picks + recipe pool. The pool splices in:
|
||||||
# (Sonnet-generated per-recipe attributes — cuisine, complexity, macros,
|
# 1. cauldron_recipe_meta (Sonnet-generated per-recipe attributes)
|
||||||
# meal_type, primary_protein/carb, comfort_tier, summary) so the planner
|
# 2. cook history from cauldron_meal_plan_slots (rotation context)
|
||||||
# can match preferences to actual recipe characteristics, not just names.
|
# 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)
|
picks = db.list_household_picks_with_pickers(hid)
|
||||||
rows = db.list_indexed_recipes(hid, limit=2000, offset=0)
|
rows = db.list_indexed_recipes(hid, limit=2000, offset=0)
|
||||||
meta_rows = db.list_recipe_meta_for_household(hid)
|
meta_rows = db.list_recipe_meta_for_household(hid)
|
||||||
|
|
@ -686,6 +687,7 @@ def create_app() -> Flask:
|
||||||
pass
|
pass
|
||||||
elif isinstance(blob, dict):
|
elif isinstance(blob, dict):
|
||||||
meta_by_slug[mr["recipe_slug"]] = blob
|
meta_by_slug[mr["recipe_slug"]] = blob
|
||||||
|
history_by_slug = db.household_recipe_history(hid, lookback_days=180)
|
||||||
recipes = []
|
recipes = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
tags = []
|
tags = []
|
||||||
|
|
@ -701,16 +703,42 @@ def create_app() -> Flask:
|
||||||
m = meta_by_slug.get(r["slug"])
|
m = meta_by_slug.get(r["slug"])
|
||||||
if m:
|
if m:
|
||||||
entry["meta"] = 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)
|
recipes.append(entry)
|
||||||
|
|
||||||
if not recipes:
|
if not recipes:
|
||||||
return jsonify({"error": "no_recipes_indexed"}), 409
|
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:
|
try:
|
||||||
slots = forge.generate_plan(
|
slots = forge.generate_plan(
|
||||||
picks=picks, recipes=recipes,
|
picks=picks, recipes=recipes,
|
||||||
slots=7, week_start=this_monday.isoformat(),
|
slots=7, week_start=this_monday.isoformat(),
|
||||||
preference=plan.get("preference_prompt"),
|
preference=plan.get("preference_prompt"),
|
||||||
|
picker_profiles=picker_profiles,
|
||||||
)
|
)
|
||||||
except ForgeError as e:
|
except ForgeError as e:
|
||||||
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
||||||
|
|
@ -781,11 +809,18 @@ def create_app() -> Flask:
|
||||||
if not recipes:
|
if not recipes:
|
||||||
return jsonify({"error": "no_recipes_indexed"}), 409
|
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:
|
try:
|
||||||
slots = forge.generate_plan(
|
slots = forge.generate_plan(
|
||||||
picks=picks, recipes=recipes,
|
picks=picks, recipes=recipes,
|
||||||
slots=7, week_start=this_monday.isoformat(),
|
slots=7, week_start=this_monday.isoformat(),
|
||||||
preference=plan.get("preference_prompt"),
|
preference=plan.get("preference_prompt"),
|
||||||
|
picker_profiles=picker_profiles,
|
||||||
)
|
)
|
||||||
except ForgeError as e:
|
except ForgeError as e:
|
||||||
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue