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
@@ -36,6 +36,26 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
def _suggested_sides_html(components: dict | None) -> str:
sides = (components or {}).get("suggested_sides")
if not isinstance(sides, dict):
return ""
items = sides.get("items")
if isinstance(items, list) and items:
text = "Pair with " + " + ".join(str(item) for item in items[:2])
else:
pair = [sides.get("vegetable"), sides.get("carb")]
pair = [str(part) for part in pair if part]
if not pair:
return ""
text = "Add " + " + ".join(pair)
return (
"<p style='font-size:13px;background:#ecfdf5;color:#065f46;"
"padding:8px;border-radius:6px;margin:8px 0'>"
f"{html.escape(text)}</p>"
)
def step_scrape(run: "WeeklyRun", db: "Session") -> None:
if run.scraped_at is not None:
logger.info("step_scrape: already done for %s, skipping", run.week_start_date)
@@ -273,6 +293,7 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
f"<p style='font-size:13px;color:#888'>Est. ~${est_cost_per_serving:.2f}/serving</p>"
if est_cost_total > 0 else ""
)
sides_block = _suggested_sides_html(item.components)
item_html_parts.append(
f'<div style="border:1px solid #e5e7eb;border-radius:8px;padding:16px;margin-bottom:12px">'
@@ -280,6 +301,7 @@ def step_email(run: "WeeklyRun", db: "Session") -> None:
f'<h3 style="margin:8px 0 4px">{recipe_name}</h3>'
f'{ing_block}'
f'{instructions_block}'
f'{sides_block}'
f'{cost_block}'
f'<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px">'
f'<a href="{vote_url}&amp;scope=approve" style="display:inline-block;padding:8px 14px;'
+8 -1
View File
@@ -29,6 +29,7 @@ from app.services.planner.filter import filter_recipes
from app.services.planner.score import score_recipes
from app.services.planner.select import select_set
from app.services.planner.types import GenerationResult
from app.services.meal_pairings import components_with_suggested_sides
def _load_match_index(db: Session) -> Dict[UUID, List[dict]]:
@@ -234,9 +235,11 @@ def generate_meal_plan(
db.flush()
_dinner_days = [1, 3, 5] # Mon, Wed, Fri — spread across the week
recipe_by_id = {recipe.id: recipe for recipe in recipes}
for index, scored_recipe in enumerate(chosen):
day = _dinner_days[index] if index < len(_dinner_days) else index + 1
meal_type = MealType.DINNER
recipe = recipe_by_id.get(scored_recipe.recipe_id)
item = MealPlanItem(
meal_plan_id=plan.id,
recipe_id=scored_recipe.recipe_id,
@@ -245,7 +248,11 @@ def generate_meal_plan(
approval_status=MealPlanItemStatus.pending,
estimated_cost=scored_recipe.cost.total_cost,
score=scored_recipe.score,
components=scored_recipe.components,
components=components_with_suggested_sides(
recipe,
meal_type,
scored_recipe.components,
),
)
db.add(item)