Public Access
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).
431 lines
11 KiB
Python
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 |