"""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 … reasoning blocks return re.sub(r".*?", "", 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": ""},\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, )