Files
Meal-Planner/backend/app/api/llm_plan.py
T
admin 2695ba0232
CI / backend (pytest + alembic) (push) Canceled after 0s
CI / frontend (build) (push) Canceled after 0s
feat(meals): enforce three lower-calorie dinners with media
2026-09-01 10:11:36 -07:00

326 lines
10 KiB
Python

"""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 canonical planner validates
and persists all picks so LLM output cannot bypass planner constraints.
The LLM call follows `app.services.llm_matcher._ask_ollama`; plan
selection and persistence are delegated to `generate_meal_plan`.
Parse errors, timeout, invalid picks, and infeasible picks fall back to
the canonical planner.
"""
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 import or_
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models import (
FamilyProfile,
MealPlan,
MealType,
Recipe,
RecipeCatalogStatus,
)
from app.security import require_session
from app.services.planner.generate import generate_meal_plan
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
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.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()
)
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,
"calories_per_serving": r.calories_per_serving,
"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, # Reasoning models may consume most of the budget before returning JSON.
"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_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:
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 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 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
@router.post("/plan", response_model=LLMPlanResponse)
def synthesize_plan(
payload: LLMPlanRequest,
db: Session = Depends(get_db),
session_family_id: str = Depends(require_session),
) -> LLMPlanResponse:
"""Use LLM preferences only when the canonical planner accepts all three."""
_ensure_ollama_configured()
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")
# 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"calories={r['calories_per_serving'] or 'unknown'} | "
f"time={r['total_time_minutes']}min"
for r in library
)
prompt = (
"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": "dinner", "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)
logger.info(
"LLM plan: prompt=%d chars, raw_picks=%d, valid_picks=%d",
len(payload.prompt), len(raw_picks), len(picks),
)
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
if result is None:
result = generate_meal_plan(
db,
family_id=profile.id,
week_start_date=payload.week_start,
)
selected_count = len(result.selected)
filled_count = selected_count - picked_count
failed_count = max(0, 3 - selected_count)
return LLMPlanResponse(
plan_id=str(result.meal_plan_id),
picked_count=picked_count,
filled_count=filled_count,
failed_count=failed_count,
reasoning=raw if raw else None,
)