diff --git a/app/services/recipe/style_adapter.py b/app/services/recipe/style_adapter.py new file mode 100644 index 0000000..cdb4e8c --- /dev/null +++ b/app/services/recipe/style_adapter.py @@ -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", ""), + ) diff --git a/app/styles/east_asian.yaml b/app/styles/east_asian.yaml new file mode 100644 index 0000000..51935ab --- /dev/null +++ b/app/styles/east_asian.yaml @@ -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 diff --git a/app/styles/eastern_european.yaml b/app/styles/eastern_european.yaml new file mode 100644 index 0000000..09fd08f --- /dev/null +++ b/app/styles/eastern_european.yaml @@ -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 diff --git a/app/styles/italian.yaml b/app/styles/italian.yaml new file mode 100644 index 0000000..885e4a6 --- /dev/null +++ b/app/styles/italian.yaml @@ -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 diff --git a/app/styles/latin.yaml b/app/styles/latin.yaml new file mode 100644 index 0000000..4a05b03 --- /dev/null +++ b/app/styles/latin.yaml @@ -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 diff --git a/app/styles/mediterranean.yaml b/app/styles/mediterranean.yaml new file mode 100644 index 0000000..f7652d7 --- /dev/null +++ b/app/styles/mediterranean.yaml @@ -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 diff --git a/tests/services/recipe/test_style_adapter.py b/tests/services/recipe/test_style_adapter.py new file mode 100644 index 0000000..da79072 --- /dev/null +++ b/tests/services/recipe/test_style_adapter.py @@ -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