feat(meals): suggest complementary sides
CI / backend (pytest + alembic) (push) Has been cancelled
CI / frontend (build) (push) Has been cancelled

This commit is contained in:
2026-06-29 14:59:30 -07:00
parent 18d7300b57
commit 7f5757094e
12 changed files with 294 additions and 9 deletions
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
from typing import Any, Mapping
from app.models import MealType
_COMPLETE_MEAL_TERMS = {
"bowl", "burger", "burrito", "casserole", "chili", "curry", "fried rice",
"lasagna", "noodle", "paella", "pasta", "pizza", "quesadilla", "ramen",
"rice bowl", "risotto", "salad", "sandwich", "soup", "spaghetti", "stew",
"stir fry", "stir-fry", "taco", "tacos", "wrap",
}
_CARB_TERMS = {
"bread", "bun", "couscous", "farro", "grain", "noodle", "orzo", "pasta",
"pita", "potato", "quinoa", "rice", "tortilla",
}
_VEG_TERMS = {
"asparagus", "beans", "broccoli", "brussels", "cabbage", "carrot",
"cauliflower", "corn", "greens", "kale", "pepper", "salad", "spinach",
"vegetable", "zucchini",
}
_PROTEIN_TERMS = {
"beef", "breast", "chicken", "chop", "cod", "cutlet", "fish", "pork",
"salmon", "shrimp", "steak", "tilapia", "tofu", "turkey",
}
_PAIRINGS_BY_CUISINE = {
"asian": ("sesame broccoli", "steamed jasmine rice"),
"chinese": ("garlic green beans", "steamed jasmine rice"),
"indian": ("roasted cauliflower", "basmati rice"),
"italian": ("garlicky green beans", "orzo or crusty bread"),
"mediterranean": ("cucumber tomato salad", "warm pita or couscous"),
"mexican": ("sauteed peppers and onions", "cilantro lime rice"),
"thai": ("cucumber salad", "steamed jasmine rice"),
}
_DEFAULT_PAIRING = ("roasted broccoli", "rice pilaf or roasted potatoes")
def components_with_suggested_sides(
recipe: Any,
meal_type: MealType | str,
base_components: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
components = dict(base_components or {})
suggestion = suggest_sides_for_recipe(recipe, meal_type)
if suggestion:
components["suggested_sides"] = suggestion
return components
def suggest_sides_for_recipe(recipe: Any, meal_type: MealType | str) -> dict[str, Any] | None:
meal_value = meal_type.value if isinstance(meal_type, MealType) else str(meal_type).lower()
if meal_value == MealType.BREAKFAST.value:
return None
explicit_sides = [
side.get("name")
for side in (getattr(recipe, "side_dishes", None) or [])
if isinstance(side, dict) and side.get("name")
]
if explicit_sides:
return {
"needed": True,
"items": explicit_sides[:2],
"note": "Use the recipe's recommended side dish pairing.",
}
text = _recipe_text(recipe)
if not _looks_like_simple_protein(recipe, text):
return None
vegetable, carb = _pairing_for_cuisine(getattr(recipe, "cuisine_tags", None) or [])
return {
"needed": True,
"vegetable": vegetable,
"carb": carb,
"note": "Simple protein entree; add a vegetable and carb to make it a complete meal.",
}
def _recipe_text(recipe: Any) -> str:
parts = [
getattr(recipe, "name", "") or "",
getattr(recipe, "protein_type", "") or "",
" ".join(getattr(recipe, "cuisine_tags", None) or []),
]
for ingredient in getattr(recipe, "ingredients", None) or []:
if isinstance(ingredient, dict):
parts.append(str(ingredient.get("name") or ingredient.get("ingredient") or ""))
return " ".join(parts).lower()
def _looks_like_simple_protein(recipe: Any, text: str) -> bool:
if any(term in text for term in _COMPLETE_MEAL_TERMS):
return False
has_protein = bool(getattr(recipe, "protein_type", None)) or any(
term in text for term in _PROTEIN_TERMS
)
if not has_protein:
return False
has_carb = any(term in text for term in _CARB_TERMS)
has_veg = any(term in text for term in _VEG_TERMS)
return not (has_carb and has_veg)
def _pairing_for_cuisine(tags: list[str]) -> tuple[str, str]:
lowered = {tag.lower() for tag in tags}
for key, pairing in _PAIRINGS_BY_CUISINE.items():
if key in lowered:
return pairing
return _DEFAULT_PAIRING