feat: RecipeEngine Level 1-2 — corpus match, substitution, grocery list, hard day mode
This commit is contained in:
parent
294974886d
commit
3a064bcac8
3 changed files with 318 additions and 0 deletions
44
app/models/schemas/recipe.py
Normal file
44
app/models/schemas/recipe.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""Pydantic schemas for the recipe engine API."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class SwapCandidate(BaseModel):
|
||||||
|
original_name: str
|
||||||
|
substitute_name: str
|
||||||
|
constraint_label: str
|
||||||
|
explanation: str
|
||||||
|
compensation_hints: list[dict] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeSuggestion(BaseModel):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
match_count: int
|
||||||
|
element_coverage: dict[str, float] = Field(default_factory=dict)
|
||||||
|
swap_candidates: list[SwapCandidate] = Field(default_factory=list)
|
||||||
|
missing_ingredients: list[str] = Field(default_factory=list)
|
||||||
|
level: int = 1
|
||||||
|
is_wildcard: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeResult(BaseModel):
|
||||||
|
suggestions: list[RecipeSuggestion]
|
||||||
|
element_gaps: list[str]
|
||||||
|
grocery_list: list[str] = Field(default_factory=list)
|
||||||
|
rate_limited: bool = False
|
||||||
|
rate_limit_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeRequest(BaseModel):
|
||||||
|
pantry_items: list[str]
|
||||||
|
level: int = Field(default=1, ge=1, le=4)
|
||||||
|
constraints: list[str] = Field(default_factory=list)
|
||||||
|
expiry_first: bool = False
|
||||||
|
hard_day_mode: bool = False
|
||||||
|
max_missing: int | None = None
|
||||||
|
style_id: str | None = None
|
||||||
|
tier: str = "free"
|
||||||
|
has_byok: bool = False
|
||||||
|
wildcard_confirmed: bool = False
|
||||||
166
app/services/recipe/recipe_engine.py
Normal file
166
app/services/recipe/recipe_engine.py
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
"""
|
||||||
|
RecipeEngine — orchestrates the four creativity levels.
|
||||||
|
|
||||||
|
Level 1: corpus lookup ranked by ingredient match + expiry urgency
|
||||||
|
Level 2: Level 1 + deterministic substitution swaps
|
||||||
|
Level 3: element scaffold → LLM constrained prompt (see llm_recipe.py)
|
||||||
|
Level 4: wildcard LLM (see llm_recipe.py)
|
||||||
|
|
||||||
|
Amendments:
|
||||||
|
- max_missing: filter to recipes missing ≤ N pantry items
|
||||||
|
- hard_day_mode: filter to easy-method recipes only
|
||||||
|
- grocery_list: aggregated missing ingredients across suggestions
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.db.store import Store
|
||||||
|
|
||||||
|
from app.models.schemas.recipe import RecipeRequest, RecipeResult, RecipeSuggestion, SwapCandidate
|
||||||
|
from app.services.recipe.element_classifier import ElementClassifier
|
||||||
|
from app.services.recipe.substitution_engine import SubstitutionEngine
|
||||||
|
|
||||||
|
_LEFTOVER_DAILY_MAX_FREE = 5
|
||||||
|
|
||||||
|
# Method complexity classification patterns
|
||||||
|
_EASY_METHODS = re.compile(
|
||||||
|
r"\b(microwave|mix|stir|blend|toast|assemble|heat)\b", re.IGNORECASE
|
||||||
|
)
|
||||||
|
_INVOLVED_METHODS = re.compile(
|
||||||
|
r"\b(braise|roast|knead|deep.?fry|fry|sauté|saute|bake|boil)\b", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_method_complexity(
|
||||||
|
directions: list[str],
|
||||||
|
available_equipment: list[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Classify recipe method complexity from direction strings.
|
||||||
|
|
||||||
|
Returns 'easy', 'moderate', or 'involved'.
|
||||||
|
available_equipment can expand the easy set (e.g. ['toaster', 'air fryer']).
|
||||||
|
"""
|
||||||
|
text = " ".join(directions).lower()
|
||||||
|
equipment_set = {e.lower() for e in (available_equipment or [])}
|
||||||
|
|
||||||
|
if _INVOLVED_METHODS.search(text):
|
||||||
|
return "involved"
|
||||||
|
|
||||||
|
if _EASY_METHODS.search(text):
|
||||||
|
return "easy"
|
||||||
|
|
||||||
|
# Check equipment-specific easy methods
|
||||||
|
for equip in equipment_set:
|
||||||
|
if equip in text:
|
||||||
|
return "easy"
|
||||||
|
|
||||||
|
return "moderate"
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeEngine:
|
||||||
|
def __init__(self, store: "Store") -> None:
|
||||||
|
self._store = store
|
||||||
|
self._classifier = ElementClassifier(store)
|
||||||
|
self._substitution = SubstitutionEngine(store)
|
||||||
|
|
||||||
|
def suggest(
|
||||||
|
self,
|
||||||
|
req: RecipeRequest,
|
||||||
|
available_equipment: list[str] | None = None,
|
||||||
|
) -> RecipeResult:
|
||||||
|
# Rate-limit leftover mode for free tier
|
||||||
|
if req.expiry_first and req.tier == "free":
|
||||||
|
allowed, count = self._store.check_and_increment_rate_limit(
|
||||||
|
"leftover_mode", _LEFTOVER_DAILY_MAX_FREE
|
||||||
|
)
|
||||||
|
if not allowed:
|
||||||
|
return RecipeResult(
|
||||||
|
suggestions=[], element_gaps=[], rate_limited=True, rate_limit_count=count
|
||||||
|
)
|
||||||
|
|
||||||
|
profiles = self._classifier.classify_batch(req.pantry_items)
|
||||||
|
gaps = self._classifier.identify_gaps(profiles)
|
||||||
|
pantry_set = {item.lower().strip() for item in req.pantry_items}
|
||||||
|
|
||||||
|
if req.level >= 3:
|
||||||
|
from app.services.recipe.llm_recipe import LLMRecipeGenerator
|
||||||
|
gen = LLMRecipeGenerator(self._store)
|
||||||
|
return gen.generate(req, profiles, gaps)
|
||||||
|
|
||||||
|
# Level 1 & 2: deterministic path
|
||||||
|
rows = self._store.search_recipes_by_ingredients(req.pantry_items, limit=20)
|
||||||
|
suggestions = []
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
ingredient_names: list[str] = row.get("ingredient_names") or []
|
||||||
|
if isinstance(ingredient_names, str):
|
||||||
|
try:
|
||||||
|
ingredient_names = json.loads(ingredient_names)
|
||||||
|
except Exception:
|
||||||
|
ingredient_names = []
|
||||||
|
|
||||||
|
# Compute missing ingredients
|
||||||
|
missing = [n for n in ingredient_names if n.lower() not in pantry_set]
|
||||||
|
|
||||||
|
# Filter by max_missing
|
||||||
|
if req.max_missing is not None and len(missing) > req.max_missing:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Filter by hard_day_mode
|
||||||
|
if req.hard_day_mode:
|
||||||
|
directions: list[str] = row.get("directions") or []
|
||||||
|
if isinstance(directions, str):
|
||||||
|
try:
|
||||||
|
directions = json.loads(directions)
|
||||||
|
except Exception:
|
||||||
|
directions = [directions]
|
||||||
|
complexity = _classify_method_complexity(directions, available_equipment)
|
||||||
|
if complexity == "involved":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build swap candidates for Level 2
|
||||||
|
swap_candidates: list[SwapCandidate] = []
|
||||||
|
if req.level == 2 and req.constraints:
|
||||||
|
for ing in ingredient_names:
|
||||||
|
for constraint in req.constraints:
|
||||||
|
swaps = self._substitution.find_substitutes(ing, constraint)
|
||||||
|
for swap in swaps[:1]:
|
||||||
|
swap_candidates.append(SwapCandidate(
|
||||||
|
original_name=swap.original_name,
|
||||||
|
substitute_name=swap.substitute_name,
|
||||||
|
constraint_label=swap.constraint_label,
|
||||||
|
explanation=swap.explanation,
|
||||||
|
compensation_hints=swap.compensation_hints,
|
||||||
|
))
|
||||||
|
|
||||||
|
coverage_raw = row.get("element_coverage") or {}
|
||||||
|
if isinstance(coverage_raw, str):
|
||||||
|
try:
|
||||||
|
coverage_raw = json.loads(coverage_raw)
|
||||||
|
except Exception:
|
||||||
|
coverage_raw = {}
|
||||||
|
|
||||||
|
suggestions.append(RecipeSuggestion(
|
||||||
|
id=row["id"],
|
||||||
|
title=row["title"],
|
||||||
|
match_count=int(row.get("match_count") or 0),
|
||||||
|
element_coverage=coverage_raw,
|
||||||
|
swap_candidates=swap_candidates,
|
||||||
|
missing_ingredients=missing,
|
||||||
|
level=req.level,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Build grocery list — deduplicated union of all missing ingredients
|
||||||
|
seen: set[str] = set()
|
||||||
|
grocery_list: list[str] = []
|
||||||
|
for s in suggestions:
|
||||||
|
for item in s.missing_ingredients:
|
||||||
|
if item not in seen:
|
||||||
|
grocery_list.append(item)
|
||||||
|
seen.add(item)
|
||||||
|
|
||||||
|
return RecipeResult(suggestions=suggestions, element_gaps=gaps, grocery_list=grocery_list)
|
||||||
108
tests/services/recipe/test_recipe_engine.py
Normal file
108
tests/services/recipe/test_recipe_engine.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import pytest, json
|
||||||
|
from tests.services.recipe.test_element_classifier import store_with_profiles
|
||||||
|
from tests.db.test_store_recipes import store_with_recipes
|
||||||
|
|
||||||
|
|
||||||
|
def test_level1_returns_ranked_suggestions(store_with_recipes):
|
||||||
|
from app.services.recipe.recipe_engine import RecipeEngine, RecipeRequest
|
||||||
|
engine = RecipeEngine(store_with_recipes)
|
||||||
|
req = RecipeRequest(
|
||||||
|
pantry_items=["butter", "parmesan"],
|
||||||
|
level=1,
|
||||||
|
constraints=[],
|
||||||
|
)
|
||||||
|
result = engine.suggest(req)
|
||||||
|
assert len(result.suggestions) > 0
|
||||||
|
assert result.suggestions[0].title == "Butter Pasta"
|
||||||
|
|
||||||
|
|
||||||
|
def test_level1_expiry_first_requires_rate_limit_free(store_with_recipes):
|
||||||
|
from app.services.recipe.recipe_engine import RecipeEngine, RecipeRequest
|
||||||
|
engine = RecipeEngine(store_with_recipes)
|
||||||
|
for _ in range(5):
|
||||||
|
req = RecipeRequest(
|
||||||
|
pantry_items=["butter"],
|
||||||
|
level=1,
|
||||||
|
constraints=[],
|
||||||
|
expiry_first=True,
|
||||||
|
tier="free",
|
||||||
|
)
|
||||||
|
result = engine.suggest(req)
|
||||||
|
assert result.rate_limited is False
|
||||||
|
req = RecipeRequest(
|
||||||
|
pantry_items=["butter"],
|
||||||
|
level=1,
|
||||||
|
constraints=[],
|
||||||
|
expiry_first=True,
|
||||||
|
tier="free",
|
||||||
|
)
|
||||||
|
result = engine.suggest(req)
|
||||||
|
assert result.rate_limited is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_level2_returns_swap_candidates(store_with_recipes):
|
||||||
|
from app.services.recipe.recipe_engine import RecipeEngine, RecipeRequest
|
||||||
|
store_with_recipes.conn.execute("""
|
||||||
|
INSERT INTO substitution_pairs
|
||||||
|
(original_name, substitute_name, constraint_label, fat_delta, occurrence_count)
|
||||||
|
VALUES (?,?,?,?,?)
|
||||||
|
""", ("butter", "coconut oil", "vegan", -1.0, 12))
|
||||||
|
store_with_recipes.conn.commit()
|
||||||
|
|
||||||
|
engine = RecipeEngine(store_with_recipes)
|
||||||
|
req = RecipeRequest(
|
||||||
|
pantry_items=["butter", "parmesan"],
|
||||||
|
level=2,
|
||||||
|
constraints=["vegan"],
|
||||||
|
)
|
||||||
|
result = engine.suggest(req)
|
||||||
|
swapped = [s for s in result.suggestions if s.swap_candidates]
|
||||||
|
assert len(swapped) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_element_gaps_reported(store_with_recipes):
|
||||||
|
from app.services.recipe.recipe_engine import RecipeEngine, RecipeRequest
|
||||||
|
engine = RecipeEngine(store_with_recipes)
|
||||||
|
req = RecipeRequest(pantry_items=["butter"], level=1, constraints=[])
|
||||||
|
result = engine.suggest(req)
|
||||||
|
assert isinstance(result.element_gaps, list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_grocery_list_max_missing(store_with_recipes):
|
||||||
|
from app.services.recipe.recipe_engine import RecipeEngine, RecipeRequest
|
||||||
|
engine = RecipeEngine(store_with_recipes)
|
||||||
|
# Butter Pasta needs butter, pasta, parmesan. We have only butter → missing 2
|
||||||
|
req = RecipeRequest(
|
||||||
|
pantry_items=["butter"],
|
||||||
|
level=1,
|
||||||
|
constraints=[],
|
||||||
|
max_missing=2,
|
||||||
|
)
|
||||||
|
result = engine.suggest(req)
|
||||||
|
assert all(len(s.missing_ingredients) <= 2 for s in result.suggestions)
|
||||||
|
assert isinstance(result.grocery_list, list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hard_day_mode_filters_complex_methods(store_with_recipes):
|
||||||
|
from app.services.recipe.recipe_engine import RecipeEngine, RecipeRequest, _classify_method_complexity
|
||||||
|
# Test the classifier directly
|
||||||
|
assert _classify_method_complexity(["mix all ingredients", "stir to combine"]) == "easy"
|
||||||
|
assert _classify_method_complexity(["sauté onions", "braise for 2 hours"]) == "involved"
|
||||||
|
|
||||||
|
# With hard_day_mode, involved recipes should be filtered out
|
||||||
|
# Seed a hard recipe into the store
|
||||||
|
store_with_recipes.conn.execute("""
|
||||||
|
INSERT INTO recipes (external_id, title, ingredients, ingredient_names,
|
||||||
|
directions, category, keywords, element_coverage)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?)
|
||||||
|
""", ("99", "Braised Short Ribs",
|
||||||
|
'["butter","beef ribs"]', '["butter","beef ribs"]',
|
||||||
|
'["braise short ribs for 3 hours","reduce sauce"]',
|
||||||
|
"Meat", '[]', '{"Richness":0.8}'))
|
||||||
|
store_with_recipes.conn.commit()
|
||||||
|
|
||||||
|
engine = RecipeEngine(store_with_recipes)
|
||||||
|
req_hard = RecipeRequest(pantry_items=["butter"], level=1, constraints=[], hard_day_mode=True)
|
||||||
|
result = engine.suggest(req_hard)
|
||||||
|
titles = [s.title for s in result.suggestions]
|
||||||
|
assert "Braised Short Ribs" not in titles
|
||||||
Loading…
Reference in a new issue