recipe enrichment: per-recipe Sonnet meta for smarter planning
The 'fancy data fun' Cobb wanted: pre-compute structured metadata for
every recipe so the plan generator can match preferences to actual
recipe characteristics, not just match keywords on names.
Sonnet returns per recipe:
- tags[]: curated descriptors (high-protein, weeknight, one-pan,
leftovers-good, kid-friendly, etc — picks 3-8 that genuinely apply)
- cuisine, complexity (easy/medium/involved), estimated_minutes
- meal_type (breakfast/lunch/dinner/snack/dessert/side/sauce/drink)
- primary_protein (chicken/beef/pork/fish/seafood/tofu/...)
- primary_carb (rice/pasta/bread/potato/tortilla/quinoa/...)
- veg_forward (veg-forward/mixed/meat-forward)
- comfort_tier (weeknight-easy/hearty-comfort/fancy-occasion/...)
- season_fit[] + summary one-liner + best_for short phrase
Schema:
- Migration 024: cauldron_recipe_meta keyed by (household_id, recipe_slug),
meta_json + enrich_version (bumping the version invalidates the cache
and forces re-walk). One row per Mealie recipe Cobb owns.
- Migration 025: cauldron_enrich_jobs — job runner state. No
proposals/review needed since metadata is purely additive.
Forge:
- enrich_recipe(recipe) builds a compact prompt with name + description
+ ingredients + steps (capped at 2000 chars total) + yields, asks
Sonnet for the structured blob. _extract_recipe_meta validates and
coerces types.
Module enrich_recipes.py:
- Daemon thread runner, walks all household recipes, skips already-
enriched at current ENRICH_VERSION (idempotent), respects external
cancel + stuck-job recovery. Skips cross-household recipes (Lake
Elsinore stuff visible but not enrichable).
Plan generator hookup:
- /api/plan/generate + regenerate now pulls cauldron_recipe_meta and
splices it into the recipe pool prompt. Each pool line goes from:
- chicken-stir-fry: Chicken Stir Fry [asian]
to:
- chicken-stir-fry: Chicken Stir Fry [asian · easy · 30min ·
protein:chicken · carb:rice · high-protein/weeknight/one-pan]
quick weeknight stir-fry with leftover-friendly portions
Sonnet now has rich attributes to actually match a 'high protein
week' or 'comfort food' or 'quick' preference against, instead of
guessing from titles.
Endpoints:
- /enrich-recipes UI page (progress bar + start + force re-enrich +
cancel; no review/approve since meta is additive)
- /api/recipes/enrich-{start,status,cancel} session-authed
- /api/admin/recipes/enrich-start bearer-authed for kayos kick-off
Cost (one-time): ~5s/recipe × 226 = ~20 min walk. Subsequent runs
only process new/changed recipes.
This commit is contained in:
parent
820d65171b
commit
10849e0e95
6 changed files with 828 additions and 7 deletions
|
|
@ -169,9 +169,11 @@ class Forge:
|
|||
slug = r.get("slug") or ""
|
||||
name = r.get("name") or slug
|
||||
tags = r.get("tags") or []
|
||||
tag_str = ""
|
||||
meta = r.get("meta") or {}
|
||||
|
||||
extras: list[str] = []
|
||||
# First 3 Mealie tags
|
||||
if tags:
|
||||
# First 3 tags only — keeps prompt token count under control
|
||||
cleaned = []
|
||||
for t in tags[:3]:
|
||||
if isinstance(t, dict):
|
||||
|
|
@ -180,8 +182,32 @@ class Forge:
|
|||
cleaned.append(t)
|
||||
cleaned = [c for c in cleaned if c]
|
||||
if cleaned:
|
||||
tag_str = f" [{', '.join(cleaned)}]"
|
||||
pool_lines.append(f"- {slug}: {name}{tag_str}")
|
||||
extras.append(", ".join(cleaned))
|
||||
# Sonnet-generated meta — the actual high-signal stuff
|
||||
if meta:
|
||||
if meta.get("cuisine") and meta["cuisine"] not in ("unknown", "other"):
|
||||
extras.append(meta["cuisine"])
|
||||
if meta.get("complexity"):
|
||||
extras.append(meta["complexity"])
|
||||
em = meta.get("estimated_minutes")
|
||||
if isinstance(em, int) and em > 0:
|
||||
extras.append(f"{em}min")
|
||||
if meta.get("primary_protein") and meta["primary_protein"] != "none":
|
||||
extras.append(f"protein:{meta['primary_protein']}")
|
||||
if meta.get("primary_carb") and meta["primary_carb"] != "none":
|
||||
extras.append(f"carb:{meta['primary_carb']}")
|
||||
if meta.get("veg_forward") and meta["veg_forward"] != "mixed":
|
||||
extras.append(meta["veg_forward"])
|
||||
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
|
||||
extra_str = f" [{' · '.join(extras)}]" if extras else ""
|
||||
pool_lines.append(f"- {slug}: {name}{extra_str}")
|
||||
|
||||
pick_lines = []
|
||||
for p in picks:
|
||||
|
|
@ -354,6 +380,113 @@ class Forge:
|
|||
result = self.run(prompt, model=model or "sonnet", timeout_secs=60)
|
||||
return _extract_cluster_decision(result)
|
||||
|
||||
def enrich_recipe(self, recipe: dict, *, model: str | None = None) -> dict:
|
||||
"""Generate structured metadata for a recipe so the plan generator
|
||||
can match preferences to actual recipe characteristics, not just
|
||||
names.
|
||||
|
||||
Input: a Mealie recipe dict (uses name + description + ingredients
|
||||
+ instructions + yields + recipeYield).
|
||||
|
||||
Output (validated):
|
||||
{
|
||||
"tags": [<curated descriptor strings>],
|
||||
# e.g. "high-protein", "weeknight", "one-pan",
|
||||
# "kid-friendly", "leftovers-good", "freezer-friendly"
|
||||
"cuisine": "<american|italian|asian|mexican|...|other|unknown>",
|
||||
"complexity": "easy|medium|involved",
|
||||
"estimated_minutes": <int>,
|
||||
"meal_type": "breakfast|lunch|dinner|snack|dessert|side",
|
||||
"primary_protein": "<chicken|beef|pork|fish|tofu|beans|eggs|none|mixed>",
|
||||
"primary_carb": "<rice|pasta|bread|potato|tortilla|quinoa|none|mixed>",
|
||||
"veg_forward": "veg-forward|mixed|meat-forward",
|
||||
"comfort_tier": "<weeknight-easy|comfort|fancy|kid-friendly|...>",
|
||||
"season_fit": [<season strings>],
|
||||
"summary": "<one-line vibe>",
|
||||
"best_for": "<short phrase about when this is the right pick>"
|
||||
}
|
||||
|
||||
Cheap call, idempotent — run once per recipe and cache forever
|
||||
(or until enrich_version bumps)."""
|
||||
# Build a compact recipe summary for the prompt
|
||||
ings = recipe.get("recipeIngredient") or []
|
||||
ing_lines: list[str] = []
|
||||
for i in ings[:30]:
|
||||
food = (i.get("food") or {}).get("name") if isinstance(i.get("food"), dict) else None
|
||||
qty = i.get("quantity")
|
||||
unit = (i.get("unit") or {}).get("name") if isinstance(i.get("unit"), dict) else None
|
||||
note = i.get("note") or ""
|
||||
line = ""
|
||||
if qty not in (None, ""):
|
||||
line += f"{qty} "
|
||||
if unit:
|
||||
line += f"{unit} "
|
||||
if food:
|
||||
line += food
|
||||
elif note:
|
||||
line += note
|
||||
if line.strip():
|
||||
ing_lines.append(line.strip())
|
||||
instructions = recipe.get("recipeInstructions") or []
|
||||
steps: list[str] = []
|
||||
char_budget = 2000
|
||||
for step in instructions:
|
||||
if not isinstance(step, dict):
|
||||
continue
|
||||
text = (step.get("text") or "").strip()
|
||||
if not text or char_budget <= 0:
|
||||
continue
|
||||
if len(text) > char_budget:
|
||||
text = text[:char_budget] + "…"
|
||||
steps.append(text)
|
||||
char_budget -= len(text)
|
||||
|
||||
prompt = (
|
||||
"Given the following recipe, return structured metadata to help "
|
||||
"an AI meal planner pick recipes that match user preferences "
|
||||
"('high protein week', 'carb load', 'light recovery', etc).\n\n"
|
||||
f"NAME: {recipe.get('name') or '(unnamed)'}\n"
|
||||
f"DESCRIPTION: {(recipe.get('description') or '').strip()[:400]}\n"
|
||||
f"YIELDS: {(recipe.get('recipeYield') or '').strip()[:80]}\n"
|
||||
f"INGREDIENTS:\n - " + "\n - ".join(ing_lines or ['(none listed)']) + "\n"
|
||||
f"STEPS:\n - " + "\n - ".join(steps or ['(none listed)']) + "\n\n"
|
||||
"Output JSON ONLY, no prose:\n"
|
||||
"{\n"
|
||||
' "tags": [<curated descriptor strings — pick 3-8 from these or invent close variants: '
|
||||
'"high-protein","low-carb","high-carb","low-fat","high-fiber",'
|
||||
'"vegetarian","vegan","gluten-free","dairy-free","keto","paleo",'
|
||||
'"weeknight","weekend","one-pan","one-pot","sheet-pan","slow-cooker","instant-pot",'
|
||||
'"freezer-friendly","leftovers-good","kid-friendly","spicy","mild",'
|
||||
'"hearty","light","fresh","comfort","fancy","quick","make-ahead">],\n'
|
||||
' "cuisine": "<american|italian|asian|mexican|mediterranean|indian|french|middle-eastern|other|unknown>",\n'
|
||||
' "complexity": "<easy|medium|involved>",\n'
|
||||
' "estimated_minutes": <int total time including prep>,\n'
|
||||
' "meal_type": "<breakfast|lunch|dinner|snack|dessert|side|sauce|drink>",\n'
|
||||
' "primary_protein": "<chicken|beef|pork|fish|seafood|tofu|tempeh|beans|eggs|cheese|nuts|none|mixed>",\n'
|
||||
' "primary_carb": "<rice|pasta|bread|potato|tortilla|quinoa|noodles|grain|none|mixed>",\n'
|
||||
' "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'
|
||||
' "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"
|
||||
"- 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; "
|
||||
"'medium' = 30-90 min OR moderate technique; 'involved' = >90 min OR "
|
||||
"advanced technique (lamination, fermentation, multi-component).\n"
|
||||
"- summary should describe the vibe / use-case, not just restate the name. "
|
||||
"e.g. 'quick weeknight stir-fry with leftover-friendly portions' beats "
|
||||
"'chicken stir fry with rice'.\n"
|
||||
"- When uncertain on a categorical, use 'unknown' or 'other' rather than guessing."
|
||||
)
|
||||
result = self.run(prompt, model=model or "sonnet", timeout_secs=90)
|
||||
return _extract_recipe_meta(result)
|
||||
|
||||
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:
|
||||
|
|
@ -390,6 +523,54 @@ class Forge:
|
|||
return _extract_food_info(result)
|
||||
|
||||
|
||||
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."""
|
||||
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 not isinstance(inner, dict):
|
||||
raise ForgeError(f"recipe meta not a dict: {str(inner)[:200]}")
|
||||
|
||||
def _str(v, default=""):
|
||||
return str(v).strip().lower()[:64] if isinstance(v, str) and v.strip() else default
|
||||
|
||||
def _str_long(v, default=""):
|
||||
return str(v).strip()[:300] if isinstance(v, str) and v.strip() else default
|
||||
|
||||
def _str_list(v) -> list[str]:
|
||||
if not isinstance(v, list):
|
||||
return []
|
||||
out = []
|
||||
for item in v:
|
||||
if isinstance(item, str) and item.strip():
|
||||
out.append(item.strip().lower()[:48])
|
||||
return out[:12]
|
||||
|
||||
def _int(v, default=0):
|
||||
try:
|
||||
return max(0, int(v))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
return {
|
||||
"tags": _str_list(inner.get("tags")),
|
||||
"cuisine": _str(inner.get("cuisine"), "unknown"),
|
||||
"complexity": _str(inner.get("complexity"), "medium"),
|
||||
"estimated_minutes": _int(inner.get("estimated_minutes")),
|
||||
"meal_type": _str(inner.get("meal_type"), "dinner"),
|
||||
"primary_protein": _str(inner.get("primary_protein"), "none"),
|
||||
"primary_carb": _str(inner.get("primary_carb"), "none"),
|
||||
"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"],
|
||||
"summary": _str_long(inner.get("summary")),
|
||||
"best_for": _str_long(inner.get("best_for")),
|
||||
}
|
||||
|
||||
|
||||
def _extract_recipe_dedupe_decision(forge_result: dict) -> dict:
|
||||
if not isinstance(forge_result, dict):
|
||||
raise ForgeError("forge result not a dict")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue