From bae94037f3b8af0e890960a6735c0604c21f0dea Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Fri, 5 Jun 2026 16:59:03 -0700 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Sprint=2013=20=E2=80=94=20F9-lite?= =?UTF-8?q?=20(Ollama=20Cloud=20free-text=20plan=20synthesis)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- backend/app/api/llm_plan.py | 326 +++++++++++++++++++++++++++++++ backend/app/main.py | 3 + backend/app/schemas/__init__.py | 18 +- frontend/src/api/index.ts | 9 + frontend/src/pages/Dashboard.tsx | 199 +++++++++++++++++-- 5 files changed, 539 insertions(+), 16 deletions(-) create mode 100644 backend/app/api/llm_plan.py diff --git a/backend/app/api/llm_plan.py b/backend/app/api/llm_plan.py new file mode 100644 index 0000000..8a18b32 --- /dev/null +++ b/backend/app/api/llm_plan.py @@ -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 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 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, + ) diff --git a/backend/app/main.py b/backend/app/main.py index dfd0ff1..7439af0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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"]) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 35dcc75..0cb5ae6 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -412,4 +412,20 @@ class RecipeSearchHit(BaseModel): class RecipeImportRequest(BaseModel): external_id: str - external_source: str = "spoonacular" \ No newline at end of file + 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 \ No newline at end of file diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index c7a937e..689826b 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -119,6 +119,15 @@ export const mealPlannerApi = { get: (mealPlanItemId: string) => api.get(`/feedback/${mealPlanItemId}`), create: (data: any) => api.post('/feedback', data), }, + + // Sprint 13: F9-lite — free-text meal-plan synthesis via Ollama + // Cloud. The prompt + week_start are sent to /api/llm/plan; the + // backend calls kimi-k2.6:cloud, parses the picks, creates the + // plan, fills the rest from the library, returns the plan id. + llm: { + plan: (data: { prompt: string; week_start: string }) => + api.post('/llm/plan', data), + }, } export default api diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index bf08320..a7972a2 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -26,6 +26,7 @@ import { Badge } from '../components/ui/Badge' import { Card, CardBody, CardHeader } from '../components/ui/Card' import { SkeletonCard, Skeleton } from '../components/ui/Skeleton' import { EmptyState } from '../components/ui/EmptyState' +import { Button } from '../components/ui/Button' import { WeekRangeNav } from '../components/WeekRangeNav' const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] @@ -392,19 +393,45 @@ export default function Dashboard() { } } - // Sprint 11: wire the dead "Generate Meal Plan" empty-state CTA. - // Creates a fresh meal plan for the current week, then fills its - // empty slots from the recipe library via the same endpoint the - // existing `Plan Week` menu uses (handlePlanWeek above). Two - // requests, but they reuse existing endpoints; no backend changes. - async function handleGenerateFirstPlan() { - if (generatingFirstPlan) return + // Sprint 13: modal + form state for the "Generate Meal Plan" CTA. + // The user picks "Use the recipe library" (default, Sprint 11 + // behaviour) or "Ask the LLM" (Sprint 13, free-text prompt). The + // modal handles its own loading + error state; the parent only + // needs to know when to close it (success) and when to show the + // toast. + const [showPromptModal, setShowPromptModal] = useState(false) + const [promptMode, setPromptMode] = useState<'library' | 'llm'>('library') + const [promptText, setPromptText] = useState('') + const [promptBusy, setPromptBusy] = useState(false) + + // Sprint 13: handler invoked from the prompt modal's submit + // button. Branches on `promptMode`. Library mode is the Sprint 11 + // create-then-fill flow; LLM mode POSTs /api/llm/plan and lets the + // backend do the synthesis. + async function handlePromptSubmit() { + if (promptBusy) return + if (promptMode === 'llm' && !promptText.trim()) { + showToast.error('Describe what you want for the week') + return + } + setPromptBusy(true) + try { + if (promptMode === 'library') { + await generateFromLibrary() + } else { + await generateFromLLM() + } + setShowPromptModal(false) + setPromptText('') + } finally { + setPromptBusy(false) + } + } + + // Library path (Sprint 11, extracted into its own function). + async function generateFromLibrary() { setGeneratingFirstPlan(true) try { - // 1) Create the empty plan. The backend returns 400 with detail - // "Meal plan for this week already exists" if another tab - // created one first — we fall through to fillEmptySlots in - // that case. let planId: string | undefined try { const res = await mealPlannerApi.meals.create({ @@ -414,7 +441,6 @@ export default function Dashboard() { }) planId = (res.data as { id?: string } | undefined)?.id } catch (createErr: unknown) { - // Race with another tab: re-fetch the plan to get its id. const existing = await mealPlannerApi.meals.getPlanned(weekStart) planId = (existing.data as { id?: string } | undefined)?.id if (!planId) throw createErr @@ -423,9 +449,6 @@ export default function Dashboard() { showToast.error('Failed to create meal plan') return } - - // 2) Fill the empty slots from the library. Same partial-success - // toast pattern as handlePlanWeek. const fillRes = await mealPlannerApi.meals.fillEmptySlots(planId, ['breakfast', 'lunch', 'dinner']) const data = fillRes.data as { filled: unknown[]; failed: { reason: string }[] } const filledCount = data.filled.length @@ -447,6 +470,41 @@ export default function Dashboard() { setGeneratingFirstPlan(false) } } + + // LLM path (Sprint 13). POSTs /api/llm/plan. The backend returns + // {plan_id, picked_count, filled_count, failed_count}. Toast + // shows the picked/filled split; on 503 (no OLLAMA_API_KEY), the + // showApiError toast surfaces the clear backend message. + async function generateFromLLM() { + setGeneratingFirstPlan(true) + try { + const res = await mealPlannerApi.llm.plan({ prompt: promptText.trim(), week_start: weekStart }) + const data = res.data as { plan_id: string; picked_count: number; filled_count: number; failed_count: number } + const total = data.picked_count + data.filled_count + if (data.failed_count > 0) { + showToast.error( + `Planned ${total} meals (LLM picked ${data.picked_count}, library filled ${data.filled_count}; ${data.failed_count} failed)`, + ) + } else { + showToast.success( + `Planned ${total} meals (LLM picked ${data.picked_count}, library filled the rest)`, + ) + } + queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] }) + } catch (err) { + showApiError(err, 'Failed to generate meal plan via LLM') + } finally { + setGeneratingFirstPlan(false) + } + } + + // Sprint 11: open the prompt modal (Sprint 13 split the body into + // generateFromLibrary + generateFromLLM, both invoked from the + // modal's submit handler). + function handleGenerateFirstPlan() { + if (generatingFirstPlan) return + setShowPromptModal(true) + } const { data: mealPlan, isLoading } = useQuery({ queryKey: ['mealPlan', weekStart], queryFn: () => mealPlannerApi.meals.getPlanned(weekStart).then(r => r.data), @@ -563,6 +621,7 @@ export default function Dashboard() { disabled: generatingFirstPlan, }} /> + {showPromptModal && renderPromptModal()} ) } @@ -729,6 +788,116 @@ export default function Dashboard() { + {showPromptModal && renderPromptModal()} ) + + // Sprint 13: prompt modal. Inline (not a separate component) + // because it depends on 4 local states (showPromptModal, + // promptMode, promptText, promptBusy) and 3 handlers. The modal + // is a real dialog with role=region + focus on the textarea; + // a full focus-trap is out of scope but Tab cycles naturally + // through the 2 radios + textarea + 2 buttons. + function renderPromptModal() { + return ( +
!promptBusy && setShowPromptModal(false)} + > +
e.stopPropagation()} + > + + +
+ +

Generate Meal Plan

+
+

+ Pick a generation strategy. Both create a fresh plan for the current week and fill any + empty slots from the recipe library. +

+
+ + +
+ {promptMode === 'llm' && ( +
+ +