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:
parent
0d65744cb6
commit
9371df1c95
16 changed files with 688 additions and 30 deletions
46
app/api/endpoints/settings.py
Normal file
46
app/api/endpoints/settings.py
Normal 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}
|
||||
|
|
@ -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"])
|
||||
6
app/db/migrations/012_user_settings.sql
Normal file
6
app/db/migrations/012_user_settings.sql
Normal 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
|
||||
);
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
210
app/services/recipe/llm_recipe.py
Normal file
210
app/services/recipe/llm_recipe.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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", ""),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
110
tests/api/test_settings.py
Normal 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
|
||||
141
tests/services/recipe/test_llm_recipe.py
Normal file
141
tests/services/recipe/test_llm_recipe.py
Normal 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"]
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue