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
+9
View File
@@ -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
+184 -15
View File
@@ -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<MealPlan | null>({
queryKey: ['mealPlan', weekStart],
queryFn: () => mealPlannerApi.meals.getPlanned(weekStart).then(r => r.data),
@@ -563,6 +621,7 @@ export default function Dashboard() {
disabled: generatingFirstPlan,
}}
/>
{showPromptModal && renderPromptModal()}
</div>
)
}
@@ -729,6 +788,116 @@ export default function Dashboard() {
</CardBody>
</Card>
</div>
{showPromptModal && renderPromptModal()}
</div>
)
// 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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
onClick={() => !promptBusy && setShowPromptModal(false)}
>
<div
className="w-full max-w-lg rounded-xl bg-white shadow-xl"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Card className="w-full max-w-lg">
<CardBody>
<div className="flex items-center gap-2 mb-3">
<Sparkles className="w-5 h-5 text-primary-600" />
<h2 className="text-lg font-semibold text-surface-900">Generate Meal Plan</h2>
</div>
<p className="text-sm text-surface-500 mb-4">
Pick a generation strategy. Both create a fresh plan for the current week and fill any
empty slots from the recipe library.
</p>
<div className="space-y-2 mb-4">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="radio"
name="prompt-mode"
value="library"
checked={promptMode === 'library'}
onChange={() => setPromptMode('library')}
disabled={promptBusy}
className="mt-1"
/>
<div>
<div className="text-sm font-medium text-surface-900">Use the recipe library</div>
<div className="text-xs text-surface-500">Faster; no external API. Default.</div>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="radio"
name="prompt-mode"
value="llm"
checked={promptMode === 'llm'}
onChange={() => setPromptMode('llm')}
disabled={promptBusy}
className="mt-1"
/>
<div>
<div className="text-sm font-medium text-surface-900">Ask the LLM</div>
<div className="text-xs text-surface-500">
Free-text prompt; kimi-k2.6:cloud picks meals, library fills the rest.
</div>
</div>
</label>
</div>
{promptMode === 'llm' && (
<div className="mb-4">
<label htmlFor="llm-prompt" className="block text-sm font-medium text-surface-700 mb-1">
What do you want for the week?
</label>
<textarea
id="llm-prompt"
autoFocus
value={promptText}
onChange={(e) => setPromptText(e.target.value)}
disabled={promptBusy}
rows={3}
maxLength={500}
placeholder="e.g., Italian-inspired, vegetarian, easy weeknight dinners"
className="w-full rounded-lg border border-surface-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<div className="mt-1 text-xs text-surface-500 text-right">
{promptText.length} / 500
</div>
</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setShowPromptModal(false)}
disabled={promptBusy}
>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={handlePromptSubmit}
disabled={promptBusy}
icon={promptBusy ? <Loader2 className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
>
{promptBusy
? promptMode === 'llm' ? 'Asking LLM' : 'Generating'
: 'Generate'}
</Button>
</div>
</CardBody>
</Card>
</div>
</div>
)
}
}