Files
Meal-Planner/backend/app/api/llm_plan.py
T
admin 25e29c714d fix(llm): Sprint 16 — switch OLLAMA_MODEL from kimi-k2.6:cloud to gpt-oss:20b
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.
2026-06-08 07:25:37 -07:00

327 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
)