kiwi/tests/services/recipe/test_llm_recipe.py
pyr0ball 9371df1c95 feat: recipe engine Phase 3 — StyleAdapter, LLM levels 3-4, user settings
Task 13: StyleAdapter with 5 cuisine templates (Italian, Latin, East Asian,
Eastern European, Mediterranean). Each template includes weighted method_bias
(sums to 1.0), element-filtered aromatics/depth/structure helpers, and
seasoning/finishing-fat vectors. StyleTemplate is a fully immutable frozen
dataclass with tuple fields.

Task 14: LLMRecipeGenerator for Levels 3 and 4. Level 3 builds a structured
element-scaffold prompt; Level 4 generates a minimal wildcard prompt (<1500
chars). Allergy hard-exclusion wired through RecipeRequest.allergies into
both prompt builders and the generate() call path. Parsed LLM response
(title, ingredients, directions, notes) fully propagated to RecipeSuggestion.

Task 15: User settings key-value store. Migration 012 adds user_settings
table. Store.get_setting / set_setting with upsert. GET/PUT /settings/{key}
endpoints with Pydantic SettingBody, key allowlist, get_session dependency.
RecipeEngine reads cooking_equipment from settings when hard_day_mode=True.

55 tests passing.
2026-03-31 14:15:18 -07:00

141 lines
4.4 KiB
Python

"""Tests for LLMRecipeGenerator — prompt builders and allergy filtering."""
from __future__ import annotations
import pytest
from app.models.schemas.recipe import RecipeRequest
from app.services.recipe.element_classifier import IngredientProfile
def _make_store():
"""Create a minimal in-memory Store."""
from app.db.store import Store
import sqlite3
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
store = Store.__new__(Store)
store.conn = conn
return store
def test_build_level3_prompt_contains_element_scaffold():
"""Level 3 prompt includes element coverage, pantry items, and constraints."""
from app.services.recipe.llm_recipe import LLMRecipeGenerator
store = _make_store()
gen = LLMRecipeGenerator(store)
req = RecipeRequest(
pantry_items=["butter", "mushrooms"],
level=3,
constraints=["vegetarian"],
)
profiles = [
IngredientProfile(name="butter", elements=["Richness"]),
IngredientProfile(name="mushrooms", elements=["Depth"]),
]
gaps = ["Brightness", "Aroma"]
prompt = gen.build_level3_prompt(req, profiles, gaps)
assert "Richness" in prompt
assert "Depth" in prompt
assert "Brightness" in prompt
assert "butter" in prompt
assert "vegetarian" in prompt
def test_build_level4_prompt_contains_pantry_and_constraints():
"""Level 4 prompt is concise and includes key context."""
from app.services.recipe.llm_recipe import LLMRecipeGenerator
store = _make_store()
gen = LLMRecipeGenerator(store)
req = RecipeRequest(
pantry_items=["pasta", "eggs", "mystery ingredient"],
level=4,
constraints=["no gluten"],
allergies=["gluten"],
wildcard_confirmed=True,
)
prompt = gen.build_level4_prompt(req)
assert "mystery" in prompt.lower()
assert "gluten" in prompt.lower()
assert len(prompt) < 1500
def test_allergy_items_excluded_from_prompt():
"""Allergy items are listed as forbidden AND filtered from pantry shown to LLM."""
from app.services.recipe.llm_recipe import LLMRecipeGenerator
store = _make_store()
gen = LLMRecipeGenerator(store)
req = RecipeRequest(
pantry_items=["olive oil", "peanuts", "garlic"],
level=3,
constraints=[],
allergies=["peanuts"],
)
profiles = [
IngredientProfile(name="olive oil", elements=["Richness"]),
IngredientProfile(name="peanuts", elements=["Texture"]),
IngredientProfile(name="garlic", elements=["Aroma"]),
]
gaps: list[str] = []
prompt = gen.build_level3_prompt(req, profiles, gaps)
# Check peanuts are in the exclusion section but NOT in the pantry section
lines = prompt.split("\n")
pantry_line = next((l for l in lines if l.startswith("Pantry")), "")
exclusion_line = next(
(l for l in lines if "must not" in l.lower()),
"",
)
assert "peanuts" not in pantry_line.lower()
assert "peanuts" in exclusion_line.lower()
assert "olive oil" in prompt.lower()
def test_generate_returns_result_when_llm_responds(monkeypatch):
"""generate() returns RecipeResult with title when LLM returns a valid response."""
from app.services.recipe.llm_recipe import LLMRecipeGenerator
from app.models.schemas.recipe import RecipeResult
store = _make_store()
gen = LLMRecipeGenerator(store)
canned_response = (
"Title: Mushroom Butter Pasta\n"
"Ingredients: butter, mushrooms, pasta\n"
"Directions: Cook pasta. Sauté mushrooms in butter. Combine.\n"
"Notes: Add parmesan to taste.\n"
)
monkeypatch.setattr(gen, "_call_llm", lambda prompt: canned_response)
req = RecipeRequest(
pantry_items=["butter", "mushrooms", "pasta"],
level=3,
constraints=["vegetarian"],
)
profiles = [
IngredientProfile(name="butter", elements=["Richness"]),
IngredientProfile(name="mushrooms", elements=["Depth"]),
]
gaps = ["Brightness"]
result = gen.generate(req, profiles, gaps)
assert isinstance(result, RecipeResult)
assert len(result.suggestions) == 1
suggestion = result.suggestions[0]
assert suggestion.title == "Mushroom Butter Pasta"
assert "butter" in suggestion.missing_ingredients
assert len(suggestion.directions) > 0
assert "parmesan" in suggestion.notes.lower()
assert result.element_gaps == ["Brightness"]