From 7f5757094e85bcc238100c5202440fbf2a0fdd44 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Mon, 29 Jun 2026 14:59:30 -0700 Subject: [PATCH] feat(meals): suggest complementary sides --- backend/app/api/llm_plan.py | 13 +++ backend/app/api/meal_plans.py | 8 +- backend/app/api/meals.py | 3 + backend/app/schemas/__init__.py | 4 +- backend/app/schemas/meal_plan_generation.py | 4 +- backend/app/services/meal_pairings.py | 116 ++++++++++++++++++++ backend/app/services/orchestrator/steps.py | 22 ++++ backend/app/services/planner/generate.py | 9 +- backend/tests/test_meal_pairings.py | 56 ++++++++++ frontend/src/pages/Dashboard.tsx | 21 +++- frontend/src/pages/MealDetail.tsx | 30 ++++- frontend/src/types/index.ts | 17 +++ 12 files changed, 294 insertions(+), 9 deletions(-) create mode 100644 backend/app/services/meal_pairings.py create mode 100644 backend/tests/test_meal_pairings.py diff --git a/backend/app/api/llm_plan.py b/backend/app/api/llm_plan.py index 2bcccb5..ec1958b 100644 --- a/backend/app/api/llm_plan.py +++ b/backend/app/api/llm_plan.py @@ -35,6 +35,7 @@ from app.config import settings from app.database import get_db from app.models import FamilyProfile, MealPlan, MealPlanItem, MealType, Recipe from app.security import require_session +from app.services.meal_pairings import components_with_suggested_sides logger = logging.getLogger(__name__) @@ -257,6 +258,10 @@ def synthesize_plan( raw_picks = _parse_picks(raw or "") valid_recipe_ids = {r["id"] for r in library} picks = _validate_picks(raw_picks, valid_recipe_ids) + recipe_by_id = { + str(r.id): r + for r in db.query(Recipe).filter(Recipe.id.in_([uuid.UUID(rid) for rid in valid_recipe_ids])).all() + } logger.info( "LLM plan: prompt=%d chars, raw_picks=%d, valid_picks=%d", len(payload.prompt), len(raw_picks), len(picks), @@ -279,6 +284,10 @@ def synthesize_plan( recipe_id=uuid.UUID(pick.recipe_id), day_of_week=pick.day_of_week, meal_type=MealType(pick.meal_type), + components=components_with_suggested_sides( + recipe_by_id.get(pick.recipe_id), + pick.meal_type, + ), )) db.flush() @@ -310,6 +319,10 @@ def synthesize_plan( recipe_id=uuid.UUID(chosen["id"]), day_of_week=day, meal_type=MealType(mt), + components=components_with_suggested_sides( + recipe_by_id.get(chosen["id"]), + mt, + ), )) used_recipe_ids.add(uuid.UUID(chosen["id"])) filled_count += 1 diff --git a/backend/app/api/meal_plans.py b/backend/app/api/meal_plans.py index 79bcb2c..f76b2ed 100644 --- a/backend/app/api/meal_plans.py +++ b/backend/app/api/meal_plans.py @@ -30,6 +30,10 @@ admin_router = APIRouter( public_router = APIRouter(prefix="/api/meal-plans", tags=["meal-plans"]) +def _components_payload(components: dict | None) -> dict: + return dict(components or {}) + + def _to_response(week_start, plan_id, items: List[MealPlanItem], result: GenerationResult) -> GenerationResponse: item_payloads: List[GenerationItem] = [] score_by_recipe = {s.recipe_id: s for s in result.selected} @@ -41,7 +45,7 @@ def _to_response(week_start, plan_id, items: List[MealPlanItem], result: Generat day_of_week=it.day_of_week, estimated_cost=it.estimated_cost or 0, score=scored.score if scored else 0.0, - components={k: float(v) for k, v in (scored.components.items() if scored else [])}, + components=_components_payload(it.components or (scored.components if scored else None)), ) ) return GenerationResponse( @@ -135,7 +139,7 @@ def get_plan(plan_id: UUID, db: Session = Depends(get_db)) -> GenerationResponse day_of_week=it.day_of_week, estimated_cost=it.estimated_cost or 0, score=it.score or 0.0, - components={k: float(v) for k, v in (it.components.items() if it.components else [])}, + components=_components_payload(it.components), ) for it in items ], diff --git a/backend/app/api/meals.py b/backend/app/api/meals.py index ad94d51..e61d0c7 100644 --- a/backend/app/api/meals.py +++ b/backend/app/api/meals.py @@ -19,6 +19,7 @@ from app.schemas import ( from app.security import require_session from app.services import approval as approval_service from app.services.feedback_analyzer import FeedbackAnalyzer +from app.services.meal_pairings import components_with_suggested_sides from uuid import UUID from typing import List, Optional from datetime import datetime, timedelta, timezone @@ -683,6 +684,7 @@ def generate_single_item( day_of_week=day_of_week, meal_type=MealType[meal_type.upper()], approval_status=MealPlanItemStatus.pending, + components=components_with_suggested_sides(recipe, meal_type), ) db.add(new_item) db.commit() @@ -769,6 +771,7 @@ def fill_empty_slots( day_of_week=day, meal_type=MealType[mt.upper()], approval_status=MealPlanItemStatus.pending, + components=components_with_suggested_sides(recipe, mt), ) db.add(new_item) try: diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 0cb5ae6..c6b3dae 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -211,7 +211,7 @@ class MealPlanItemResponse(MealPlanItemBase): denial_expires_at: Optional[datetime] = None used_pantry_items: Optional[List[UUID]] = [] score: Optional[float] = None - components: Optional[Dict[str, float]] = None + components: Optional[Dict[str, Any]] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None recipe: Optional[RecipeResponse] = None @@ -428,4 +428,4 @@ class LLMPlanResponse(BaseModel): picked_count: int filled_count: int failed_count: int - reasoning: Optional[str] = None \ No newline at end of file + reasoning: Optional[str] = None diff --git a/backend/app/schemas/meal_plan_generation.py b/backend/app/schemas/meal_plan_generation.py index 94c21da..359e755 100644 --- a/backend/app/schemas/meal_plan_generation.py +++ b/backend/app/schemas/meal_plan_generation.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import date from decimal import Decimal -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional from uuid import UUID from pydantic import BaseModel, Field @@ -27,7 +27,7 @@ class GenerationItem(BaseModel): day_of_week: int estimated_cost: Decimal score: float - components: Dict[str, float] + components: Dict[str, Any] class GenerationDebug(BaseModel): diff --git a/backend/app/services/meal_pairings.py b/backend/app/services/meal_pairings.py new file mode 100644 index 0000000..4541707 --- /dev/null +++ b/backend/app/services/meal_pairings.py @@ -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 diff --git a/backend/app/services/orchestrator/steps.py b/backend/app/services/orchestrator/steps.py index 15ac23e..7f27347 100644 --- a/backend/app/services/orchestrator/steps.py +++ b/backend/app/services/orchestrator/steps.py @@ -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 ( + "

" + f"{html.escape(text)}

" + ) + + 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"

Est. ~${est_cost_per_serving:.2f}/serving

" if est_cost_total > 0 else "" ) + sides_block = _suggested_sides_html(item.components) item_html_parts.append( f'
' @@ -280,6 +301,7 @@ def step_email(run: "WeeklyRun", db: "Session") -> None: f'

{recipe_name}

' f'{ing_block}' f'{instructions_block}' + f'{sides_block}' f'{cost_block}' f'
' f' @@ -100,6 +114,11 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, on {totalTime > 0 && `${totalTime} min · `} {item.recipe?.servings} servings

+ {sideText && ( +

+ {sideText} +

+ )}
void }) { return (
@@ -158,6 +170,8 @@ export default function MealDetail() { const totalTime = recipe.total_time_minutes ?? (recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0) + const suggestedSides = getSuggestedSides(item) + const sideText = suggestedSides ? formatSuggestedSides(suggestedSides) : null return (
@@ -240,6 +254,20 @@ export default function MealDetail() { )}
+ {sideText && ( + + +

Complete the meal

+
+ +

{sideText}

+ {suggestedSides?.note && ( +

{suggestedSides.note}

+ )} +
+
+ )} + {/* Ingredients */} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 8ac3492..04d9f52 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -62,6 +62,21 @@ export interface Recipe { created_at?: string updated_at?: string total_time_minutes?: number + side_dishes?: SideDish[] +} + +export interface SuggestedSides { + needed?: boolean + vegetable?: string + carb?: string + items?: string[] + note?: string +} + +export interface SideDish { + name: string + ingredients?: Array<{ name: string; qty: number; unit?: string }> + prep_notes?: string } export interface RecipeIngredient { @@ -117,6 +132,8 @@ export interface MealPlanItem { approval_status: 'pending' | 'approved' | 'denied' | 'swapped' denial_reason?: string denial_details?: string + score?: number | null + components?: Record | null estimated_cost?: number used_pantry_items: string[] recipe?: Recipe