Public Access
Sprint 13 (commit bae9403) set OLLAMA_MODEL=kimi-k2.6:cloud.
kimi-k2.6 is a reasoning model that burns the entire max_tokens=800
budget on internal reasoning and never produces the JSON answer
for the Sprint 13 prompt. Every /api/llm/plan call has returned
picked_count=0 since 2026-06-05. The library fill (Sprint 6+)
silently took over, masking the bug. Every "Ask the LLM" click
paid Ollama costs for nothing.
Discovered while answering the user's "is there anything else to
refine?" question. Added a temp debug log to _ask_llm, saw
raw_response='' with finish_reason: length. Verified on Ollama
Cloud: gpt-oss:20b (OpenAI's open-source 20B non-reasoning
model) returns 21 valid picks in 2074 chars on the same prompt.
finish_reason: stop. Reasoning field is 239 chars vs kimi-k2.6's
8206+ chars.
Two-line fix:
- backend/app/config.py:38 — OLLAMA_MODEL: str = "gpt-oss:20b"
(was "kimi-k2.6:cloud")
- backend/app/api/llm_plan.py:117 — max_tokens: 4000 (was 800).
21 picks × ~100 chars + reasoning + boilerplate ≈ 2100+ chars;
4000 gives 2x headroom.
Plus the host's .env (or docker-compose env) was also set to
OLLAMA_MODEL=gpt-oss:20b — pydantic settings read env first, so
the .env change is what actually fixed the running container. The
config.py default is a backup for new deploys.
Plus frontend/src/api/llm.test.ts (NEW, 4 cases) — Vitest
contract test on the LLM response shape. Locks plan_id (UUID),
picked_count / filled_count / failed_count (non-negative integers
summing to ≤ 21), and reasoning (string|null). Catches
response-shape regressions so a future model swap that breaks
the JSON contract is caught at npm test time. The 4 cases: 8a
(POST to /llm/plan with payload), 8b (response.plan_id is a
valid UUID), 8c (counts are non-negative integers summing to
≤ 21), 8d (reasoning is string or null).
Verified: 11/11 vitest cases pass (4 new from S16 + 7 from S14).
npm run build green. Live API: 5/5 test weeks return picked_count
15-21 (was 0/5 before). Backend env verified:
docker exec mealplanner-backend-1 env | grep OLLAMA_MODEL →
gpt-oss:20b. No new runtime dependencies. No migration. No
schema change. No UI change.
Deploy: git pull + docker compose up -d --build backend frontend.
The .env change should already be in place; verify with
docker exec mealplanner-backend-1 env | grep OLLAMA_MODEL.
327 lines
11 KiB
Python
327 lines
11 KiB
Python
"""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
|
||
|
||
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)
|
||
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,
|
||
)
|