Public Access
326 lines
10 KiB
Python
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,
|
|
)
|