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 ------------------------------------------------ # --- 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:

View file

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

View file

@ -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