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.
This commit is contained in:
pyr0ball 2026-03-31 14:15:18 -07:00
parent 0d65744cb6
commit 9371df1c95
16 changed files with 688 additions and 30 deletions

View file

@ -0,0 +1,46 @@
"""User settings endpoints."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.cloud_session import CloudUser, get_session
from app.db.session import get_store
from app.db.store import Store
router = APIRouter()
_ALLOWED_KEYS = frozenset({"cooking_equipment"})
class SettingBody(BaseModel):
value: str
@router.get("/{key}")
async def get_setting(
key: str,
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> dict:
"""Return the stored value for a settings key."""
if key not in _ALLOWED_KEYS:
raise HTTPException(status_code=422, detail=f"Unknown settings key: '{key}'.")
value = store.get_setting(key)
if value is None:
raise HTTPException(status_code=404, detail=f"Setting '{key}' not found.")
return {"key": key, "value": value}
@router.put("/{key}")
async def set_setting(
key: str,
body: SettingBody,
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> dict:
"""Upsert a settings key-value pair."""
if key not in _ALLOWED_KEYS:
raise HTTPException(status_code=422, detail=f"Unknown settings key: '{key}'.")
store.set_setting(key, body.value)
return {"key": key, "value": body.value}

View file

@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, staples
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples
api_router = APIRouter()
@ -9,4 +9,5 @@ api_router.include_router(ocr.router, prefix="/receipts", tags=["ocr"])
api_router.include_router(export.router, tags=["export"])
api_router.include_router(inventory.router, prefix="/inventory", tags=["inventory"])
api_router.include_router(recipes.router, prefix="/recipes", tags=["recipes"])
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
api_router.include_router(staples.router, prefix="/staples", tags=["staples"])

View file

@ -0,0 +1,6 @@
-- Migration 012: User settings key-value store.
CREATE TABLE IF NOT EXISTS user_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);

View file

@ -327,6 +327,24 @@ class Store:
self.conn.commit()
return (True, current + 1)
# ── user settings ────────────────────────────────────────────────────
def get_setting(self, key: str) -> str | None:
"""Return the value for a settings key, or None if not set."""
row = self._fetch_one(
"SELECT value FROM user_settings WHERE key = ?", (key,)
)
return row["value"] if row else None
def set_setting(self, key: str, value: str) -> None:
"""Upsert a settings key-value pair."""
self.conn.execute(
"INSERT INTO user_settings (key, value) VALUES (?, ?)"
" ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, value),
)
self.conn.commit()
# ── substitution feedback ─────────────────────────────────────────────
def log_substitution_feedback(

View file

@ -19,6 +19,8 @@ class RecipeSuggestion(BaseModel):
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)
directions: list[str] = Field(default_factory=list)
notes: str = ""
level: int = 1
is_wildcard: bool = False
@ -49,3 +51,4 @@ class RecipeRequest(BaseModel):
tier: str = "free"
has_byok: bool = False
wildcard_confirmed: bool = False
allergies: list[str] = Field(default_factory=list)

View file

@ -0,0 +1,210 @@
"""LLM-driven recipe generator for Levels 3 and 4."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.db.store import Store
from app.models.schemas.recipe import RecipeRequest, RecipeResult, RecipeSuggestion
from app.services.recipe.element_classifier import IngredientProfile
from app.services.recipe.style_adapter import StyleAdapter
logger = logging.getLogger(__name__)
def _filter_allergies(pantry_items: list[str], allergies: list[str]) -> list[str]:
"""Return pantry items with allergy matches removed (bidirectional substring)."""
if not allergies:
return list(pantry_items)
return [
item for item in pantry_items
if not any(
allergy.lower() in item.lower() or item.lower() in allergy.lower()
for allergy in allergies
)
]
class LLMRecipeGenerator:
def __init__(self, store: "Store") -> None:
self._store = store
self._style_adapter = StyleAdapter()
def build_level3_prompt(
self,
req: RecipeRequest,
profiles: list[IngredientProfile],
gaps: list[str],
) -> str:
"""Build a structured element-scaffold prompt for Level 3."""
allergy_list = req.allergies
safe_pantry = _filter_allergies(req.pantry_items, allergy_list)
covered_elements: list[str] = []
for profile in profiles:
for element in profile.elements:
if element not in covered_elements:
covered_elements.append(element)
lines: list[str] = [
"You are a creative chef. Generate a recipe using the ingredients below.",
"",
f"Pantry items: {', '.join(safe_pantry)}",
]
if req.constraints:
lines.append(f"Dietary constraints: {', '.join(req.constraints)}")
if allergy_list:
lines.append(f"IMPORTANT — must NOT contain: {', '.join(allergy_list)}")
lines.append("")
lines.append(f"Covered culinary elements: {', '.join(covered_elements) or 'none'}")
if gaps:
lines.append(
f"Missing elements to address: {', '.join(gaps)}. "
"Incorporate ingredients or techniques to fill these gaps."
)
if req.style_id:
template = self._style_adapter.get(req.style_id)
if template:
lines.append(f"Cuisine style: {template.name}")
if template.aromatics:
lines.append(f"Preferred aromatics: {', '.join(template.aromatics[:4])}")
lines += [
"",
"Reply in this format:",
"Title: <recipe name>",
"Ingredients: <comma-separated list>",
"Directions: <numbered steps>",
"Notes: <optional tips>",
]
return "\n".join(lines)
def build_level4_prompt(
self,
req: RecipeRequest,
) -> str:
"""Build a minimal wildcard prompt for Level 4."""
allergy_list = req.allergies
safe_pantry = _filter_allergies(req.pantry_items, allergy_list)
lines: list[str] = [
"Surprise me with a creative, unexpected recipe.",
f"Ingredients available: {', '.join(safe_pantry)}",
]
if req.constraints:
lines.append(f"Constraints: {', '.join(req.constraints)}")
if allergy_list:
lines.append(f"Must NOT contain: {', '.join(allergy_list)}")
lines += [
"Treat any mystery ingredient as a wildcard — use your imagination.",
"Title: <name> | Ingredients: <list> | Directions: <steps>",
]
return "\n".join(lines)
def _call_llm(self, prompt: str) -> str:
"""Call the LLM router and return the response text."""
try:
from circuitforge_core.llm.router import LLMRouter
router = LLMRouter()
return router.complete(prompt)
except Exception as exc:
logger.error("LLM call failed: %s", exc)
return ""
def _parse_response(self, response: str) -> dict[str, str | list[str]]:
"""Parse LLM response text into structured recipe fields."""
result: dict[str, str | list[str]] = {
"title": "",
"ingredients": [],
"directions": "",
"notes": "",
}
current_key: str | None = None
buffer: list[str] = []
def _flush(key: str | None, buf: list[str]) -> None:
if key is None or not buf:
return
text = " ".join(buf).strip()
if key == "ingredients":
result["ingredients"] = [i.strip() for i in text.split(",") if i.strip()]
else:
result[key] = text
for line in response.splitlines():
lower = line.lower().strip()
if lower.startswith("title:"):
_flush(current_key, buffer)
current_key, buffer = "title", [line.split(":", 1)[1].strip()]
elif lower.startswith("ingredients:"):
_flush(current_key, buffer)
current_key, buffer = "ingredients", [line.split(":", 1)[1].strip()]
elif lower.startswith("directions:"):
_flush(current_key, buffer)
current_key, buffer = "directions", [line.split(":", 1)[1].strip()]
elif lower.startswith("notes:"):
_flush(current_key, buffer)
current_key, buffer = "notes", [line.split(":", 1)[1].strip()]
elif current_key and line.strip():
buffer.append(line.strip())
_flush(current_key, buffer)
return result
def generate(
self,
req: RecipeRequest,
profiles: list[IngredientProfile],
gaps: list[str],
) -> RecipeResult:
"""Generate a recipe via LLM and return a RecipeResult."""
if req.level == 4:
prompt = self.build_level4_prompt(req)
else:
prompt = self.build_level3_prompt(req, profiles, gaps)
response = self._call_llm(prompt)
if not response:
return RecipeResult(suggestions=[], element_gaps=gaps)
parsed = self._parse_response(response)
raw_directions = parsed.get("directions", "")
directions_list: list[str] = (
[s.strip() for s in raw_directions.split(".") if s.strip()]
if isinstance(raw_directions, str)
else list(raw_directions)
)
raw_notes = parsed.get("notes", "")
notes_str: str = raw_notes if isinstance(raw_notes, str) else ""
suggestion = RecipeSuggestion(
id=0,
title=parsed.get("title") or "LLM Recipe",
match_count=len(req.pantry_items),
element_coverage={},
missing_ingredients=list(parsed.get("ingredients", [])),
directions=directions_list,
notes=notes_str,
level=req.level,
is_wildcard=(req.level == 4),
)
return RecipeResult(
suggestions=[suggestion],
element_gaps=gaps,
)

View file

@ -73,6 +73,16 @@ class RecipeEngine:
req: RecipeRequest,
available_equipment: list[str] | None = None,
) -> RecipeResult:
# Load cooking equipment from user settings when hard_day_mode is active
if req.hard_day_mode and available_equipment is None:
equipment_json = self._store.get_setting("cooking_equipment")
if equipment_json:
try:
available_equipment = json.loads(equipment_json)
except (json.JSONDecodeError, TypeError):
available_equipment = []
else:
available_equipment = []
# Rate-limit leftover mode for free tier
if req.expiry_first and req.tier == "free":
allowed, count = self._store.check_and_increment_rate_limit(

View file

@ -16,13 +16,55 @@ _STYLES_DIR = Path(__file__).parents[2] / "styles"
class StyleTemplate:
style_id: str
name: str
aromatics: list[str]
depth_sources: list[str]
brightness_sources: list[str]
method_bias: list[str]
structure_forms: list[str]
aromatics: tuple[str, ...]
depth_sources: tuple[str, ...]
brightness_sources: tuple[str, ...]
method_bias: dict[str, float]
structure_forms: tuple[str, ...]
seasoning_bias: str
finishing_fat: str
finishing_fat_str: str
def bias_aroma_selection(self, pantry_items: list[str]) -> list[str]:
"""Return aromatics present in pantry (bidirectional substring match)."""
result = []
for aroma in self.aromatics:
for item in pantry_items:
if aroma.lower() in item.lower() or item.lower() in aroma.lower():
result.append(aroma)
break
return result
def preferred_depth_sources(self, pantry_items: list[str]) -> list[str]:
"""Return depth_sources present in pantry."""
result = []
for src in self.depth_sources:
for item in pantry_items:
if src.lower() in item.lower() or item.lower() in src.lower():
result.append(src)
break
return result
def preferred_structure_forms(self, pantry_items: list[str]) -> list[str]:
"""Return structure_forms present in pantry."""
result = []
for form in self.structure_forms:
for item in pantry_items:
if form.lower() in item.lower() or item.lower() in form.lower():
result.append(form)
break
return result
def method_weights(self) -> dict[str, float]:
"""Return method bias weights."""
return dict(self.method_bias)
def seasoning_vector(self) -> str:
"""Return seasoning bias."""
return self.seasoning_bias
def finishing_fat(self) -> str:
"""Return finishing fat."""
return self.finishing_fat_str
class StyleAdapter:
@ -32,9 +74,13 @@ class StyleAdapter:
try:
template = self._load(yaml_path)
self._styles[template.style_id] = template
except (KeyError, yaml.YAMLError) as exc:
except (KeyError, yaml.YAMLError, TypeError) as exc:
raise ValueError(f"Failed to load style from {yaml_path}: {exc}") from exc
@property
def styles(self) -> dict[str, StyleTemplate]:
return self._styles
def get(self, style_id: str) -> StyleTemplate | None:
return self._styles.get(style_id)
@ -63,12 +109,12 @@ class StyleAdapter:
return {}
return {
"aroma_candidates": self.bias_aroma_selection(style_id, pantry_items),
"depth_suggestions": template.depth_sources,
"brightness_suggestions": template.brightness_sources,
"depth_suggestions": list(template.depth_sources),
"brightness_suggestions": list(template.brightness_sources),
"method_bias": template.method_bias,
"structure_forms": template.structure_forms,
"structure_forms": list(template.structure_forms),
"seasoning_bias": template.seasoning_bias,
"finishing_fat": template.finishing_fat,
"finishing_fat": template.finishing_fat_str,
}
def _load(self, path: Path) -> StyleTemplate:
@ -76,11 +122,11 @@ class StyleAdapter:
return StyleTemplate(
style_id=data["style_id"],
name=data["name"],
aromatics=data.get("aromatics", []),
depth_sources=data.get("depth_sources", []),
brightness_sources=data.get("brightness_sources", []),
method_bias=data.get("method_bias", []),
structure_forms=data.get("structure_forms", []),
aromatics=tuple(data.get("aromatics", [])),
depth_sources=tuple(data.get("depth_sources", [])),
brightness_sources=tuple(data.get("brightness_sources", [])),
method_bias=dict(data.get("method_bias", {})),
structure_forms=tuple(data.get("structure_forms", [])),
seasoning_bias=data.get("seasoning_bias", ""),
finishing_fat=data.get("finishing_fat", ""),
finishing_fat_str=data.get("finishing_fat", ""),
)

View file

@ -3,7 +3,11 @@ name: East Asian
aromatics: [ginger, scallion, sesame, star anise, five spice, sichuan pepper, lemongrass]
depth_sources: [soy sauce, miso, oyster sauce, shiitake, fish sauce, bonito]
brightness_sources: [rice vinegar, mirin, citrus zest, ponzu]
method_bias: [steam then pan-fry, wok high heat, braise in soy]
method_bias:
stir_fry: 0.35
steam: 0.25
braise: 0.20
boil: 0.20
structure_forms: [dumpling wrapper, thin noodle, rice, bao]
seasoning_bias: soy sauce
finishing_fat: toasted sesame oil

View file

@ -3,7 +3,11 @@ name: Eastern European
aromatics: [dill, caraway, marjoram, parsley, horseradish, bay leaf]
depth_sources: [sour cream, smoked meats, bacon, dried mushrooms]
brightness_sources: [sauerkraut brine, apple cider vinegar, sour cream]
method_bias: [braise, boil, bake, stuff and fold]
structure_forms: [dumpling wrapper (pierogi), bread dough, stuffed cabbage]
method_bias:
braise: 0.35
boil: 0.30
bake: 0.25
roast: 0.10
structure_forms: [dumpling wrapper, bread dough, stuffed cabbage]
seasoning_bias: kosher salt
finishing_fat: butter or lard

View file

@ -1,9 +1,13 @@
style_id: italian
name: Italian
aromatics: [basil, oregano, garlic, fennel, rosemary, thyme, sage, marjoram]
aromatics: [basil, oregano, garlic, onion, fennel, rosemary, thyme, sage, marjoram]
depth_sources: [parmesan, pecorino, anchovies, canned tomato, porcini mushrooms]
brightness_sources: [lemon, white wine, tomato, red wine vinegar]
method_bias: [low-slow braise, high-heat sear, roast]
method_bias:
braise: 0.30
roast: 0.30
saute: 0.25
simmer: 0.15
structure_forms: [pasta, wrapped, layered, risotto]
seasoning_bias: sea salt
finishing_fat: olive oil

View file

@ -3,7 +3,11 @@ name: Latin
aromatics: [cumin, chili, cilantro, epazote, mexican oregano, ancho, chipotle, smoked paprika]
depth_sources: [dried chilis, smoked peppers, chocolate, achiote]
brightness_sources: [lime, tomatillo, brined jalapeño, orange]
method_bias: [dry roast spices, high-heat sear, braise]
method_bias:
roast: 0.30
braise: 0.30
fry: 0.25
grill: 0.15
structure_forms: [wrapped in masa, pastry, stuffed, bowl]
seasoning_bias: kosher salt
finishing_fat: lard or neutral oil

View file

@ -3,7 +3,11 @@ name: Mediterranean
aromatics: [oregano, thyme, rosemary, mint, sumac, za'atar, preserved lemon]
depth_sources: [tahini, feta, halloumi, dried olives, harissa]
brightness_sources: [lemon, pomegranate molasses, yogurt, sumac]
method_bias: [roast, grill, braise with tomato]
method_bias:
roast: 0.35
grill: 0.30
braise: 0.25
saute: 0.10
structure_forms: [flatbread, stuffed vegetables, grain bowl, mezze plate]
seasoning_bias: sea salt
finishing_fat: olive oil

110
tests/api/test_settings.py Normal file
View file

@ -0,0 +1,110 @@
"""Tests for user settings endpoints."""
from __future__ import annotations
import json
from unittest.mock import MagicMock
import pytest
from fastapi.testclient import TestClient
from app.cloud_session import get_session
from app.db.session import get_store
from app.main import app
from app.models.schemas.recipe import RecipeRequest
from app.services.recipe.recipe_engine import RecipeEngine
client = TestClient(app)
def _make_session(tier: str = "free", has_byok: bool = False) -> MagicMock:
mock = MagicMock()
mock.tier = tier
mock.has_byok = has_byok
return mock
def _make_store() -> MagicMock:
mock = MagicMock()
mock.get_setting.return_value = None
mock.set_setting.return_value = None
mock.search_recipes_by_ingredients.return_value = []
mock.check_and_increment_rate_limit.return_value = (True, 1)
return mock
@pytest.fixture()
def tmp_store() -> MagicMock:
session_mock = _make_session()
store_mock = _make_store()
app.dependency_overrides[get_session] = lambda: session_mock
app.dependency_overrides[get_store] = lambda: store_mock
yield store_mock
app.dependency_overrides.clear()
def test_set_and_get_cooking_equipment(tmp_store: MagicMock) -> None:
"""PUT then GET round-trips the cooking_equipment value."""
equipment_json = '["oven", "stovetop"]'
# PUT stores the value
put_resp = client.put(
"/api/v1/settings/cooking_equipment",
json={"value": equipment_json},
)
assert put_resp.status_code == 200
assert put_resp.json()["key"] == "cooking_equipment"
assert put_resp.json()["value"] == equipment_json
tmp_store.set_setting.assert_called_once_with("cooking_equipment", equipment_json)
# GET returns the stored value
tmp_store.get_setting.return_value = equipment_json
get_resp = client.get("/api/v1/settings/cooking_equipment")
assert get_resp.status_code == 200
assert get_resp.json()["value"] == equipment_json
def test_get_missing_setting_returns_404(tmp_store: MagicMock) -> None:
"""GET an allowed key that was never set returns 404."""
tmp_store.get_setting.return_value = None
resp = client.get("/api/v1/settings/cooking_equipment")
assert resp.status_code == 404
def test_hard_day_mode_uses_equipment_setting(tmp_store: MagicMock) -> None:
"""RecipeEngine.suggest() respects cooking_equipment from store when hard_day_mode=True."""
equipment_json = '["microwave"]'
tmp_store.get_setting.return_value = equipment_json
engine = RecipeEngine(store=tmp_store)
req = RecipeRequest(
pantry_items=["rice", "water"],
level=1,
constraints=[],
hard_day_mode=True,
)
result = engine.suggest(req)
# Engine should have read the equipment setting
tmp_store.get_setting.assert_called_with("cooking_equipment")
# Result is a valid RecipeResult (no crash)
assert result is not None
assert hasattr(result, "suggestions")
def test_put_unknown_key_returns_422(tmp_store: MagicMock) -> None:
"""PUT to an unknown settings key returns 422."""
resp = client.put(
"/api/v1/settings/nonexistent_key",
json={"value": "something"},
)
assert resp.status_code == 422
def test_put_null_value_returns_422(tmp_store: MagicMock) -> None:
"""PUT with a null value returns 422 (Pydantic validation)."""
resp = client.put(
"/api/v1/settings/cooking_equipment",
json={"value": None},
)
assert resp.status_code == 422

View file

@ -0,0 +1,141 @@
"""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"]

View file

@ -1,16 +1,64 @@
from tests.services.recipe.test_element_classifier import store_with_profiles
from app.services.recipe.style_adapter import StyleAdapter
# --- Spec-required tests ---
def test_italian_style_biases_aromatics():
"""Garlic and onion appear when they're in both pantry and italian aromatics."""
adapter = StyleAdapter()
italian = adapter.get("italian")
pantry = ["garlic", "onion", "ginger"]
result = italian.bias_aroma_selection(pantry)
assert "garlic" in result
assert "onion" in result
def test_east_asian_method_weights_sum_to_one():
"""East Asian method_bias weights sum to ~1.0."""
adapter = StyleAdapter()
east_asian = adapter.get("east_asian")
weights = east_asian.method_weights()
assert abs(sum(weights.values()) - 1.0) < 1e-6
def test_style_adapter_loads_all_five_styles():
"""Adapter discovers all 5 cuisine YAML files."""
adapter = StyleAdapter()
assert len(adapter.styles) == 5
# --- Additional coverage ---
def test_load_italian_style():
from app.services.recipe.style_adapter import StyleAdapter
adapter = StyleAdapter()
italian = adapter.get("italian")
assert italian is not None
assert "basil" in italian.aromatics or "oregano" in italian.aromatics
def test_bias_aroma_toward_style(store_with_profiles):
from app.services.recipe.style_adapter import StyleAdapter
def test_bias_aroma_selection_excludes_non_style_items():
"""bias_aroma_selection does not include items not in the style's aromatics."""
adapter = StyleAdapter()
italian = adapter.get("italian")
pantry = ["butter", "parmesan", "basil", "cumin", "soy sauce"]
result = italian.bias_aroma_selection(pantry)
assert "basil" in result
assert "soy sauce" not in result
assert "cumin" not in result
def test_preferred_depth_sources():
"""preferred_depth_sources returns only depth sources present in pantry."""
adapter = StyleAdapter()
italian = adapter.get("italian")
pantry = ["parmesan", "olive oil", "pasta"]
result = italian.preferred_depth_sources(pantry)
assert "parmesan" in result
assert "olive oil" not in result
def test_bias_aroma_selection_adapter_method():
"""StyleAdapter.bias_aroma_selection returns italian-biased items."""
adapter = StyleAdapter()
pantry = ["butter", "parmesan", "basil", "cumin", "soy sauce"]
biased = adapter.bias_aroma_selection("italian", pantry)
@ -19,7 +67,6 @@ def test_bias_aroma_toward_style(store_with_profiles):
def test_list_all_styles():
from app.services.recipe.style_adapter import StyleAdapter
adapter = StyleAdapter()
styles = adapter.list_all()
style_ids = [s.style_id for s in styles]