feat: StyleAdapter — 5 cuisine templates with element dimension biasing
This commit is contained in:
parent
ea22dc8b47
commit
0d65744cb6
7 changed files with 159 additions and 0 deletions
86
app/services/recipe/style_adapter.py
Normal file
86
app/services/recipe/style_adapter.py
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
"""
|
||||||
|
StyleAdapter — cuisine-mode overlay that biases element dimensions.
|
||||||
|
YAML templates in app/styles/.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
_STYLES_DIR = Path(__file__).parents[2] / "styles"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
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]
|
||||||
|
seasoning_bias: str
|
||||||
|
finishing_fat: str
|
||||||
|
|
||||||
|
|
||||||
|
class StyleAdapter:
|
||||||
|
def __init__(self, styles_dir: Path = _STYLES_DIR) -> None:
|
||||||
|
self._styles: dict[str, StyleTemplate] = {}
|
||||||
|
for yaml_path in sorted(styles_dir.glob("*.yaml")):
|
||||||
|
try:
|
||||||
|
template = self._load(yaml_path)
|
||||||
|
self._styles[template.style_id] = template
|
||||||
|
except (KeyError, yaml.YAMLError) as exc:
|
||||||
|
raise ValueError(f"Failed to load style from {yaml_path}: {exc}") from exc
|
||||||
|
|
||||||
|
def get(self, style_id: str) -> StyleTemplate | None:
|
||||||
|
return self._styles.get(style_id)
|
||||||
|
|
||||||
|
def list_all(self) -> list[StyleTemplate]:
|
||||||
|
return list(self._styles.values())
|
||||||
|
|
||||||
|
def bias_aroma_selection(self, style_id: str, pantry_items: list[str]) -> list[str]:
|
||||||
|
"""Return pantry items that match the style's preferred aromatics.
|
||||||
|
Falls back to all pantry items if no match found."""
|
||||||
|
template = self._styles.get(style_id)
|
||||||
|
if not template:
|
||||||
|
return pantry_items
|
||||||
|
matched = [
|
||||||
|
item for item in pantry_items
|
||||||
|
if any(
|
||||||
|
aroma.lower() in item.lower() or item.lower() in aroma.lower()
|
||||||
|
for aroma in template.aromatics
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return matched if matched else pantry_items
|
||||||
|
|
||||||
|
def apply(self, style_id: str, pantry_items: list[str]) -> dict:
|
||||||
|
"""Return style-biased ingredient guidance for each element dimension."""
|
||||||
|
template = self._styles.get(style_id)
|
||||||
|
if not template:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
"aroma_candidates": self.bias_aroma_selection(style_id, pantry_items),
|
||||||
|
"depth_suggestions": template.depth_sources,
|
||||||
|
"brightness_suggestions": template.brightness_sources,
|
||||||
|
"method_bias": template.method_bias,
|
||||||
|
"structure_forms": template.structure_forms,
|
||||||
|
"seasoning_bias": template.seasoning_bias,
|
||||||
|
"finishing_fat": template.finishing_fat,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _load(self, path: Path) -> StyleTemplate:
|
||||||
|
data = yaml.safe_load(path.read_text())
|
||||||
|
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", []),
|
||||||
|
seasoning_bias=data.get("seasoning_bias", ""),
|
||||||
|
finishing_fat=data.get("finishing_fat", ""),
|
||||||
|
)
|
||||||
9
app/styles/east_asian.yaml
Normal file
9
app/styles/east_asian.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
style_id: east_asian
|
||||||
|
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]
|
||||||
|
structure_forms: [dumpling wrapper, thin noodle, rice, bao]
|
||||||
|
seasoning_bias: soy sauce
|
||||||
|
finishing_fat: toasted sesame oil
|
||||||
9
app/styles/eastern_european.yaml
Normal file
9
app/styles/eastern_european.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
style_id: eastern_european
|
||||||
|
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]
|
||||||
|
seasoning_bias: kosher salt
|
||||||
|
finishing_fat: butter or lard
|
||||||
9
app/styles/italian.yaml
Normal file
9
app/styles/italian.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
style_id: italian
|
||||||
|
name: Italian
|
||||||
|
aromatics: [basil, oregano, garlic, 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]
|
||||||
|
structure_forms: [pasta, wrapped, layered, risotto]
|
||||||
|
seasoning_bias: sea salt
|
||||||
|
finishing_fat: olive oil
|
||||||
9
app/styles/latin.yaml
Normal file
9
app/styles/latin.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
style_id: latin
|
||||||
|
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]
|
||||||
|
structure_forms: [wrapped in masa, pastry, stuffed, bowl]
|
||||||
|
seasoning_bias: kosher salt
|
||||||
|
finishing_fat: lard or neutral oil
|
||||||
9
app/styles/mediterranean.yaml
Normal file
9
app/styles/mediterranean.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
style_id: mediterranean
|
||||||
|
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]
|
||||||
|
structure_forms: [flatbread, stuffed vegetables, grain bowl, mezze plate]
|
||||||
|
seasoning_bias: sea salt
|
||||||
|
finishing_fat: olive oil
|
||||||
28
tests/services/recipe/test_style_adapter.py
Normal file
28
tests/services/recipe/test_style_adapter.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
from tests.services.recipe.test_element_classifier import store_with_profiles
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
adapter = StyleAdapter()
|
||||||
|
pantry = ["butter", "parmesan", "basil", "cumin", "soy sauce"]
|
||||||
|
biased = adapter.bias_aroma_selection("italian", pantry)
|
||||||
|
assert "basil" in biased
|
||||||
|
assert "soy sauce" not in biased or "basil" in biased
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
|
assert "italian" in style_ids
|
||||||
|
assert "latin" in style_ids
|
||||||
|
assert "east_asian" in style_ids
|
||||||
Loading…
Reference in a new issue