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.
141 lines
4.4 KiB
Python
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"]
|