feat: RecipeEngine Level 1-2 — corpus match, substitution, grocery list, hard day mode
This commit is contained in:
parent
6a54204cfc
commit
37737b06de
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