Files
Meal-Planner/backend/app/api/llm_plan.py
T
admin 7f5757094e
CI / backend (pytest + alembic) (push) Has been cancelled
CI / frontend (build) (push) Has been cancelled
feat(meals): suggest complementary sides
2026-06-29 14:59:30 -07:00

340 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Sprint 13 — F9-lite: free-text meal-plan synthesis via Ollama Cloud.
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.
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 endpoint never crashes on LLM failure: parse errors, timeout,
or empty picks all fall through to the `fillEmptySlots` path.
"""
from __future__ import annotations
import json
import logging
import re
import uuid
from datetime import date
from typing import List, Optional
import requests
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
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.security import require_session
from app.services.meal_pairings import components_with_suggested_sides
logger = logging.getLogger(__name__)
router = APIRouter()
class LLMPlanRequest(BaseModel):
prompt: str = Field(..., min_length=1, max_length=500)
week_start: date
class LLMPickedItem(BaseModel):
day_of_week: int
meal_type: str # "breakfast" | "lunch" | "dinner"
recipe_id: str
class LLMPlanResponse(BaseModel):
plan_id: str
picked_count: int
filled_count: int
failed_count: int
reasoning: Optional[str] = None
# Sprint 13: cap on the library size sent to the LLM. Larger
# libraries would exceed the prompt token budget for kimi-k2.
_MAX_LIBRARY_PROMPTED = 200
_LLM_TIMEOUT_SECS = 60
def _ensure_ollama_configured() -> str:
key = settings.OLLAMA_API_KEY
if not key:
raise HTTPException(
status_code=503,
detail="OLLAMA_API_KEY not configured; set it in the backend env",
)
return key
def _serialize_library(db: Session, profile_id: uuid.UUID) -> List[dict]:
"""Read up to _MAX_LIBRARY_PROMPTED recipes for the family.
Sorted alphabetically by name for deterministic LLM input."""
rows = (
db.query(Recipe)
.filter(Recipe.family_profile_id == profile_id)
.order_by(Recipe.name.asc())
.limit(_MAX_LIBRARY_PROMPTED)
.all()
)
out = []
for r in rows:
out.append({
"id": str(r.id),
"name": r.name,
"cuisine_tags": r.cuisine_tags or [],
"dietary_tags": r.dietary_tags or [],
"protein_type": r.protein_type,
"total_time_minutes": (r.prep_time_minutes or 0) + (r.cook_time_minutes or 0),
})
return out
def _ask_llm(prompt: str) -> Optional[str]:
"""Single-shot Ollama call. Mirrors llm_matcher._ask_ollama.
Returns the raw assistant text, or None on any failure."""
if not settings.OLLAMA_API_KEY:
return None
try:
resp = requests.post(
f"{settings.OLLAMA_BASE_URL}/chat/completions",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {settings.OLLAMA_API_KEY}",
},
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
"temperature": 0,
},
timeout=_LLM_TIMEOUT_SECS,
)
resp.raise_for_status()
except requests.RequestException as exc:
logger.warning("Ollama plan call failed: %s", exc)
return None
content = resp.json().get("choices", [{}])[0].get("message", {}).get("content", "")
# Strip <think>…</think> reasoning blocks
return re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL).strip()
def _parse_picks(raw: str) -> List[dict]:
"""Parse the LLM's JSON response. Tolerant: handles markdown fences,
trailing commentary, and JSON-with-no-markdown. Returns a list of
{day_of_week, meal_type, recipe_id} dicts (validated by caller)."""
if not raw:
return []
# Strip markdown code fences if present
fence_match = re.search(r"```(?:json)?\s*(\[.*?\])\s*```", raw, flags=re.DOTALL)
if fence_match:
candidate = fence_match.group(1)
else:
# Find the first '[' and the matching ']'
start = raw.find("[")
if start == -1:
return []
depth = 0
end = -1
for i in range(start, len(raw)):
if raw[i] == "[":
depth += 1
elif raw[i] == "]":
depth -= 1
if depth == 0:
end = i + 1
break
if end == -1:
return []
candidate = raw[start:end]
try:
parsed = json.loads(candidate)
except json.JSONDecodeError:
logger.warning("LLM plan parse failed; raw=%r", raw[:300])
return []
if not isinstance(parsed, list):
return []
return parsed
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}
out: List[LLMPickedItem] = []
for p in picks:
try:
day = int(p.get("day_of_week"))
meal = str(p.get("meal_type", "")).lower()
rid = str(p.get("recipe_id", ""))
except (TypeError, ValueError):
continue
if day < 1 or day > 7:
continue
if meal not in valid_meal_types:
continue
if rid not in valid_recipe_ids:
continue
out.append(LLMPickedItem(day_of_week=day, meal_type=meal, recipe_id=rid))
return out
@router.post("/plan", response_model=LLMPlanResponse)
def synthesize_plan(
payload: LLMPlanRequest,
db: Session = Depends(get_db),
_user: 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."""
_ensure_ollama_configured()
profile = db.query(FamilyProfile).first()
if not profile:
raise HTTPException(status_code=404, detail="family profile not found")
# 1) Reject if a plan for this week already exists (idempotency
# matches the Sprint 11 `meals.create` 400 path).
existing = (
db.query(MealPlan)
.filter(
MealPlan.family_profile_id == profile.id,
MealPlan.week_start_date == payload.week_start,
)
.first()
)
if existing:
raise HTTPException(
status_code=400,
detail=f"meal plan for {payload.week_start} already exists: {existing.id}",
)
# 2) Read the recipe library + build the LLM prompt.
library = _serialize_library(db, profile.id)
if not library:
raise HTTPException(
status_code=400,
detail="recipe library is empty; import some recipes first",
)
compact = "\n".join(
f"- id={r['id']} | {r['name']} | "
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"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"
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'
' ...\n'
"]"
)
# 3) Call the LLM.
raw = _ask_llm(prompt)
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()
# 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()
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)
return LLMPlanResponse(
plan_id=str(plan.id),
picked_count=picked_count,
filled_count=filled_count,
failed_count=failed_count,
reasoning=raw if raw else None,
)