feat(ui): Sprint 13 — F9-lite (Ollama Cloud free-text plan synthesis)

F9-lite reuses the pre-existing OLLAMA_* config (config.py:36-38:
OLLAMA_BASE_URL=https://ollama.com/v1, OLLAMA_API_KEY,
OLLAMA_MODEL=kimi-k2.6:cloud). Avoids the local model pull
(F9-full would be 4 GB on disk + a separate uvicorn process).
Cloud LLM — operator’s existing OLLAMA billing applies per call.

Sprint 13 splits the Sprint 11 "Generate Meal Plan" CTA into a
2-step modal: "Use the recipe library" (default, Sprint 11’s
existing flow) or "Ask the LLM" (new). The LLM path POSTs to
/api/llm/plan with a free-text prompt; the backend calls
kimi-k2.6:cloud on ollama.com, parses the LLM’s JSON picks,
creates a fresh plan, fills the LLM’s picks, and falls through
to the Sprint 6+ fillEmptySlots pattern for the slots the LLM
didn’t cover.

Backend:
- backend/app/api/llm_plan.py (NEW, ~280 lines). 1 endpoint
  (POST /api/llm/plan body {prompt, week_start}) + 4 helpers:
  - _ensure_ollama_configured — 503 on missing OLLAMA_API_KEY.
  - _serialize_library — reads up to 200 recipes for the
    family, sorted alphabetically. Cap prevents prompt-token
    overflow on kimi-k2.
  - _ask_llm — mirrors llm_matcher._ask_ollama (same URL,
    same headers, max_tokens=800, temperature=0, strips think
    blocks, 60s timeout).
  - _parse_picks — tolerant JSON parser. Handles markdown code
    fences, trailing commentary, and bare JSON. On failure
    returns []; the library fill takes over.
  - _validate_picks — drops invalid entries: missing fields,
    out-of-range day_of_week, unknown meal_type, unknown
    recipe_id. Returns a list of LLMPickedItem.
  Flow: rejects duplicate week (400) and empty library (400),
  builds the prompt, calls the LLM, validates picks, creates
  the plan, inserts the LLM-picked items, fills the rest from
  the library (Sprint 6+ pattern, re-implemented inline to
  avoid a self-HTTP-call), returns {plan_id, picked_count,
  filled_count, failed_count, reasoning}.
- backend/app/schemas/__init__.py — added LLMPlanRequest +
  LLMPlanResponse.
- backend/app/main.py:65-66 — registered llm_plan_api.router
  at the /api/llm prefix. No collision with the pre-existing
  WIP recipes.py.

Frontend:
- frontend/src/api/index.ts — added llm.plan(data) method.
- frontend/src/pages/Dashboard.tsx — added the prompt modal
  (radio for library vs. LLM + textarea for the LLM path with
  500-char counter) + new state (showPromptModal, promptMode,
  promptText, promptBusy) + extracted Sprint 11’s body into
  generateFromLibrary + added generateFromLLM. The modal is
  inline (not a separate component) because it depends on 4
  local states + 3 handlers. Click-outside-to-dismiss is
  disabled while promptBusy is true. The textarea autoFocuses
  when LLM mode is selected. Added the Button import.

LLM tolerance: a 60s timeout, parse-failure (markdown code
fences, trailing commentary), or empty response all return 0
picks; the library fill takes over. The user never sees a
crash — at worst, picked_count: 0 and the toast reads "Planned
N meals (LLM picked 0, library filled the rest)".

Verified: npm run build green (tsc 0 errors, vite 0 errors).
Bundle: 500.28 → 503.82 kB (+3.5 kB). Backend AST clean on
all 3 changed files. No new dependencies, no migration, no
pre-existing WIP files touched.

Deploy: git pull + docker compose up -d --build backend
frontend (no migration, no new dependencies).
This commit is contained in:
2026-06-05 16:59:03 -07:00
parent e939c96961
commit bae94037f3
5 changed files with 539 additions and 16 deletions
+326
View File
@@ -0,0 +1,326 @@
"""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": 800, # kimi-k2 reasons before answering; 21 picks need 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,
)
+3
View File
@@ -41,6 +41,7 @@ from app.api import meal_plans as meal_plans_api
from app.api import feedback as feedback_api
from app.api import orchestrate as orchestrate_api
from app.api import recipe_search as recipe_search_api
from app.api import llm_plan as llm_plan_api
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
@@ -61,3 +62,5 @@ app.include_router(meal_plans_api.public_router)
app.include_router(feedback_api.router, prefix="/api/feedback", tags=["feedback"])
# Sprint 12: external recipe search (Spoonacular) + import.
app.include_router(recipe_search_api.router, prefix="/api/recipes", tags=["recipes"])
# Sprint 13: F9-lite — free-text meal-plan synthesis via Ollama Cloud.
app.include_router(llm_plan_api.router, prefix="/api/llm", tags=["llm"])
+17 -1
View File
@@ -412,4 +412,20 @@ class RecipeSearchHit(BaseModel):
class RecipeImportRequest(BaseModel):
external_id: str
external_source: str = "spoonacular"
external_source: str = "spoonacular"
# Sprint 13: F9-lite — free-text meal-plan synthesis via Ollama Cloud.
# The prompt is 1-500 chars; the response mirrors the Sprint 11
# create-then-fill shape (plan_id + per-step counts).
class LLMPlanRequest(BaseModel):
prompt: str = Field(..., min_length=1, max_length=500)
week_start: date
class LLMPlanResponse(BaseModel):
plan_id: str
picked_count: int
filled_count: int
failed_count: int
reasoning: Optional[str] = None