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:
Kayos 2026-04-30 22:04:46 -07:00
parent f2705e4dd5
commit 89f33f237c
5 changed files with 330 additions and 26 deletions

View file

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

View file

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

View file

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

View file

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

View file

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