Public Access
feat(meals): enforce three lower-calorie dinners with media
This commit is contained in:
+83
-97
@@ -1,21 +1,16 @@
|
||||
"""Sprint 13 — F9-lite: free-text meal-plan synthesis via Ollama Cloud.
|
||||
"""Free-text preferences constrained by the canonical three-dinner planner.
|
||||
|
||||
This endpoint lets the user describe what they want for the week
|
||||
("Italian-inspired, vegetarian", "easy weeknight dinners, no fish")
|
||||
and asks the configured LLM (kimi-k2.6:cloud on ollama.com) to pick
|
||||
meals from the local recipe library. The LLM's picks are inserted
|
||||
into a fresh plan; the remaining slots are filled by the existing
|
||||
Sprint 6+ `fillEmptySlots` partial-success pattern.
|
||||
meals from the local recipe library. The canonical planner validates
|
||||
and persists all picks so LLM output cannot bypass planner constraints.
|
||||
|
||||
Reuses:
|
||||
- The LLM call pattern from `app.services.llm_matcher._ask_ollama`
|
||||
(POST ${OLLAMA_BASE_URL}/chat/completions, same headers, same
|
||||
max_tokens=500, temperature=0, strip <think> blocks).
|
||||
- The Sprint 6+ `meals.create` + `meals.fillEmptySlots` semantics
|
||||
for the plan create + fill flow.
|
||||
The LLM call follows `app.services.llm_matcher._ask_ollama`; plan
|
||||
selection and persistence are delegated to `generate_meal_plan`.
|
||||
|
||||
The endpoint never crashes on LLM failure: parse errors, timeout,
|
||||
or empty picks all fall through to the `fillEmptySlots` path.
|
||||
Parse errors, timeout, invalid picks, and infeasible picks fall back to
|
||||
the canonical planner.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -29,13 +24,20 @@ from typing import List, Optional
|
||||
import requests
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models import FamilyProfile, MealPlan, MealPlanItem, MealType, Recipe
|
||||
from app.models import (
|
||||
FamilyProfile,
|
||||
MealPlan,
|
||||
MealType,
|
||||
Recipe,
|
||||
RecipeCatalogStatus,
|
||||
)
|
||||
from app.security import require_session
|
||||
from app.services.meal_pairings import components_with_suggested_sides
|
||||
from app.services.planner.generate import generate_meal_plan
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -49,7 +51,7 @@ class LLMPlanRequest(BaseModel):
|
||||
|
||||
class LLMPickedItem(BaseModel):
|
||||
day_of_week: int
|
||||
meal_type: str # "breakfast" | "lunch" | "dinner"
|
||||
meal_type: str
|
||||
recipe_id: str
|
||||
|
||||
|
||||
@@ -82,7 +84,13 @@ def _serialize_library(db: Session, profile_id: uuid.UUID) -> List[dict]:
|
||||
Sorted alphabetically by name for deterministic LLM input."""
|
||||
rows = (
|
||||
db.query(Recipe)
|
||||
.filter(Recipe.family_profile_id == profile_id)
|
||||
.filter(
|
||||
Recipe.catalog_status == RecipeCatalogStatus.ACTIVE,
|
||||
or_(
|
||||
Recipe.family_profile_id == profile_id,
|
||||
Recipe.family_profile_id.is_(None),
|
||||
),
|
||||
)
|
||||
.order_by(Recipe.name.asc())
|
||||
.limit(_MAX_LIBRARY_PROMPTED)
|
||||
.all()
|
||||
@@ -95,6 +103,7 @@ def _serialize_library(db: Session, profile_id: uuid.UUID) -> List[dict]:
|
||||
"cuisine_tags": r.cuisine_tags or [],
|
||||
"dietary_tags": r.dietary_tags or [],
|
||||
"protein_type": r.protein_type,
|
||||
"calories_per_serving": r.calories_per_serving,
|
||||
"total_time_minutes": (r.prep_time_minutes or 0) + (r.cook_time_minutes or 0),
|
||||
})
|
||||
return out
|
||||
@@ -115,7 +124,7 @@ def _ask_llm(prompt: str) -> Optional[str]:
|
||||
json={
|
||||
"model": settings.OLLAMA_MODEL,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 4000, # 47-recipe library: 21 picks × ~100 chars + reasoning + boilerplate ≈ 2100+ chars; 4000 gives 2x headroom
|
||||
"max_tokens": 4000, # Reasoning models may consume most of the budget before returning JSON.
|
||||
"temperature": 0,
|
||||
},
|
||||
timeout=_LLM_TIMEOUT_SECS,
|
||||
@@ -171,7 +180,10 @@ def _parse_picks(raw: str) -> List[dict]:
|
||||
def _validate_picks(picks: List[dict], valid_recipe_ids: set[str]) -> List[LLMPickedItem]:
|
||||
"""Drop invalid entries: missing fields, out-of-range day_of_week,
|
||||
unknown meal_type, unknown recipe_id."""
|
||||
valid_meal_types = {m.value for m in MealType}
|
||||
valid_days = {1, 3, 5}
|
||||
valid_meal_types = {MealType.DINNER.value}
|
||||
used_days: set[int] = set()
|
||||
used_recipes: set[str] = set()
|
||||
out: List[LLMPickedItem] = []
|
||||
for p in picks:
|
||||
try:
|
||||
@@ -180,13 +192,17 @@ def _validate_picks(picks: List[dict], valid_recipe_ids: set[str]) -> List[LLMPi
|
||||
rid = str(p.get("recipe_id", ""))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if day < 1 or day > 7:
|
||||
if day not in valid_days or day in used_days:
|
||||
continue
|
||||
if meal not in valid_meal_types:
|
||||
continue
|
||||
if rid not in valid_recipe_ids:
|
||||
if rid not in valid_recipe_ids or rid in used_recipes:
|
||||
continue
|
||||
out.append(LLMPickedItem(day_of_week=day, meal_type=meal, recipe_id=rid))
|
||||
used_days.add(day)
|
||||
used_recipes.add(rid)
|
||||
if len(out) == 3:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
@@ -194,14 +210,17 @@ def _validate_picks(picks: List[dict], valid_recipe_ids: set[str]) -> List[LLMPi
|
||||
def synthesize_plan(
|
||||
payload: LLMPlanRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_user: str = Depends(require_session),
|
||||
session_family_id: str = Depends(require_session),
|
||||
) -> LLMPlanResponse:
|
||||
"""Synthesize a 7-day meal plan from a free-text prompt + the
|
||||
local recipe library via the configured LLM. Falls through to
|
||||
the Sprint 6+ library-fill for any slots the LLM didn't pick."""
|
||||
"""Use LLM preferences only when the canonical planner accepts all three."""
|
||||
_ensure_ollama_configured()
|
||||
|
||||
profile = db.query(FamilyProfile).first()
|
||||
try:
|
||||
profile_id = uuid.UUID(session_family_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="family profile not found")
|
||||
|
||||
profile = db.query(FamilyProfile).filter(FamilyProfile.id == profile_id).first()
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="family profile not found")
|
||||
|
||||
@@ -234,21 +253,19 @@ def synthesize_plan(
|
||||
f"cuisine={','.join(r['cuisine_tags']) or 'n/a'} | "
|
||||
f"diet={','.join(r['dietary_tags']) or 'n/a'} | "
|
||||
f"protein={r['protein_type'] or 'n/a'} | "
|
||||
f"calories={r['calories_per_serving'] or 'unknown'} | "
|
||||
f"time={r['total_time_minutes']}min"
|
||||
for r in library
|
||||
)
|
||||
prompt = (
|
||||
"You are planning a 7-day meal plan (Monday through Sunday) "
|
||||
"for a family. Each day has 3 meals: breakfast, lunch, dinner. "
|
||||
"Pick up to 21 meals total from the recipe library below. "
|
||||
"If a slot has no good match for the user's request, OMIT it "
|
||||
"(do not invent a recipe). Use only recipe_ids from the list.\n\n"
|
||||
"You are choosing exactly 3 dinners for Monday, Wednesday, and Friday "
|
||||
"(day_of_week 1, 3, and 5). Pick one different recipe for each day. "
|
||||
"Do not invent recipes and use only recipe_ids from the list.\n\n"
|
||||
f"USER REQUEST: {payload.prompt}\n\n"
|
||||
f"RECIPE LIBRARY ({len(library)} recipes):\n{compact}\n\n"
|
||||
'RETURN FORMAT — valid JSON only, no markdown, no commentary:\n'
|
||||
'[\n'
|
||||
' {"day_of_week": 1, "meal_type": "breakfast", "recipe_id": "<uuid>"},\n'
|
||||
' {"day_of_week": 1, "meal_type": "lunch", "recipe_id": "<uuid>"},\n'
|
||||
' {"day_of_week": 1, "meal_type": "dinner", "recipe_id": "<uuid>"},\n'
|
||||
' ...\n'
|
||||
"]"
|
||||
)
|
||||
@@ -258,80 +275,49 @@ 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),
|
||||
)
|
||||
|
||||
# 4) Create the empty plan.
|
||||
plan = MealPlan(
|
||||
family_profile_id=profile.id,
|
||||
week_start_date=payload.week_start,
|
||||
status="draft",
|
||||
notes=f"LLM-synthesized from prompt: {payload.prompt[:200]}",
|
||||
)
|
||||
db.add(plan)
|
||||
db.flush()
|
||||
picked_count = 0
|
||||
result = None
|
||||
if len(picks) == 3:
|
||||
preferred_ids = {uuid.UUID(pick.recipe_id) for pick in picks}
|
||||
active_ids = {
|
||||
recipe_id
|
||||
for (recipe_id,) in db.query(Recipe.id).filter(
|
||||
Recipe.catalog_status == RecipeCatalogStatus.ACTIVE
|
||||
).all()
|
||||
}
|
||||
result = generate_meal_plan(
|
||||
db,
|
||||
family_id=profile.id,
|
||||
week_start_date=payload.week_start,
|
||||
exclude_recipe_ids=active_ids - preferred_ids,
|
||||
)
|
||||
if len(result.selected) == 3:
|
||||
picked_count = 3
|
||||
else:
|
||||
generated_plan = db.query(MealPlan).filter(MealPlan.id == result.meal_plan_id).first()
|
||||
if generated_plan is not None:
|
||||
db.delete(generated_plan)
|
||||
db.commit()
|
||||
result = None
|
||||
|
||||
# 5) Insert the LLM-picked items.
|
||||
for pick in picks:
|
||||
db.add(MealPlanItem(
|
||||
meal_plan_id=plan.id,
|
||||
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()
|
||||
if result is None:
|
||||
result = generate_meal_plan(
|
||||
db,
|
||||
family_id=profile.id,
|
||||
week_start_date=payload.week_start,
|
||||
)
|
||||
|
||||
picked_count = len(picks)
|
||||
picked_slots = {(p.day_of_week, p.meal_type) for p in picks}
|
||||
|
||||
# 6) Fill the rest from the library (Sprint 6+ pattern).
|
||||
# Re-implemented inline (not via HTTP) to avoid a self-call.
|
||||
requested_meal_types = ["breakfast", "lunch", "dinner"]
|
||||
used_recipe_ids = {uuid.UUID(p.recipe_id) for p in picks}
|
||||
filled_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for day in range(1, 8):
|
||||
for mt in requested_meal_types:
|
||||
if (day, mt) in picked_slots:
|
||||
continue
|
||||
# Pick the first library recipe not already used in the plan
|
||||
# and with a matching meal_type preference (best-effort). The
|
||||
# LLM call above was the smart pick; the library fill is the
|
||||
# fallback for any slots the LLM didn't cover.
|
||||
candidates = [r for r in library if uuid.UUID(r["id"]) not in used_recipe_ids]
|
||||
if not candidates:
|
||||
failed_count += 1
|
||||
continue
|
||||
chosen = candidates[0]
|
||||
db.add(MealPlanItem(
|
||||
meal_plan_id=plan.id,
|
||||
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
|
||||
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
selected_count = len(result.selected)
|
||||
filled_count = selected_count - picked_count
|
||||
failed_count = max(0, 3 - selected_count)
|
||||
|
||||
return LLMPlanResponse(
|
||||
plan_id=str(plan.id),
|
||||
plan_id=str(result.meal_plan_id),
|
||||
picked_count=picked_count,
|
||||
filled_count=filled_count,
|
||||
failed_count=failed_count,
|
||||
|
||||
Reference in New Issue
Block a user