Files
Meal-Planner/backend/app/schemas/__init__.py
T
admin bae94037f3 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).
2026-06-05 16:59:03 -07:00

431 lines
11 KiB
Python

from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal
from enum import Enum
from typing import Any, Dict, List, Optional
from uuid import UUID
from pydantic import BaseModel, Field, model_validator
# Re-export from submodules so forward references resolve
from .recipe import (
RecipeBase,
RecipeCreate,
RecipeIngredientRef,
RecipeRead,
RecipeUpdate,
ResolveIngredientCandidate,
ResolveIngredientRequest,
ResolveIngredientResponse,
)
# These may be referenced by other models; re-export as aliases if needed.
RecipeResponse = RecipeRead
class FamilyMemberRole(str, Enum):
adult = "adult"
child = "child"
class MealType(str, Enum):
breakfast = "breakfast"
lunch = "lunch"
dinner = "dinner"
class MealPlanStatus(str, Enum):
draft = "draft"
pending_approval = "pending_approval"
approved = "approved"
locked = "locked"
class MealPlanItemStatus(str, Enum):
pending = "pending"
approved = "approved"
denied = "denied"
swapped = "swapped"
class DenialReason(str, Enum):
too_expensive = "too_expensive"
boring = "boring"
disliked_ingredient = "disliked_ingredient"
cultural = "cultural"
other = "other"
class NeverSuggestReason(str, Enum):
allergy = "allergy"
dislike = "dislike"
tried_too_much = "tried_too_much"
other = "other"
class IngredientBase(BaseModel):
name: str
name_lower: str
plural_name: Optional[str] = None
aisle: Optional[str] = None
typical_price: Optional[float] = None
unit: Optional[str] = None
season_months: Optional[List[int]] = None
class IngredientResponse(IngredientBase):
id: UUID
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class IngredientCreate(IngredientBase):
pass
class FamilyMemberBase(BaseModel):
name: str
email: Optional[str] = None
role: FamilyMemberRole
likes_mushrooms: bool = False
class FamilyMemberResponse(FamilyMemberBase):
id: UUID
family_profile_id: UUID
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class FamilyMemberCreate(FamilyMemberBase):
pass
class FamilyProfileBase(BaseModel):
name: str
household_size: int
adult_count: int
child_count: int
dietary_notes: Optional[str] = None
budget_per_meal: float = 50.00
class FamilyProfileResponse(BaseModel):
id: UUID
name: str
household_size: int
adult_count: int
child_count: int
dietary_notes: Optional[str] = None
budget_per_meal: float = 50.00
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
members: List[FamilyMemberResponse] = []
planner_config: Optional[dict] = None
class Config:
from_attributes = True
class PlannerConfigOverride(BaseModel):
recency_weeks: Optional[int] = Field(default=None, ge=0)
calorie_tolerance_pct: Optional[int] = Field(default=None, ge=0, le=100)
max_total_minutes: Optional[int] = Field(default=None, ge=0)
max_meal_cost: Optional[float] = Field(default=None, ge=0)
w_savings: Optional[float] = Field(default=None, ge=0, le=1)
w_coverage: Optional[float] = Field(default=None, ge=0, le=1)
w_pantry: Optional[float] = Field(default=None, ge=0, le=1)
w_time: Optional[float] = Field(default=None, ge=0, le=1)
w_recency: Optional[float] = Field(default=None, ge=0, le=1)
time_ideal_minutes: Optional[int] = Field(default=None, ge=0)
time_full_minutes: Optional[int] = Field(default=None, ge=0)
recency_full_weeks: Optional[int] = Field(default=None, ge=0)
top_k: Optional[int] = Field(default=None, ge=1)
set_size: Optional[int] = Field(default=None, ge=1)
p_protein: Optional[float] = Field(default=None, ge=0)
p_cuisine: Optional[float] = Field(default=None, ge=0)
class PlannerConfigResponse(BaseModel):
recency_weeks: int
calorie_tolerance_pct: int
max_total_minutes: int
max_meal_cost: float
w_savings: float
w_coverage: float
w_pantry: float
w_time: float
w_recency: float
time_ideal_minutes: int
time_full_minutes: int
recency_full_weeks: int
top_k: int
set_size: int
p_protein: float
p_cuisine: float
source: str = "default"
class PlannerConfigUpdateRequest(BaseModel):
planner_config: PlannerConfigOverride
class FamilyProfileCreate(FamilyProfileBase):
pass
class FamilyProfileUpdate(BaseModel):
name: Optional[str] = None
household_size: Optional[int] = None
adult_count: Optional[int] = None
child_count: Optional[int] = None
dietary_notes: Optional[str] = None
budget_per_meal: Optional[float] = None
planner_config: Optional[dict] = None
class RecipeCreate(RecipeBase):
pass
class MealPlanItemBase(BaseModel):
recipe_id: UUID
day_of_week: int = Field(..., ge=1, le=7)
meal_type: MealType
estimated_cost: Optional[float] = None
class MealPlanItemResponse(MealPlanItemBase):
id: UUID
meal_plan_id: UUID
approval_status: MealPlanItemStatus = MealPlanItemStatus.pending
denial_reason: Optional[DenialReason] = None
denial_details: Optional[str] = None
# Sprint 8: when this denial decays. NULL = no decay (approve / never_again).
denial_expires_at: Optional[datetime] = None
used_pantry_items: Optional[List[UUID]] = []
score: Optional[float] = None
components: Optional[Dict[str, float]] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
recipe: Optional[RecipeResponse] = None
class Config:
from_attributes = True
class MealPlanItemCreate(MealPlanItemBase):
pass
class MealPlanBase(BaseModel):
week_start_date: date
status: MealPlanStatus = MealPlanStatus.draft
approval_deadline: Optional[datetime] = None
notes: Optional[str] = None
class MealPlanResponse(MealPlanBase):
id: UUID
family_profile_id: UUID
total_estimated_cost: Optional[float] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
items: List[MealPlanItemResponse] = []
class Config:
from_attributes = True
class MealPlanCreate(MealPlanBase):
items: List[MealPlanItemCreate] = []
class VoteRequest(BaseModel):
vote: bool
denial_reason: Optional[DenialReason] = None
denial_details: Optional[str] = None
# Sprint 8: "this_week" (default) or "never_again". Only honored when
# vote=False; ignored for approve votes.
denial_scope: Optional[str] = Field(None, pattern="^(this_week|never_again)$")
class VoteResponse(BaseModel):
id: UUID
meal_plan_item_id: UUID
family_member_id: UUID
vote: bool
# Sprint 8: which deny-scope the voter chose. NULL on approve votes.
denial_scope: Optional[str] = None
voted_at: Optional[datetime] = None
class Config:
from_attributes = True
class HomePantryBase(BaseModel):
ingredient_id: UUID
quantity: Optional[float] = None
unit: Optional[str] = None
expires_at: Optional[date] = None
class HomePantryResponse(HomePantryBase):
id: UUID
family_profile_id: UUID
added_at: Optional[datetime] = None
created_at: Optional[datetime] = None
ingredient: Optional[IngredientResponse] = None
class Config:
from_attributes = True
class HomePantryCreate(HomePantryBase):
pass
class HomePantryBulkCreate(BaseModel):
"""Request body for POST /api/pantry/bulk. Accepts a list of items to
add in one call; each item follows the same upsert semantics as
HomePantryCreate (insert or overwrite qty/unit/expires)."""
items: List[HomePantryCreate]
class HomePantryBulkResultItem(BaseModel):
ingredient_id: UUID
status: str # "added" | "updated" | "skipped"
id: Optional[UUID] = None
reason: Optional[str] = None
class HomePantryBulkResult(BaseModel):
"""Response body for POST /api/pantry/bulk. Reports per-item
outcomes so the UI can show a precise toast ("Added 8 items, 1
skipped — no ingredient link"). Total counts are derived for
convenience."""
added: int
updated: int
skipped: int
results: List[HomePantryBulkResultItem]
class FillEmptySlotsRequest(BaseModel):
"""Request body for POST /api/meals/{id}/fill-empty-slots. The
caller selects which meal types to fill (dinner only, or all
three). Days 1-7 are filled automatically; the backend iterates
in day order then meal_type order."""
meal_types: List[str] = Field(
default_factory=lambda: ["breakfast", "lunch", "dinner"],
description="Subset of {breakfast, lunch, dinner} to fill.",
)
class FilledSlot(BaseModel):
day_of_week: int
meal_type: str
item: MealPlanItemResponse
class FailedSlot(BaseModel):
day_of_week: int
meal_type: str
reason: str
class FillEmptySlotsResult(BaseModel):
"""Response body for POST /api/meals/{id}/fill-empty-slots.
Reports each slot as either 'filled' (with the new MealPlanItem)
or 'failed' (with a human-readable reason). Partial success is
the model: the caller decides whether to retry the failed
slots."""
filled: List[FilledSlot]
failed: List[FailedSlot]
class FeedbackBase(BaseModel):
rating: Optional[int] = Field(None, ge=1, le=5)
never_suggest: bool = False
denial_reason: Optional[DenialReason] = None
feedback_text: Optional[str] = None
class FeedbackResponse(FeedbackBase):
id: UUID
family_profile_id: UUID
family_member_id: Optional[UUID] = None
meal_plan_item_id: UUID
created_at: Optional[datetime] = None
class Config:
from_attributes = True
class FeedbackCreate(FeedbackBase):
meal_plan_item_id: UUID
class ShoppingListItem(BaseModel):
ingredient_id: Optional[UUID] = None
name: str
quantity: Optional[float] = None
unit: Optional[str] = None
aisle: Optional[str] = None
estimated_price: Optional[float] = None
is_on_sale: bool = False
sale_price: Optional[float] = None
in_season: bool = False
in_pantry: bool = False
class ShoppingListResponse(BaseModel):
week_start_date: date
items: List[ShoppingListItem]
total_estimated_cost: float
sale_items_count: int
by_aisle: dict[str, List[ShoppingListItem]]
# Sprint 12: Spoonacular external recipe search. The hit shape is
# what the webui shows in the "Search the web" panel; the import
# request is what the import button POSTs.
class RecipeSearchHit(BaseModel):
external_id: str
external_source: str = "spoonacular"
name: str
image_url: Optional[str] = None
source_url: Optional[str] = None
prep_time_minutes: Optional[int] = None
cook_time_minutes: Optional[int] = None
servings: Optional[int] = None
cuisine_tags: List[str] = Field(default_factory=list)
dietary_tags: List[str] = Field(default_factory=list)
protein_type: Optional[str] = None
calories_per_serving: Optional[int] = None
class RecipeImportRequest(BaseModel):
external_id: str
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