Public Access
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:
@@ -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,
|
||||||
|
)
|
||||||
@@ -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 feedback as feedback_api
|
||||||
from app.api import orchestrate as orchestrate_api
|
from app.api import orchestrate as orchestrate_api
|
||||||
from app.api import recipe_search as recipe_search_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(auth.router, prefix="/api/auth", tags=["auth"])
|
||||||
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
|
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"])
|
app.include_router(feedback_api.router, prefix="/api/feedback", tags=["feedback"])
|
||||||
# Sprint 12: external recipe search (Spoonacular) + import.
|
# Sprint 12: external recipe search (Spoonacular) + import.
|
||||||
app.include_router(recipe_search_api.router, prefix="/api/recipes", tags=["recipes"])
|
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"])
|
||||||
|
|||||||
@@ -412,4 +412,20 @@ class RecipeSearchHit(BaseModel):
|
|||||||
|
|
||||||
class RecipeImportRequest(BaseModel):
|
class RecipeImportRequest(BaseModel):
|
||||||
external_id: str
|
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
|
||||||
@@ -119,6 +119,15 @@ export const mealPlannerApi = {
|
|||||||
get: (mealPlanItemId: string) => api.get(`/feedback/${mealPlanItemId}`),
|
get: (mealPlanItemId: string) => api.get(`/feedback/${mealPlanItemId}`),
|
||||||
create: (data: any) => api.post('/feedback', data),
|
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
|
export default api
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { Badge } from '../components/ui/Badge'
|
|||||||
import { Card, CardBody, CardHeader } from '../components/ui/Card'
|
import { Card, CardBody, CardHeader } from '../components/ui/Card'
|
||||||
import { SkeletonCard, Skeleton } from '../components/ui/Skeleton'
|
import { SkeletonCard, Skeleton } from '../components/ui/Skeleton'
|
||||||
import { EmptyState } from '../components/ui/EmptyState'
|
import { EmptyState } from '../components/ui/EmptyState'
|
||||||
|
import { Button } from '../components/ui/Button'
|
||||||
import { WeekRangeNav } from '../components/WeekRangeNav'
|
import { WeekRangeNav } from '../components/WeekRangeNav'
|
||||||
|
|
||||||
const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
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.
|
// Sprint 13: modal + form state for the "Generate Meal Plan" CTA.
|
||||||
// Creates a fresh meal plan for the current week, then fills its
|
// The user picks "Use the recipe library" (default, Sprint 11
|
||||||
// empty slots from the recipe library via the same endpoint the
|
// behaviour) or "Ask the LLM" (Sprint 13, free-text prompt). The
|
||||||
// existing `Plan Week` menu uses (handlePlanWeek above). Two
|
// modal handles its own loading + error state; the parent only
|
||||||
// requests, but they reuse existing endpoints; no backend changes.
|
// needs to know when to close it (success) and when to show the
|
||||||
async function handleGenerateFirstPlan() {
|
// toast.
|
||||||
if (generatingFirstPlan) return
|
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)
|
setGeneratingFirstPlan(true)
|
||||||
try {
|
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
|
let planId: string | undefined
|
||||||
try {
|
try {
|
||||||
const res = await mealPlannerApi.meals.create({
|
const res = await mealPlannerApi.meals.create({
|
||||||
@@ -414,7 +441,6 @@ export default function Dashboard() {
|
|||||||
})
|
})
|
||||||
planId = (res.data as { id?: string } | undefined)?.id
|
planId = (res.data as { id?: string } | undefined)?.id
|
||||||
} catch (createErr: unknown) {
|
} catch (createErr: unknown) {
|
||||||
// Race with another tab: re-fetch the plan to get its id.
|
|
||||||
const existing = await mealPlannerApi.meals.getPlanned(weekStart)
|
const existing = await mealPlannerApi.meals.getPlanned(weekStart)
|
||||||
planId = (existing.data as { id?: string } | undefined)?.id
|
planId = (existing.data as { id?: string } | undefined)?.id
|
||||||
if (!planId) throw createErr
|
if (!planId) throw createErr
|
||||||
@@ -423,9 +449,6 @@ export default function Dashboard() {
|
|||||||
showToast.error('Failed to create meal plan')
|
showToast.error('Failed to create meal plan')
|
||||||
return
|
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 fillRes = await mealPlannerApi.meals.fillEmptySlots(planId, ['breakfast', 'lunch', 'dinner'])
|
||||||
const data = fillRes.data as { filled: unknown[]; failed: { reason: string }[] }
|
const data = fillRes.data as { filled: unknown[]; failed: { reason: string }[] }
|
||||||
const filledCount = data.filled.length
|
const filledCount = data.filled.length
|
||||||
@@ -447,6 +470,41 @@ export default function Dashboard() {
|
|||||||
setGeneratingFirstPlan(false)
|
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>({
|
const { data: mealPlan, isLoading } = useQuery<MealPlan | null>({
|
||||||
queryKey: ['mealPlan', weekStart],
|
queryKey: ['mealPlan', weekStart],
|
||||||
queryFn: () => mealPlannerApi.meals.getPlanned(weekStart).then(r => r.data),
|
queryFn: () => mealPlannerApi.meals.getPlanned(weekStart).then(r => r.data),
|
||||||
@@ -563,6 +621,7 @@ export default function Dashboard() {
|
|||||||
disabled: generatingFirstPlan,
|
disabled: generatingFirstPlan,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{showPromptModal && renderPromptModal()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -729,6 +788,116 @@ export default function Dashboard() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
{showPromptModal && renderPromptModal()}
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user