From 9371df1c959a3d51858ed95b0c4006e0632436df Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 31 Mar 2026 14:15:18 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20recipe=20engine=20Phase=203=20=E2=80=94?= =?UTF-8?q?=20StyleAdapter,=20LLM=20levels=203-4,=20user=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/api/endpoints/settings.py | 46 +++++ app/api/routes.py | 3 +- app/db/migrations/012_user_settings.sql | 6 + app/db/store.py | 18 ++ app/models/schemas/recipe.py | 3 + app/services/recipe/llm_recipe.py | 210 ++++++++++++++++++++ app/services/recipe/recipe_engine.py | 10 + app/services/recipe/style_adapter.py | 80 ++++++-- app/styles/east_asian.yaml | 6 +- app/styles/eastern_european.yaml | 8 +- app/styles/italian.yaml | 8 +- app/styles/latin.yaml | 6 +- app/styles/mediterranean.yaml | 6 +- tests/api/test_settings.py | 110 ++++++++++ tests/services/recipe/test_llm_recipe.py | 141 +++++++++++++ tests/services/recipe/test_style_adapter.py | 57 +++++- 16 files changed, 688 insertions(+), 30 deletions(-) create mode 100644 app/api/endpoints/settings.py create mode 100644 app/db/migrations/012_user_settings.sql create mode 100644 app/services/recipe/llm_recipe.py create mode 100644 tests/api/test_settings.py create mode 100644 tests/services/recipe/test_llm_recipe.py diff --git a/app/api/endpoints/settings.py b/app/api/endpoints/settings.py new file mode 100644 index 0000000..1570cdc --- /dev/null +++ b/app/api/endpoints/settings.py @@ -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} diff --git a/app/api/routes.py b/app/api/routes.py index c07a15c..fd642c7 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -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"]) \ No newline at end of file diff --git a/app/db/migrations/012_user_settings.sql b/app/db/migrations/012_user_settings.sql new file mode 100644 index 0000000..0c2e40f --- /dev/null +++ b/app/db/migrations/012_user_settings.sql @@ -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 +); diff --git a/app/db/store.py b/app/db/store.py index c974cf1..b0480af 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -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( diff --git a/app/models/schemas/recipe.py b/app/models/schemas/recipe.py index ff61b86..cc64236 100644 --- a/app/models/schemas/recipe.py +++ b/app/models/schemas/recipe.py @@ -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) diff --git a/app/services/recipe/llm_recipe.py b/app/services/recipe/llm_recipe.py new file mode 100644 index 0000000..9081a8d --- /dev/null +++ b/app/services/recipe/llm_recipe.py @@ -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: ", + "Ingredients: ", + "Directions: ", + "Notes: ", + ] + + 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: | Ingredients: | Directions: ", + ] + + 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, + ) diff --git a/app/services/recipe/recipe_engine.py b/app/services/recipe/recipe_engine.py index 57b6fc4..06f594d 100644 --- a/app/services/recipe/recipe_engine.py +++ b/app/services/recipe/recipe_engine.py @@ -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( diff --git a/app/services/recipe/style_adapter.py b/app/services/recipe/style_adapter.py index cdb4e8c..5f405d4 100644 --- a/app/services/recipe/style_adapter.py +++ b/app/services/recipe/style_adapter.py @@ -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", ""), ) diff --git a/app/styles/east_asian.yaml b/app/styles/east_asian.yaml index 51935ab..5cfe8f8 100644 --- a/app/styles/east_asian.yaml +++ b/app/styles/east_asian.yaml @@ -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 diff --git a/app/styles/eastern_european.yaml b/app/styles/eastern_european.yaml index 09fd08f..00526d6 100644 --- a/app/styles/eastern_european.yaml +++ b/app/styles/eastern_european.yaml @@ -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 diff --git a/app/styles/italian.yaml b/app/styles/italian.yaml index 885e4a6..856d0d7 100644 --- a/app/styles/italian.yaml +++ b/app/styles/italian.yaml @@ -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 diff --git a/app/styles/latin.yaml b/app/styles/latin.yaml index 4a05b03..9ec9618 100644 --- a/app/styles/latin.yaml +++ b/app/styles/latin.yaml @@ -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 diff --git a/app/styles/mediterranean.yaml b/app/styles/mediterranean.yaml index f7652d7..8c079c7 100644 --- a/app/styles/mediterranean.yaml +++ b/app/styles/mediterranean.yaml @@ -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 diff --git a/tests/api/test_settings.py b/tests/api/test_settings.py new file mode 100644 index 0000000..f670ce5 --- /dev/null +++ b/tests/api/test_settings.py @@ -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 diff --git a/tests/services/recipe/test_llm_recipe.py b/tests/services/recipe/test_llm_recipe.py new file mode 100644 index 0000000..0588722 --- /dev/null +++ b/tests/services/recipe/test_llm_recipe.py @@ -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"] diff --git a/tests/services/recipe/test_style_adapter.py b/tests/services/recipe/test_style_adapter.py index da79072..e52705d 100644 --- a/tests/services/recipe/test_style_adapter.py +++ b/tests/services/recipe/test_style_adapter.py @@ -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]