plan agent: per-user fit + pairings + mood + leftover_potential +
Hecate's weekly reading + 2nd-pass allergen verification
Five new context dimensions for the planner agent (and one quality
fix), bumping ENRICH_VERSION 3→4 so existing meta gets refreshed on
the next walk:
(A) Per-user fit score. Computed at plan-gen time from picker_profiles
× meta — no extra Sonnet calls. _compute_fit_score scores each
recipe 1-5 per household member based on cuisine match, protein
match, comfort_tier match, and tag overlap with that user's
historical picks. Renders in the recipe pool prompt as
'fit:cobb=5,abby=2,leia=3'. Gives Sonnet per-user signal to
bias AI-chosen slots toward whoever's home that week, AND makes
pick rationale explainable.
(B) Pairings per recipe. New meta fields:
pairings.serves_well_with: ["crusty bread","green salad",...]
pairings.drinks: ["pinot noir","iced tea",...]
Sonnet enriches both during the main pass. Foundation for future
"auto-suggest matching side" UX in multi-meal slots.
(C) Leftover potential 1-5. Per-recipe score: 1=eat-now-only (crispy
things, fresh salads), 5=actually BETTER as leftovers (stews,
braises, lasagna). Lets the planner thread Sunday's slow-cooked
pot roast into Monday's lunch slots intentionally.
(D) Mood scores. Per-recipe 1-5 ratings on:
cozy / summer_fresh / energizing / comfort
Independent dimensions — a recipe can score high on multiple.
Foundation for future weather/mood-aware planning ("rainy week
→ bias cozy>=4").
(E) Hecate's weekly reading. New TEXT column hecate_reading on
cauldron_meal_plans (migration 032). The plan-gen prompt asks
Hecate to write a 1-paragraph narrative voice description of the
week — "this week leans into the brisk turn of the season, three
hearty one-pots front-load your weekday energy, salmon Wednesday
for Cobb's gym push..." Confident, wise, theatrical voice. Pure
flavor, no functional impact, but makes Hecate FEEL like an
advisor not an opaque function. Plus the planner system prompt
now opens with the Hecate persona ("You are Hecate, Greek-
mythology witch goddess of crossroads, herbs, and magic — and
the family's meal planner"). Output schema gains a "reading"
field alongside "slots".
ALLERGEN VERIFICATION (the quality fix Cobb explicitly asked for):
- Sonnet sometimes flags pork=true on a sweet potato recipe via the
conservative-default rule. Cobb wants CLEAN data.
- New forge.verify_allergens — second Sonnet pass after main enrich
with a strict prompt: "name the SPECIFIC ingredient triggering each
allergen flag, or set FALSE." For non-anaphylaxis exclusions like
pork, set FALSE unless an actual pork ingredient is named. For
ANAPHYLAXIS allergens, conservative TRUE still applies.
- Cost: ~3s/recipe extra. Wired into enrich_recipes.run_enrich after
initial enrich; failure is non-fatal (falls through to original).
- Eliminates the pork-on-sweet-potatoes class of false positives.
Code restructure: forge.generate_plan now returns {slots, reading}
instead of just slots. _extract_plan_payload pulls both. Server-side
generate + regenerate paths unwrap and persist reading via the new
db.set_plan_hecate_reading. Plan template renders the reading in a
purple-bordered serif callout above the day grid.
Schema:
- migration 032 adds hecate_reading TEXT to cauldron_meal_plans.
- Cauldron_recipe_meta gets new fields persisted in meta_json (no
schema change there — JSON column already accommodates).
This commit is contained in:
parent
f2705e4dd5
commit
89f33f237c
5 changed files with 330 additions and 26 deletions
|
|
@ -497,6 +497,12 @@ MIGRATIONS = [
|
|||
ALTER TABLE cauldron_meal_plans
|
||||
ADD COLUMN IF NOT EXISTS meal_types_json JSON
|
||||
""",
|
||||
# 032 — Hecate's narrative weekly reading. Stored alongside the plan
|
||||
# so it persists with the slots and can be re-rendered. ~2KB ceiling.
|
||||
"""
|
||||
ALTER TABLE cauldron_meal_plans
|
||||
ADD COLUMN IF NOT EXISTS hecate_reading TEXT
|
||||
""",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -704,6 +710,16 @@ class DB:
|
|||
(clean, plan_id),
|
||||
)
|
||||
|
||||
def set_plan_hecate_reading(self, plan_id: int, reading: str) -> None:
|
||||
"""Persist Hecate's narrative weekly reading. Called after generate
|
||||
so a re-render of the plan view shows it."""
|
||||
clean = (reading or "").strip()[:2000] or None
|
||||
with self.conn() as c, c.cursor() as cur:
|
||||
cur.execute(
|
||||
"UPDATE cauldron_meal_plans SET hecate_reading=%s WHERE id=%s",
|
||||
(clean, plan_id),
|
||||
)
|
||||
|
||||
def set_plan_meal_types(self, plan_id: int, meal_types: list | None) -> None:
|
||||
"""Persist which meal types this plan should generate. None / empty
|
||||
list defaults to ['dinner'] at generation time."""
|
||||
|
|
@ -1696,7 +1712,9 @@ class DB:
|
|||
# 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
|
||||
# v4: added pairings, mood scores, leftover_potential, plus second-pass
|
||||
# allergen verification to clean false-positive contains.* booleans
|
||||
ENRICH_VERSION = 4
|
||||
|
||||
def get_recipe_meta(self, household_id: int, recipe_slug: str) -> dict | None:
|
||||
with self.conn() as c, c.cursor() as cur:
|
||||
|
|
|
|||
|
|
@ -132,6 +132,17 @@ def run_enrich(
|
|||
)
|
||||
continue
|
||||
|
||||
# Verification pass: re-check contains.* booleans with a strict
|
||||
# prompt. Catches false-positives like "pork=true on sweet potatoes"
|
||||
# that the conservative-default rule produces. Best-effort —
|
||||
# falls through to original meta on any failure.
|
||||
try:
|
||||
verified = forge.verify_allergens(recipe, meta.get("contains"))
|
||||
if verified:
|
||||
meta["contains"] = verified
|
||||
except Exception as e:
|
||||
log.warning("[enrich:%s] verify_allergens(%s): %s — keeping initial flags", job_id, slug, e)
|
||||
|
||||
try:
|
||||
db.upsert_recipe_meta(
|
||||
household_id=household_id,
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ class Forge:
|
|||
meal_types=meal_types_clean,
|
||||
)
|
||||
result = self.run(prompt, model=model or "sonnet")
|
||||
parsed = _extract_plan_slots(result)
|
||||
parsed, reading = _extract_plan_payload(result)
|
||||
if not isinstance(parsed, list):
|
||||
raise ForgeError("model output: 'slots' must be a list")
|
||||
if len(parsed) != expected_total:
|
||||
|
|
@ -190,7 +190,7 @@ class Forge:
|
|||
"reason": (raw.get("reason") or "")[:500],
|
||||
"source": source,
|
||||
})
|
||||
return out
|
||||
return {"slots": out, "reading": reading}
|
||||
|
||||
@staticmethod
|
||||
def _build_plan_prompt(*, picks, recipes, slots, week_start, preference=None,
|
||||
|
|
@ -247,6 +247,13 @@ class Forge:
|
|||
flags = [k for k, v in contains.items() if v]
|
||||
if flags:
|
||||
extras.append("has:" + ",".join(flags))
|
||||
# Per-user fit score (NEW): the recipe pool entry now carries
|
||||
# a `fit` dict computed in server.py from picker_profiles ×
|
||||
# this recipe's meta. {sub: 1-5}. Render as "fit:cobb=5,abby=2"
|
||||
fit = r.get("fit") or {}
|
||||
if isinstance(fit, dict) and fit:
|
||||
pieces = [f"{k}={v}" for k, v in list(fit.items())[:4]]
|
||||
extras.append("fit:" + ",".join(pieces))
|
||||
|
||||
# Rotation history — let Sonnet avoid 3-weeks-in-a-row repeats
|
||||
history = r.get("history") or {}
|
||||
|
|
@ -412,18 +419,24 @@ class Forge:
|
|||
)
|
||||
output_shape = (
|
||||
'{"slots": [{"day": "monday", "meal_type": "breakfast", '
|
||||
'"recipe_slug": "...", "picker_subs": [...] or [], "reason": "..."}, ...]}'
|
||||
'"recipe_slug": "...", "picker_subs": [...] or [], "reason": "..."}, ...], '
|
||||
'"reading": "<one-paragraph weekly reading from Hecate>"}'
|
||||
)
|
||||
else:
|
||||
output_shape = (
|
||||
'{"slots": [{"day": "monday", "meal_type": "dinner", '
|
||||
'"recipe_slug": "...", "picker_subs": [...] or [], "reason": "..."}, ...]}'
|
||||
'"recipe_slug": "...", "picker_subs": [...] or [], "reason": "..."}, ...], '
|
||||
'"reading": "<one-paragraph weekly reading from Hecate>"}'
|
||||
)
|
||||
expected_total = slots * len(meal_types_clean)
|
||||
meal_types_label = "/".join(meal_types_clean) if is_multi_meal else "dinner"
|
||||
|
||||
return (
|
||||
f"You are a family meal planner. Build a {slots}-day plan ({meal_types_label}) "
|
||||
"You are Hecate, a Greek-mythology witch goddess of crossroads, "
|
||||
"herbs, and magic — and the family's meal planner. You're advising "
|
||||
"with the warm authority of someone who knows the household's tastes, "
|
||||
"their week's rhythm, and what kind of nourishment fits the moment.\n\n"
|
||||
f"Build a {slots}-day plan ({meal_types_label}) "
|
||||
f"for the week of {week_start}.\n\n"
|
||||
f"POOL (all available recipes):\n{pool_block}\n\n"
|
||||
f"PICKS (recipes the family pre-selected — every pick MUST appear in "
|
||||
|
|
@ -452,6 +465,13 @@ class Forge:
|
|||
"- \"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"
|
||||
"- \"reading\" is YOUR voice — Hecate addressing the household. One "
|
||||
"paragraph (~3-5 sentences). Describe the arc of the week: what "
|
||||
"you're leaning into, who you've honored with their picks, where "
|
||||
"the leftovers thread through, what mood the week takes on. "
|
||||
"Witch-goddess tone: confident, wise, a little theatrical. "
|
||||
"Reference specific recipes and members by name when fitting. "
|
||||
"Don't restate every slot's reason — give the WEEK's character.\n"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -678,6 +698,17 @@ class Forge:
|
|||
' "sesame": <bool>,\n'
|
||||
' "pork": <bool> // for halal/kosher-ish filters\n'
|
||||
' },\n'
|
||||
' "pairings": {\n'
|
||||
' "serves_well_with": [<2-4 short phrases — sides/components>],\n'
|
||||
' "drinks": [<1-3 drink suggestions: wine, beer, soda, tea>]\n'
|
||||
' },\n'
|
||||
' "mood": {\n'
|
||||
' "cozy": <int 1-5: how cozy/cold-weather/cuddle this feels>,\n'
|
||||
' "summer_fresh": <int 1-5: how light/refreshing/hot-weather this feels>,\n'
|
||||
' "energizing": <int 1-5: how performance/gym-fuel this is>,\n'
|
||||
' "comfort": <int 1-5: how nostalgic/comforting this is>\n'
|
||||
' },\n'
|
||||
' "leftover_potential": <int 1-5: 1=eat-now-only, 5=tomorrow lunch=this+++>,\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"
|
||||
|
|
@ -691,13 +722,30 @@ class Forge:
|
|||
"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"
|
||||
"recipe. dairy=true for butter, cream, cheese, milk, yogurt, ghee, whey, "
|
||||
"casein. gluten=true for regular flour/bread/pasta/soy sauce/beer/seitan; "
|
||||
"FALSE when explicitly gluten-free or naturally GF. soy=true for soy "
|
||||
"sauce, tofu, tempeh, edamame, miso.\n"
|
||||
" IMPORTANT: do NOT set contains.X=TRUE unless you can name an actual "
|
||||
"ingredient in the recipe that triggers it. False positives clutter the "
|
||||
"data. For ANAPHYLAXIS-risk allergens (peanuts, tree nuts, shellfish, "
|
||||
"eggs, fish, sesame), if a sauce or compound ingredient could plausibly "
|
||||
"contain it, set TRUE conservatively. For non-allergic exclusions "
|
||||
"(pork — religious/dietary), set FALSE unless an actual pork ingredient "
|
||||
"is listed.\n"
|
||||
"- pairings.serves_well_with: 2-4 short phrases describing sides or "
|
||||
"components that pair well. Examples: 'crusty bread', 'green salad', "
|
||||
"'jasmine rice', 'roasted vegetables'. Don't list ingredients already "
|
||||
"in the recipe.\n"
|
||||
"- pairings.drinks: 1-3 drink suggestions (specific or generic). "
|
||||
"Examples: 'iced tea', 'pinot noir', 'cold lager', 'sparkling water "
|
||||
"with lime'.\n"
|
||||
"- mood scores (1-5): how does the dish FEEL? cozy=cold-day-cuddle, "
|
||||
"summer_fresh=hot-day-light, energizing=workout-fuel, comfort=nostalgic-warm. "
|
||||
"These are independent — a recipe can score high on multiple.\n"
|
||||
"- leftover_potential 1-5: 1=eat-fresh-only (crispy fries, salads with "
|
||||
"wilting lettuce), 3=fine the next day, 5=actually BETTER as leftovers "
|
||||
"(stews, braises, lasagna).\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; "
|
||||
|
|
@ -711,6 +759,73 @@ class Forge:
|
|||
result = self.run(prompt, model=model or "sonnet", timeout_secs=180)
|
||||
return _extract_recipe_meta(result)
|
||||
|
||||
def verify_allergens(
|
||||
self,
|
||||
recipe: dict,
|
||||
prior_contains: dict | None,
|
||||
*,
|
||||
model: str | None = None,
|
||||
) -> dict:
|
||||
"""Second-pass allergen check. Re-reads the ingredient list with a
|
||||
strict prompt — must NAME the triggering ingredient or set FALSE.
|
||||
Catches false-positive contains.* booleans from the initial enrich
|
||||
(e.g. pork=true on a sweet potato recipe). Returns a corrected
|
||||
contains dict with the same keys.
|
||||
|
||||
Cheap call (~2-3s, focused prompt). The strictness here is the
|
||||
opposite of the conservative-default in the main enrichment —
|
||||
if you can't point to the ingredient, the answer is FALSE."""
|
||||
ings = recipe.get("recipeIngredient") or []
|
||||
ing_lines: list[str] = []
|
||||
for i in ings[:40]:
|
||||
food = (i.get("food") or {}).get("name") if isinstance(i.get("food"), dict) else None
|
||||
note = (i.get("note") or "").strip()
|
||||
display = (i.get("display") or "").strip()
|
||||
line = food or display or note
|
||||
if line:
|
||||
ing_lines.append(str(line).strip()[:120])
|
||||
|
||||
prompt = (
|
||||
f"Verify the allergen flags for this recipe.\n\n"
|
||||
f"NAME: {recipe.get('name') or '(unnamed)'}\n"
|
||||
f"INGREDIENTS:\n - " + "\n - ".join(ing_lines or ['(none listed)']) + "\n\n"
|
||||
f"PRIOR FLAGS (may be wrong): {json.dumps(prior_contains or {})}\n\n"
|
||||
"Output JSON ONLY, no prose:\n"
|
||||
"{\n"
|
||||
' "contains": {\n'
|
||||
' "dairy": <bool>, "gluten": <bool>, "nuts": <bool>,\n'
|
||||
' "peanuts": <bool>, "eggs": <bool>, "shellfish": <bool>,\n'
|
||||
' "fish": <bool>, "soy": <bool>, "sesame": <bool>,\n'
|
||||
' "pork": <bool>\n'
|
||||
" },\n"
|
||||
' "evidence": {\n'
|
||||
' "<allergen>": "<exact ingredient name that triggers it, or empty if FALSE>"\n'
|
||||
" }\n"
|
||||
"}\n\n"
|
||||
"Rules:\n"
|
||||
"- ONLY set TRUE if you can name a SPECIFIC ingredient in the list above\n"
|
||||
" that contains it. e.g. dairy=TRUE → evidence.dairy='butter, heavy cream'.\n"
|
||||
" Otherwise set FALSE and evidence.<allergen>=''.\n"
|
||||
"- For ANAPHYLAXIS allergens (peanuts, tree nuts, shellfish, eggs, fish,\n"
|
||||
" sesame, dairy): if a sauce/condiment 'might' contain the allergen but\n"
|
||||
" it's not explicit, lean TRUE. Better safe than sorry.\n"
|
||||
"- For NON-ALLERGIC exclusions (pork — religious/dietary): set FALSE\n"
|
||||
" unless an actual pork ingredient is named (pork, ham, bacon,\n"
|
||||
" pork sausage explicitly identified, prosciutto, pancetta, chorizo\n"
|
||||
" if pork-based, lardo).\n"
|
||||
"- gluten: TRUE for regular wheat flour/bread/pasta/soy sauce/beer/seitan/\n"
|
||||
" semolina/couscous/bulgur. FALSE if explicitly gluten-free.\n"
|
||||
"- soy: TRUE for soy sauce, tofu, tempeh, edamame, miso, soy oil.\n"
|
||||
"- The prior flags are a hint, not authority. Override them based on\n"
|
||||
" actual ingredient evidence."
|
||||
)
|
||||
try:
|
||||
result = self.run(prompt, model=model or "sonnet", timeout_secs=60)
|
||||
except ForgeError:
|
||||
# Verification failed — fall back to prior contains; better than nothing
|
||||
return prior_contains or {}
|
||||
return _extract_allergen_verification(result, prior_contains or {})
|
||||
|
||||
def fetch_food_info(self, name: str, *, model: str | None = None) -> dict:
|
||||
"""Ask Sonnet for density + unit class + common size of a single
|
||||
food. Returns a dict shaped like:
|
||||
|
|
@ -747,6 +862,29 @@ class Forge:
|
|||
return _extract_food_info(result)
|
||||
|
||||
|
||||
def _extract_allergen_verification(forge_result: dict, prior: dict) -> dict:
|
||||
"""Pull the corrected contains dict out of the verify_allergens reply.
|
||||
Falls back to prior on any shape problem — verification is best-effort."""
|
||||
if not isinstance(forge_result, dict):
|
||||
return prior
|
||||
inner = forge_result.get("result", forge_result)
|
||||
if isinstance(inner, str):
|
||||
try:
|
||||
inner = _parse_json_blob(inner)
|
||||
except Exception:
|
||||
return prior
|
||||
if not isinstance(inner, dict):
|
||||
return prior
|
||||
contains_raw = inner.get("contains") or {}
|
||||
if not isinstance(contains_raw, dict):
|
||||
return prior
|
||||
return {
|
||||
k: bool(contains_raw.get(k, prior.get(k, False)))
|
||||
for k in ("dairy", "gluten", "nuts", "peanuts", "eggs",
|
||||
"shellfish", "fish", "soy", "sesame", "pork")
|
||||
}
|
||||
|
||||
|
||||
def _extract_recipe_meta(forge_result: dict) -> dict:
|
||||
"""Validate the recipe metadata blob from Sonnet. Coerces types,
|
||||
normalizes enums to lowercase, drops fields not in the schema."""
|
||||
|
|
@ -797,6 +935,32 @@ def _extract_recipe_meta(forge_result: dict) -> dict:
|
|||
"shellfish", "fish", "soy", "sesame", "pork")
|
||||
}
|
||||
|
||||
pairings_raw = inner.get("pairings") or {}
|
||||
if not isinstance(pairings_raw, dict):
|
||||
pairings_raw = {}
|
||||
pairings = {
|
||||
"serves_well_with": [
|
||||
str(x).strip()[:80] for x in (pairings_raw.get("serves_well_with") or [])
|
||||
if isinstance(x, str) and x.strip()
|
||||
][:6],
|
||||
"drinks": [
|
||||
str(x).strip()[:60] for x in (pairings_raw.get("drinks") or [])
|
||||
if isinstance(x, str) and x.strip()
|
||||
][:4],
|
||||
}
|
||||
|
||||
def _score(v, default=3):
|
||||
try:
|
||||
n = int(v)
|
||||
return max(1, min(5, n))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
mood_raw = inner.get("mood") or {}
|
||||
if not isinstance(mood_raw, dict):
|
||||
mood_raw = {}
|
||||
mood = {k: _score(mood_raw.get(k)) for k in ("cozy", "summer_fresh", "energizing", "comfort")}
|
||||
|
||||
return {
|
||||
"tags": _str_list(inner.get("tags")),
|
||||
"cuisine": _str(inner.get("cuisine"), "unknown"),
|
||||
|
|
@ -813,6 +977,9 @@ def _extract_recipe_meta(forge_result: dict) -> dict:
|
|||
"carbs_g": _int_or_none(inner.get("carbs_g")),
|
||||
"fat_g": _int_or_none(inner.get("fat_g")),
|
||||
"contains": contains,
|
||||
"pairings": pairings,
|
||||
"mood": mood,
|
||||
"leftover_potential": _score(inner.get("leftover_potential")),
|
||||
"summary": _str_long(inner.get("summary")),
|
||||
"best_for": _str_long(inner.get("best_for")),
|
||||
}
|
||||
|
|
@ -908,7 +1075,8 @@ def _extract_food_info(forge_result: dict) -> dict:
|
|||
|
||||
def _extract_plan_slots(forge_result: dict):
|
||||
"""clawdforge wraps its return; the JSON we asked for can sit in a few
|
||||
different shapes. Normalize aggressively."""
|
||||
different shapes. Normalize aggressively. Returns (slots, reading)
|
||||
where reading may be empty string."""
|
||||
if not isinstance(forge_result, dict):
|
||||
raise ForgeError("forge result not a dict")
|
||||
inner = forge_result.get("result", forge_result)
|
||||
|
|
@ -922,6 +1090,24 @@ def _extract_plan_slots(forge_result: dict):
|
|||
raise ForgeError(f"forge result missing 'slots' key: {str(inner)[:200]}")
|
||||
|
||||
|
||||
def _extract_plan_payload(forge_result: dict) -> tuple[list, str]:
|
||||
"""Like _extract_plan_slots but ALSO pulls Hecate's weekly reading
|
||||
text if present. Returns (slots_list, reading_str)."""
|
||||
if not isinstance(forge_result, dict):
|
||||
raise ForgeError("forge result not a dict")
|
||||
inner = forge_result.get("result", forge_result)
|
||||
if isinstance(inner, str):
|
||||
inner = _parse_json_blob(inner)
|
||||
if isinstance(inner, list):
|
||||
return inner, ""
|
||||
if isinstance(inner, dict) and "slots" in inner:
|
||||
reading = inner.get("reading") or inner.get("weekly_reading") or ""
|
||||
if not isinstance(reading, str):
|
||||
reading = ""
|
||||
return inner["slots"], reading.strip()[:2000]
|
||||
raise ForgeError(f"forge result missing 'slots' key: {str(inner)[:200]}")
|
||||
|
||||
|
||||
def _parse_json_blob(s: str):
|
||||
s = s.strip()
|
||||
# Strip code fences if Sonnet wrapped its output
|
||||
|
|
|
|||
|
|
@ -796,6 +796,9 @@ def create_app() -> Flask:
|
|||
elif isinstance(blob, dict):
|
||||
meta_by_slug[mr["recipe_slug"]] = blob
|
||||
history_by_slug = db.household_recipe_history(hid, lookback_days=180)
|
||||
# Compute picker_profiles UP FRONT so per-user fit scoring can
|
||||
# use them when building the recipe pool (fit goes inline per recipe).
|
||||
picker_profiles = db.household_picker_profiles(hid, lookback_days=365)
|
||||
recipes = []
|
||||
for r in rows:
|
||||
tags = []
|
||||
|
|
@ -811,6 +814,23 @@ def create_app() -> Flask:
|
|||
m = meta_by_slug.get(r["slug"])
|
||||
if m:
|
||||
entry["meta"] = m
|
||||
# Per-user fit score — match each member's picker profile
|
||||
# (cuisines, proteins, comfort_tiers, tags) against this
|
||||
# recipe's meta and produce a 1-5 score per user. Sonnet
|
||||
# uses these to bias AI-chosen slots toward each member's
|
||||
# demonstrated taste.
|
||||
fit_scores: dict[str, int] = {}
|
||||
for sub, prof in (picker_profiles or {}).items():
|
||||
if not isinstance(prof, dict):
|
||||
continue
|
||||
score = _compute_fit_score(m, prof)
|
||||
if score > 0:
|
||||
# Use display_name when available for readable prompt,
|
||||
# else the sub. Cap at 12 chars to keep prompt slim.
|
||||
nm = (prof.get("display_name") or sub).split("@")[0][:12]
|
||||
fit_scores[nm] = score
|
||||
if fit_scores:
|
||||
entry["fit"] = fit_scores
|
||||
h = history_by_slug.get(r["slug"])
|
||||
if h:
|
||||
# Compute weeks-ago for the prompt (relative human time
|
||||
|
|
@ -818,13 +838,11 @@ def create_app() -> Flask:
|
|||
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
|
||||
delta_days = (date.today() - last).days
|
||||
weeks_ago = max(0, delta_days // 7)
|
||||
except Exception:
|
||||
delta_days = 0
|
||||
weeks_ago = max(0, delta_days // 7)
|
||||
weeks_ago = None
|
||||
entry["history"] = {
|
||||
"weeks_ago": weeks_ago,
|
||||
"count_30d": h.get("count_30d") or 0,
|
||||
|
|
@ -835,11 +853,7 @@ 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)
|
||||
# picker_profiles already computed above for fit scoring; reuse it.
|
||||
|
||||
# Numeric daily targets + allergen exclusions + meal_types parsed
|
||||
# from the persisted plan row
|
||||
|
|
@ -863,7 +877,7 @@ def create_app() -> Flask:
|
|||
plan_meal_types = None
|
||||
|
||||
try:
|
||||
slots = forge.generate_plan(
|
||||
payload = forge.generate_plan(
|
||||
picks=picks, recipes=recipes,
|
||||
slots=7, week_start=this_monday.isoformat(),
|
||||
preference=plan.get("preference_prompt"),
|
||||
|
|
@ -874,6 +888,8 @@ def create_app() -> Flask:
|
|||
)
|
||||
except ForgeError as e:
|
||||
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
||||
slots = payload["slots"] if isinstance(payload, dict) else payload
|
||||
reading = payload.get("reading", "") if isinstance(payload, dict) else ""
|
||||
|
||||
inserted = db.save_plan_slots(plan["id"], slots)
|
||||
if inserted == 0:
|
||||
|
|
@ -884,6 +900,8 @@ def create_app() -> Flask:
|
|||
"plan": _plan_payload(plan),
|
||||
}), 409
|
||||
|
||||
if reading:
|
||||
db.set_plan_hecate_reading(plan["id"], reading)
|
||||
plan = db.mark_plan_generated(plan["id"], u["sub"])
|
||||
db.enrich_plan_with_slots(plan)
|
||||
return jsonify({"ok": True, "plan": _plan_payload(plan)})
|
||||
|
|
@ -982,7 +1000,7 @@ def create_app() -> Flask:
|
|||
plan_meal_types = None
|
||||
|
||||
try:
|
||||
slots = forge.generate_plan(
|
||||
payload = forge.generate_plan(
|
||||
picks=picks, recipes=recipes,
|
||||
slots=7, week_start=this_monday.isoformat(),
|
||||
preference=plan.get("preference_prompt"),
|
||||
|
|
@ -993,8 +1011,12 @@ def create_app() -> Flask:
|
|||
)
|
||||
except ForgeError as e:
|
||||
return jsonify({"error": "forge_failed", "detail": str(e)}), 502
|
||||
slots = payload["slots"] if isinstance(payload, dict) else payload
|
||||
reading = payload.get("reading", "") if isinstance(payload, dict) else ""
|
||||
|
||||
db.save_plan_slots(plan["id"], slots)
|
||||
if reading:
|
||||
db.set_plan_hecate_reading(plan["id"], reading)
|
||||
plan = db.mark_plan_generated(plan["id"], u["sub"])
|
||||
db.enrich_plan_with_slots(plan)
|
||||
return jsonify({"ok": True, "plan": _plan_payload(plan)})
|
||||
|
|
@ -1877,6 +1899,43 @@ def _resolve_sub_displays(db, plan: dict) -> dict[str, str]:
|
|||
return out
|
||||
|
||||
|
||||
def _compute_fit_score(meta: dict, profile: dict) -> int:
|
||||
"""Score (1-5) how well a recipe's meta matches a user's picker profile.
|
||||
Profile dimensions: cuisines, proteins, comfort_tiers, tags (each is a
|
||||
{name → count} dict from picker history). Score logic:
|
||||
- +1 base if profile is non-empty (we have signal at all)
|
||||
- +1 if recipe.cuisine matches a top-3 cuisine the user has picked
|
||||
- +1 if recipe.primary_protein matches a top-3 protein
|
||||
- +1 if recipe.comfort_tier matches their top-2 comfort tier
|
||||
- +1 if any of recipe's tags overlap their top-5 tags
|
||||
Cap at 5. If no profile data exists, returns 3 (neutral, no signal)."""
|
||||
if not isinstance(profile, dict) or not profile.get("total_picks"):
|
||||
return 3
|
||||
score = 1
|
||||
|
||||
def _top_keys(d: dict | None, n: int) -> set:
|
||||
if not isinstance(d, dict):
|
||||
return set()
|
||||
return set(list(d.keys())[:n])
|
||||
|
||||
cuisines = _top_keys(profile.get("cuisines"), 3)
|
||||
proteins = _top_keys(profile.get("proteins"), 3)
|
||||
tiers = _top_keys(profile.get("comfort_tiers"), 2)
|
||||
tags_top = _top_keys(profile.get("tags"), 5)
|
||||
|
||||
if meta.get("cuisine") and meta["cuisine"] in cuisines:
|
||||
score += 1
|
||||
if meta.get("primary_protein") and meta["primary_protein"] in proteins:
|
||||
score += 1
|
||||
if meta.get("comfort_tier") and meta["comfort_tier"] in tiers:
|
||||
score += 1
|
||||
recipe_tags = set(meta.get("tags") or [])
|
||||
if recipe_tags & tags_top:
|
||||
score += 1
|
||||
|
||||
return min(5, score)
|
||||
|
||||
|
||||
def _job_payload(job: dict) -> dict:
|
||||
"""JSON-serializable view of a sterilize job row (datetimes → iso)."""
|
||||
j = dict(job)
|
||||
|
|
|
|||
|
|
@ -274,6 +274,26 @@
|
|||
font-size: 9px; letter-spacing: .2em; text-transform: uppercase;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.hecate-reading {
|
||||
color: var(--bone);
|
||||
font-family: var(--serif);
|
||||
font-size: 1.05em; line-height: 1.6;
|
||||
padding: 14px 18px;
|
||||
background: linear-gradient(135deg,
|
||||
rgba(45, 29, 74, .35) 0%,
|
||||
rgba(45, 29, 74, .15) 100%);
|
||||
border-left: 3px solid var(--purple-bright);
|
||||
border-radius: 6px;
|
||||
font-style: italic;
|
||||
letter-spacing: .01em;
|
||||
}
|
||||
.hecate-reading::before {
|
||||
content: "✶ ";
|
||||
color: var(--purple-bright);
|
||||
font-style: normal;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="page-head">
|
||||
|
|
@ -435,6 +455,16 @@
|
|||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if plan.hecate_reading %}
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>hecate's reading</h2>
|
||||
<span class="ctx">weekly</span>
|
||||
</div>
|
||||
<div class="hecate-reading">{{ plan.hecate_reading }}</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if plan.slots %}
|
||||
<section class="panel purple">
|
||||
<div class="panel-head">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue