"""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 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 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 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_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": ""},\n' ' {"day_of_week": 1, "meal_type": "lunch", "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), ) # 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), )) 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), )) 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, )