diff --git a/app/models/schemas/recipe.py b/app/models/schemas/recipe.py new file mode 100644 index 0000000..7227d77 --- /dev/null +++ b/app/models/schemas/recipe.py @@ -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 diff --git a/app/services/recipe/recipe_engine.py b/app/services/recipe/recipe_engine.py new file mode 100644 index 0000000..7d1a167 --- /dev/null +++ b/app/services/recipe/recipe_engine.py @@ -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) diff --git a/tests/services/recipe/test_recipe_engine.py b/tests/services/recipe/test_recipe_engine.py new file mode 100644 index 0000000..acd1fe8 --- /dev/null +++ b/tests/services/recipe/test_recipe_engine.py @@ -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