kiwi/app/services/recipe/assembly_recipes.py
pyr0ball 07450bf59f feat: recipe engine — assembly templates, prep notes, FTS fixes, texture backfill
- Assembly template system (13 templates: burrito, fried rice, omelette, stir fry,
  pasta, sandwich, grain bowl, soup/stew, casserole, pancakes, porridge, pie, pudding)
  with role-based matching, whole-word single-keyword guard, deterministic titles
  via MD5 pantry hash
- Prep-state stripping: strips 'melted butter' → 'butter' for coverage checks;
  reconstructs actionable states as 'Before you start:' cooking instructions
  (NutritionPanel prep_notes field + RecipesView.vue display block)
- FTS5 fixes: always double-quote all terms; strip apostrophes to prevent
  syntax errors on brands like "Stouffer's"; 'plant-based' → bare 'based' crash
- Bidirectional synonym expansion: alt-meat, alt-chicken, alt-beef, alt-pork
  mapped to canonical texture class; pantry expansion covers 'hamburger' from
  'burger patties' etc.
- Texture profile backfill script (378K ingredient_profiles rows) with macro-derived
  classification in priority order (fatty → creamy → starchy → firm → fibrous →
  tender → liquid → neutral); oats/legumes starchy-first fix
- LLM prompt: ban flavoured/sweetened ingredients (vanilla yoghurt) from savoury
- Migrations 014 (nutrition macros) + 015 (recipe FTS index)
- Nutrition estimation pipeline script
- gitignore MagicMock sqlite test artifacts
2026-04-02 22:12:35 -07:00

647 lines
26 KiB
Python

"""
Assembly-dish template matcher for Level 1/2.
Assembly dishes (burritos, stir fry, fried rice, omelettes, sandwiches, etc.)
are defined by structural roles -- container + filler + sauce -- not by a fixed
ingredient list. The corpus can never fully cover them.
This module fires when the pantry covers all *required* roles of a template.
Results are injected at the top of the Level 1/2 suggestion list with negative
ids (client displays them identically to corpus recipes).
Templates define:
- required: list of role sets -- ALL must have at least one pantry match
- optional: role sets whose matched items are shown as extras
- directions: short cooking instructions
- notes: serving suggestions / variations
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from app.models.schemas.recipe import RecipeSuggestion
# IDs in range -100..-1 are reserved for assembly-generated suggestions
_ASSEMBLY_ID_START = -1
@dataclass
class AssemblyRole:
"""One role in a template (e.g. 'protein').
display: human-readable role label
keywords: substrings matched against pantry item (lowercased)
"""
display: str
keywords: list[str]
@dataclass
class AssemblyTemplate:
"""A template assembly dish."""
id: int
title: str
required: list[AssemblyRole]
optional: list[AssemblyRole]
directions: list[str]
notes: str = ""
def _matches_role(role: AssemblyRole, pantry_set: set[str]) -> list[str]:
"""Return pantry items that satisfy this role.
Single-word keywords use whole-word matching (word must appear as a
discrete token) so short words like 'pea', 'ham', 'egg' don't false-match
inside longer words like 'peanut', 'hamburger', 'eggnog'.
Multi-word keywords (e.g. 'burger patt') use substring matching.
"""
hits: list[str] = []
for item in pantry_set:
item_lower = item.lower()
item_words = set(item_lower.split())
for kw in role.keywords:
if " " in kw:
# Multi-word: substring match
if kw in item_lower:
hits.append(item)
break
else:
# Single-word: whole-word match only
if kw in item_words:
hits.append(item)
break
return hits
def _pick_one(items: list[str], seed: int) -> str:
"""Deterministically pick one item from a list using a seed."""
return sorted(items)[seed % len(items)]
def _pantry_hash(pantry_set: set[str]) -> int:
"""Stable integer derived from pantry contents — used for deterministic picks."""
key = ",".join(sorted(pantry_set))
return int(hashlib.md5(key.encode()).hexdigest(), 16) # noqa: S324 — non-crypto use
def _keyword_label(item: str, role: AssemblyRole) -> str:
"""Return a short, clean label derived from the keyword that matched.
Uses the longest matching keyword (most specific) as the base label,
then title-cases it. This avoids pasting full raw pantry names like
'Organic Extra Firm Tofu' into titles — just 'Tofu' instead.
"""
lower = item.lower()
best_kw = ""
for kw in role.keywords:
if kw in lower and len(kw) > len(best_kw):
best_kw = kw
label = (best_kw or item).strip().title()
# Drop trailing 's' from keywords like "beans" → "Bean" when it reads better
return label
def _personalized_title(tmpl: AssemblyTemplate, pantry_set: set[str], seed: int) -> str:
"""Build a specific title using actual pantry items, e.g. 'Chicken & Broccoli Burrito'.
Uses the matched keyword as the label (not the full pantry item name) so
'Organic Extra Firm Tofu Block''Tofu' in the title.
Picks at most two roles; prefers protein then vegetable.
"""
priority_displays = ["protein", "vegetables", "sauce base", "cheese"]
picked: list[str] = []
for display in priority_displays:
for role in tmpl.optional:
if role.display != display:
continue
hits = _matches_role(role, pantry_set)
if hits:
item = _pick_one(hits, seed)
label = _keyword_label(item, role)
if label not in picked:
picked.append(label)
if len(picked) >= 2:
break
if not picked:
return tmpl.title
return f"{' & '.join(picked)} {tmpl.title}"
# ---------------------------------------------------------------------------
# Template definitions
# ---------------------------------------------------------------------------
ASSEMBLY_TEMPLATES: list[AssemblyTemplate] = [
AssemblyTemplate(
id=-1,
title="Burrito / Taco",
required=[
AssemblyRole("tortilla or wrap", [
"tortilla", "wrap", "taco shell", "flatbread", "pita",
]),
],
optional=[
AssemblyRole("protein", [
"chicken", "beef", "steak", "pork", "sausage", "hamburger",
"burger patt", "shrimp", "egg", "tofu", "beans", "bean",
]),
AssemblyRole("rice or starch", ["rice", "quinoa", "potato"]),
AssemblyRole("cheese", [
"cheese", "cheddar", "mozzarella", "monterey", "queso",
]),
AssemblyRole("salsa or sauce", [
"salsa", "hot sauce", "taco sauce", "enchilada", "guacamole",
]),
AssemblyRole("sour cream or yogurt", ["sour cream", "greek yogurt", "crema"]),
AssemblyRole("vegetables", [
"pepper", "onion", "tomato", "lettuce", "corn", "avocado",
"spinach", "broccoli", "zucchini",
]),
],
directions=[
"Warm the tortilla in a dry skillet or microwave for 20 seconds.",
"Heat any proteins or vegetables in a pan until cooked through.",
"Layer ingredients down the center: rice first, then protein, then vegetables.",
"Add cheese, salsa, and sour cream last so they stay cool.",
"Fold in the sides and roll tightly. Optionally toast seam-side down 1-2 minutes.",
],
notes="Works as a burrito (rolled), taco (folded), or quesadilla (cheese only, pressed flat).",
),
AssemblyTemplate(
id=-2,
title="Fried Rice",
required=[
AssemblyRole("cooked rice", [
"rice", "leftover rice", "instant rice", "microwavable rice",
]),
],
optional=[
AssemblyRole("protein", [
"chicken", "beef", "pork", "shrimp", "egg", "tofu",
"sausage", "ham", "spam",
]),
AssemblyRole("soy sauce or seasoning", [
"soy sauce", "tamari", "teriyaki", "oyster sauce", "fish sauce",
]),
AssemblyRole("oil", ["oil", "butter", "sesame"]),
AssemblyRole("egg", ["egg"]),
AssemblyRole("vegetables", [
"carrot", "peas", "corn", "onion", "scallion", "green onion",
"broccoli", "bok choy", "bean sprout", "zucchini", "spinach",
]),
AssemblyRole("garlic or ginger", ["garlic", "ginger"]),
],
directions=[
"Use day-old cold rice if available -- it fries better than fresh.",
"Heat oil in a large skillet or wok over high heat.",
"Add garlic/ginger and any raw vegetables; stir fry 2-3 minutes.",
"Push to the side, scramble eggs in the same pan if using.",
"Add protein (pre-cooked or raw) and cook through.",
"Add rice, breaking up clumps. Stir fry until heated and lightly toasted.",
"Season with soy sauce and any other sauces. Toss to combine.",
],
notes="Add a fried egg on top. A drizzle of sesame oil at the end adds a lot.",
),
AssemblyTemplate(
id=-3,
title="Omelette / Scramble",
required=[
AssemblyRole("eggs", ["egg"]),
],
optional=[
AssemblyRole("cheese", [
"cheese", "cheddar", "mozzarella", "feta", "parmesan",
]),
AssemblyRole("vegetables", [
"pepper", "onion", "tomato", "spinach", "mushroom",
"broccoli", "zucchini", "scallion", "avocado",
]),
AssemblyRole("protein", [
"ham", "bacon", "sausage", "chicken", "turkey",
"smoked salmon",
]),
AssemblyRole("herbs or seasoning", [
"herb", "basil", "chive", "parsley", "salt", "pepper",
"hot sauce", "salsa",
]),
],
directions=[
"Beat eggs with a splash of water or milk and a pinch of salt.",
"Saute any vegetables and proteins in butter or oil over medium heat until softened.",
"Pour eggs over fillings (scramble) or pour into a clean buttered pan (omelette).",
"For omelette: cook until nearly set, add fillings to one side, fold over.",
"For scramble: stir gently over medium-low heat until just set.",
"Season and serve immediately.",
],
notes="Works for breakfast, lunch, or a quick dinner. Any leftover vegetables work well.",
),
AssemblyTemplate(
id=-4,
title="Stir Fry",
required=[
AssemblyRole("vegetables", [
"pepper", "broccoli", "carrot", "snap pea", "bok choy",
"zucchini", "mushroom", "corn", "onion", "bean sprout",
"cabbage", "spinach", "asparagus",
]),
],
optional=[
AssemblyRole("protein", [
"chicken", "beef", "pork", "shrimp", "tofu", "egg",
]),
AssemblyRole("sauce", [
"soy sauce", "teriyaki", "oyster sauce", "hoisin",
"stir fry sauce", "sesame",
]),
AssemblyRole("starch base", ["rice", "noodle", "pasta", "ramen"]),
AssemblyRole("garlic or ginger", ["garlic", "ginger"]),
AssemblyRole("oil", ["oil", "sesame"]),
],
directions=[
"Cut all proteins and vegetables into similar-sized pieces for even cooking.",
"Heat oil in a wok or large skillet over the highest heat your stove allows.",
"Cook protein first until nearly done; remove and set aside.",
"Add dense vegetables (carrots, broccoli) first; quick-cooking veg last.",
"Return protein, add sauce, and toss everything together for 1-2 minutes.",
"Serve over rice or noodles.",
],
notes="High heat is the key. Do not crowd the pan -- cook in batches if needed.",
),
AssemblyTemplate(
id=-5,
title="Pasta with Whatever You Have",
required=[
AssemblyRole("pasta", [
"pasta", "spaghetti", "penne", "fettuccine", "rigatoni",
"linguine", "rotini", "farfalle", "macaroni", "noodle",
]),
],
optional=[
AssemblyRole("sauce base", [
"tomato", "marinara", "pasta sauce", "cream", "butter",
"olive oil", "pesto",
]),
AssemblyRole("protein", [
"chicken", "beef", "pork", "shrimp", "sausage", "bacon",
"ham", "tuna", "canned fish",
]),
AssemblyRole("cheese", [
"parmesan", "romano", "mozzarella", "ricotta", "feta",
]),
AssemblyRole("vegetables", [
"tomato", "spinach", "mushroom", "pepper", "zucchini",
"broccoli", "artichoke", "olive", "onion",
]),
AssemblyRole("garlic", ["garlic"]),
],
directions=[
"Cook pasta in well-salted boiling water until al dente. Reserve 1 cup pasta water.",
"While pasta cooks, saute garlic in olive oil over medium heat.",
"Add proteins and cook through; add vegetables until tender.",
"Add sauce base and simmer 5 minutes. Add pasta water to loosen if needed.",
"Toss cooked pasta with sauce. Finish with cheese if using.",
],
notes="Pasta water is the secret -- the starch thickens and binds any sauce.",
),
AssemblyTemplate(
id=-6,
title="Sandwich / Wrap",
required=[
AssemblyRole("bread or wrap", [
"bread", "roll", "bun", "wrap", "tortilla", "pita",
"bagel", "english muffin", "croissant", "flatbread",
]),
],
optional=[
AssemblyRole("protein", [
"chicken", "turkey", "ham", "roast beef", "tuna", "egg",
"bacon", "salami", "pepperoni", "tofu", "tempeh",
]),
AssemblyRole("cheese", [
"cheese", "cheddar", "swiss", "provolone", "mozzarella",
]),
AssemblyRole("condiment", [
"mayo", "mustard", "ketchup", "hot sauce", "ranch",
"hummus", "pesto", "aioli",
]),
AssemblyRole("vegetables", [
"lettuce", "tomato", "onion", "cucumber", "avocado",
"pepper", "sprout", "arugula",
]),
],
directions=[
"Toast bread if desired.",
"Spread condiments on both inner surfaces.",
"Layer protein first, then cheese, then vegetables.",
"Press together and cut diagonally.",
],
notes="Leftovers, deli meat, canned fish -- nearly anything works between bread.",
),
AssemblyTemplate(
id=-7,
title="Grain Bowl",
required=[
AssemblyRole("grain base", [
"rice", "quinoa", "farro", "barley", "couscous",
"bulgur", "freekeh", "polenta",
]),
],
optional=[
AssemblyRole("protein", [
"chicken", "beef", "pork", "tofu", "egg", "shrimp",
"beans", "bean", "lentil", "chickpea",
]),
AssemblyRole("vegetables", [
"roasted", "broccoli", "carrot", "kale", "spinach",
"cucumber", "tomato", "corn", "edamame", "avocado",
"beet", "sweet potato",
]),
AssemblyRole("dressing or sauce", [
"dressing", "tahini", "vinaigrette", "sauce",
"olive oil", "lemon", "soy sauce",
]),
AssemblyRole("toppings", [
"nut", "seed", "feta", "parmesan", "herb",
]),
],
directions=[
"Cook grain base according to package directions; season with salt.",
"Roast or saute vegetables with oil, salt, and pepper until tender.",
"Cook or slice protein.",
"Arrange grain in a bowl, top with protein and vegetables.",
"Drizzle with dressing and add toppings.",
],
notes="Great for meal prep -- cook grains and proteins in bulk, assemble bowls all week.",
),
AssemblyTemplate(
id=-8,
title="Soup / Stew",
required=[
AssemblyRole("broth or liquid base", [
"broth", "stock", "bouillon",
"tomato sauce", "coconut milk", "cream of",
]),
],
optional=[
AssemblyRole("protein", [
"chicken", "beef", "pork", "sausage", "shrimp",
"beans", "bean", "lentil", "tofu",
]),
AssemblyRole("vegetables", [
"carrot", "celery", "onion", "potato", "tomato",
"spinach", "kale", "corn", "pea", "zucchini",
]),
AssemblyRole("starch thickener", [
"potato", "pasta", "noodle", "rice", "barley",
"flour", "cornstarch",
]),
AssemblyRole("seasoning", [
"garlic", "herb", "bay leaf", "thyme", "rosemary",
"cumin", "paprika", "chili",
]),
],
directions=[
"Saute onion, celery, and garlic in oil until softened, about 5 minutes.",
"Add any raw proteins and cook until browned.",
"Add broth or liquid base and bring to a simmer.",
"Add dense vegetables (carrots, potatoes) first; quick-cooking veg in the last 10 minutes.",
"Add starches and cook until tender.",
"Season to taste and simmer at least 20 minutes for flavors to develop.",
],
notes="Soups and stews improve overnight in the fridge. Almost any combination works.",
),
AssemblyTemplate(
id=-9,
title="Casserole / Bake",
required=[
AssemblyRole("starch or base", [
"pasta", "rice", "potato", "noodle", "bread",
"tortilla", "polenta", "grits", "macaroni",
]),
AssemblyRole("binder or sauce", [
"cream of", "cheese", "cream cheese", "sour cream",
"soup mix", "gravy", "tomato sauce", "marinara",
"broth", "stock", "milk", "cream",
]),
],
optional=[
AssemblyRole("protein", [
"chicken", "beef", "pork", "tuna", "ham", "sausage",
"ground", "shrimp", "beans", "bean", "lentil",
]),
AssemblyRole("vegetables", [
"broccoli", "corn", "pea", "onion", "mushroom",
"spinach", "zucchini", "tomato", "pepper", "carrot",
]),
AssemblyRole("cheese topping", [
"cheddar", "mozzarella", "parmesan", "swiss",
"cheese", "breadcrumb",
]),
AssemblyRole("seasoning", [
"garlic", "herb", "thyme", "rosemary", "paprika",
"onion powder", "salt", "pepper",
]),
],
directions=[
"Preheat oven to 375 F (190 C). Grease a 9x13 baking dish.",
"Cook starch base (pasta, rice, potato) until just underdone -- it finishes in the oven.",
"Mix cooked starch with sauce/binder, protein, and vegetables in the dish.",
"Season generously -- casseroles need salt.",
"Top with cheese or breadcrumbs if using.",
"Bake covered 25 minutes, then uncovered 15 minutes until golden and bubbly.",
],
notes="Classic pantry dump dinner. Cream of anything soup is the universal binder.",
),
AssemblyTemplate(
id=-10,
title="Pancakes / Waffles / Quick Bread",
required=[
AssemblyRole("flour or baking mix", [
"flour", "bisquick", "pancake mix", "waffle mix",
"baking mix", "cornmeal", "oats",
]),
AssemblyRole("leavening or egg", [
"egg", "baking powder", "baking soda", "yeast",
]),
],
optional=[
AssemblyRole("liquid", [
"milk", "buttermilk", "water", "juice",
"almond milk", "oat milk", "sour cream",
]),
AssemblyRole("fat", [
"butter", "oil", "margarine",
]),
AssemblyRole("sweetener", [
"sugar", "honey", "maple syrup", "brown sugar",
]),
AssemblyRole("mix-ins", [
"blueberr", "banana", "apple", "chocolate chip",
"nut", "berry", "cinnamon", "vanilla",
]),
],
directions=[
"Whisk dry ingredients (flour, leavening, sugar, salt) together in a bowl.",
"Whisk wet ingredients (egg, milk, melted butter) in a separate bowl.",
"Fold wet into dry until just combined -- lumps are fine, do not overmix.",
"For pancakes: cook on a buttered griddle over medium heat, flip when bubbles form.",
"For waffles: pour into preheated waffle iron according to manufacturer instructions.",
"For muffins or quick bread: pour into greased pan, bake at 375 F until a toothpick comes out clean.",
],
notes="Overmixing develops gluten and makes pancakes tough. Stop when just combined.",
),
AssemblyTemplate(
id=-11,
title="Porridge / Oatmeal",
required=[
AssemblyRole("oats or grain porridge", [
"oat", "porridge", "grits", "semolina", "cream of wheat",
"polenta", "congee", "rice porridge",
]),
],
optional=[
AssemblyRole("liquid", ["milk", "water", "almond milk", "oat milk", "coconut milk"]),
AssemblyRole("sweetener", ["sugar", "honey", "maple syrup", "brown sugar", "agave"]),
AssemblyRole("fruit", ["banana", "berry", "apple", "raisin", "date", "mango"]),
AssemblyRole("toppings", ["nut", "seed", "granola", "coconut", "chocolate"]),
AssemblyRole("spice", ["cinnamon", "nutmeg", "vanilla", "cardamom"]),
],
directions=[
"Combine oats with liquid in a pot — typically 1 part oats to 2 parts liquid.",
"Bring to a gentle simmer over medium heat, stirring occasionally.",
"Cook 5 minutes (rolled oats) or 2 minutes (quick oats) until thickened to your liking.",
"Stir in sweetener and spices.",
"Top with fruit, nuts, or seeds and serve immediately.",
],
notes="Overnight oats: skip cooking — soak oats in cold milk overnight in the fridge.",
),
AssemblyTemplate(
id=-12,
title="Pie / Pot Pie",
required=[
AssemblyRole("pastry or crust", [
"pastry", "puff pastry", "pie crust", "shortcrust",
"pie shell", "phyllo", "filo", "biscuit",
]),
],
optional=[
AssemblyRole("protein filling", [
"chicken", "beef", "pork", "lamb", "turkey", "tofu",
"mushroom", "beans", "bean", "lentil", "tuna", "salmon",
]),
AssemblyRole("vegetables", [
"carrot", "pea", "corn", "potato", "onion", "leek",
"broccoli", "spinach", "mushroom", "parsnip", "swede",
]),
AssemblyRole("sauce or binder", [
"gravy", "cream of", "stock", "broth", "cream",
"white sauce", "bechamel", "cheese sauce",
]),
AssemblyRole("seasoning", [
"thyme", "rosemary", "sage", "garlic", "herb",
"mustard", "worcestershire",
]),
AssemblyRole("sweet filling", [
"apple", "berry", "cherry", "pear", "peach",
"rhubarb", "plum", "custard",
]),
],
directions=[
"For pot pie: make a sauce by combining stock or cream-of-something with cooked vegetables and protein.",
"Season generously — fillings need more salt than you think.",
"Pour filling into a baking dish and top with pastry, pressing edges to seal.",
"Cut a few slits in the top to release steam. Brush with egg wash or milk if available.",
"Bake at 400 F (200 C) for 25-35 minutes until pastry is golden brown.",
"For sweet pie: fill unbaked crust with fruit filling, top with second crust or crumble, bake similarly.",
],
notes="Puff pastry from the freezer is the shortcut to impressive pot pies. Thaw in the fridge overnight.",
),
AssemblyTemplate(
id=-13,
title="Pudding / Custard",
required=[
AssemblyRole("dairy or dairy-free milk", [
"milk", "cream", "oat milk", "almond milk",
"soy milk", "coconut milk",
]),
AssemblyRole("thickener or set", [
"egg", "cornstarch", "custard powder", "gelatin",
"agar", "tapioca", "arrowroot",
]),
],
optional=[
AssemblyRole("sweetener", ["sugar", "honey", "maple syrup", "condensed milk"]),
AssemblyRole("flavouring", [
"vanilla", "chocolate", "cocoa", "caramel",
"lemon", "orange", "cinnamon", "nutmeg",
]),
AssemblyRole("starchy base", [
"rice", "bread", "sponge", "cake", "biscuit",
]),
AssemblyRole("fruit", ["raisin", "sultana", "berry", "banana", "apple"]),
],
directions=[
"For stovetop custard: whisk eggs and sugar together, heat milk until steaming.",
"Slowly pour hot milk into egg mixture while whisking constantly (tempering).",
"Return to low heat and stir until mixture coats the back of a spoon.",
"For cornstarch pudding: whisk cornstarch into cold milk first, then heat while stirring.",
"Add flavourings (vanilla, cocoa) once off heat.",
"Pour into dishes and refrigerate at least 2 hours to set.",
],
notes="UK-style pudding is broad — bread pudding, rice pudding, spotted dick, treacle sponge all count.",
),
]
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def match_assembly_templates(
pantry_items: list[str],
pantry_set: set[str],
excluded_ids: list[int],
) -> list[RecipeSuggestion]:
"""Return assembly-dish suggestions whose required roles are all satisfied.
Titles are personalized with specific pantry items (deterministically chosen
from the pantry contents so the same pantry always produces the same title).
Skips templates whose id is in excluded_ids (dismiss/load-more support).
"""
excluded = set(excluded_ids)
seed = _pantry_hash(pantry_set)
results: list[RecipeSuggestion] = []
for tmpl in ASSEMBLY_TEMPLATES:
if tmpl.id in excluded:
continue
# All required roles must be satisfied
if any(not _matches_role(role, pantry_set) for role in tmpl.required):
continue
optional_hit_count = sum(
1 for role in tmpl.optional if _matches_role(role, pantry_set)
)
results.append(RecipeSuggestion(
id=tmpl.id,
title=_personalized_title(tmpl, pantry_set, seed + tmpl.id),
match_count=len(tmpl.required) + optional_hit_count,
element_coverage={},
swap_candidates=[],
missing_ingredients=[],
directions=tmpl.directions,
notes=tmpl.notes,
level=1,
is_wildcard=False,
nutrition=None,
))
# Sort by optional coverage descending — best-matched templates first
results.sort(key=lambda s: s.match_count, reverse=True)
return results