Public Access
feat(meals): suggest complementary sides
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
reasoning: Optional[str] = None
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}&scope=approve" style="display:inline-block;padding:8px 14px;'
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.models import MealType
|
||||
from app.services.meal_pairings import components_with_suggested_sides, suggest_sides_for_recipe
|
||||
|
||||
|
||||
def _recipe(**kwargs):
|
||||
defaults = {
|
||||
"name": "Grilled Chicken Breast",
|
||||
"protein_type": "chicken",
|
||||
"cuisine_tags": [],
|
||||
"ingredients": [],
|
||||
"side_dishes": [],
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
def test_simple_protein_gets_vegetable_and_carb_pairing():
|
||||
sides = suggest_sides_for_recipe(_recipe(), MealType.DINNER)
|
||||
|
||||
assert sides is not None
|
||||
assert sides["vegetable"] == "roasted broccoli"
|
||||
assert sides["carb"] == "rice pilaf or roasted potatoes"
|
||||
|
||||
|
||||
def test_complete_meal_does_not_get_extra_pairing():
|
||||
recipe = _recipe(name="Chicken Pasta Bake", protein_type="chicken")
|
||||
|
||||
assert suggest_sides_for_recipe(recipe, MealType.DINNER) is None
|
||||
|
||||
|
||||
def test_breakfast_does_not_get_side_pairing():
|
||||
recipe = _recipe(name="Turkey Sausage", protein_type="turkey")
|
||||
|
||||
assert suggest_sides_for_recipe(recipe, MealType.BREAKFAST) is None
|
||||
|
||||
|
||||
def test_recipe_side_dishes_are_used_when_present():
|
||||
recipe = _recipe(side_dishes=[{"name": "green salad"}, {"name": "garlic bread"}])
|
||||
|
||||
sides = suggest_sides_for_recipe(recipe, "dinner")
|
||||
|
||||
assert sides is not None
|
||||
assert sides["items"] == ["green salad", "garlic bread"]
|
||||
|
||||
|
||||
def test_components_preserve_existing_scores():
|
||||
components = components_with_suggested_sides(
|
||||
_recipe(),
|
||||
MealType.DINNER,
|
||||
{"savings": 0.25},
|
||||
)
|
||||
|
||||
assert components["savings"] == 0.25
|
||||
assert components["suggested_sides"]["needed"] is True
|
||||
Reference in New Issue
Block a user