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 ------------------------------------------------
|
||||
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue