#55 — Complexity rating on recipe cards: - Derived from direction text via _classify_method_complexity() - Badge displayed on every card: easy (green), moderate (amber), involved (red) - Filterable via complexity filter chips in the results bar #58 — Cooking time + difficulty as filter domains: - estimated_time_min derived from step count + complexity - Time hint (~Nm) shown on every card - complexity_filter and max_time_min fields in RecipeRequest - Both applied in the engine before suggestions are built #53 — Surprise Me: picks a random suggestion from the filtered pool, avoids repeating the last pick. Shown in a spotlight card. #57 — Just Pick One: surfaces the top-matched suggestion in the same spotlight card. One tap to commit to cooking it. Closes #55, #58, #53, #57
136 lines
4.5 KiB
Python
136 lines
4.5 KiB
Python
"""Pydantic schemas for the recipe engine API."""
|
|
from __future__ import annotations
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class SwapCandidate(BaseModel):
|
|
original_name: str
|
|
substitute_name: str
|
|
constraint_label: str
|
|
explanation: str
|
|
compensation_hints: list[dict] = Field(default_factory=list)
|
|
|
|
|
|
class NutritionPanel(BaseModel):
|
|
"""Per-recipe macro summary. All values are per-serving when servings is known,
|
|
otherwise for the full recipe. None means data is unavailable."""
|
|
calories: float | None = None
|
|
fat_g: float | None = None
|
|
protein_g: float | None = None
|
|
carbs_g: float | None = None
|
|
fiber_g: float | None = None
|
|
sugar_g: float | None = None
|
|
sodium_mg: float | None = None
|
|
servings: float | None = None
|
|
estimated: bool = False # True when nutrition was inferred from ingredient profiles
|
|
|
|
|
|
class RecipeSuggestion(BaseModel):
|
|
id: int
|
|
title: str
|
|
match_count: int
|
|
element_coverage: dict[str, float] = Field(default_factory=dict)
|
|
swap_candidates: list[SwapCandidate] = Field(default_factory=list)
|
|
matched_ingredients: list[str] = Field(default_factory=list)
|
|
missing_ingredients: list[str] = Field(default_factory=list)
|
|
directions: list[str] = Field(default_factory=list)
|
|
prep_notes: list[str] = Field(default_factory=list)
|
|
notes: str = ""
|
|
level: int = 1
|
|
is_wildcard: bool = False
|
|
nutrition: NutritionPanel | None = None
|
|
source_url: str | None = None
|
|
complexity: str | None = None # 'easy' | 'moderate' | 'involved'
|
|
estimated_time_min: int | None = None # derived from step count + method signals
|
|
|
|
|
|
class GroceryLink(BaseModel):
|
|
ingredient: str
|
|
retailer: str
|
|
url: str
|
|
|
|
|
|
class RecipeResult(BaseModel):
|
|
suggestions: list[RecipeSuggestion]
|
|
element_gaps: list[str]
|
|
grocery_list: list[str] = Field(default_factory=list)
|
|
grocery_links: list[GroceryLink] = Field(default_factory=list)
|
|
rate_limited: bool = False
|
|
rate_limit_count: int = 0
|
|
orch_fallback: bool = False # True when orch budget exhausted; fell back to local LLM
|
|
|
|
|
|
class NutritionFilters(BaseModel):
|
|
"""Optional per-serving upper bounds for macro filtering. None = no filter."""
|
|
max_calories: float | None = None
|
|
max_sugar_g: float | None = None
|
|
max_carbs_g: float | None = None
|
|
max_sodium_mg: float | None = None
|
|
|
|
|
|
class RecipeRequest(BaseModel):
|
|
pantry_items: list[str]
|
|
level: int = Field(default=1, ge=1, le=4)
|
|
constraints: list[str] = Field(default_factory=list)
|
|
expiry_first: bool = False
|
|
hard_day_mode: bool = False
|
|
max_missing: int | None = None
|
|
style_id: str | None = None
|
|
category: str | None = None
|
|
tier: str = "free"
|
|
has_byok: bool = False
|
|
wildcard_confirmed: bool = False
|
|
allergies: list[str] = Field(default_factory=list)
|
|
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
|
|
excluded_ids: list[int] = Field(default_factory=list)
|
|
shopping_mode: bool = False
|
|
pantry_match_only: bool = False # when True, only return recipes with zero missing ingredients
|
|
complexity_filter: str | None = None # 'easy' | 'moderate' | 'involved' — None = any
|
|
max_time_min: int | None = None # filter by estimated cooking time ceiling
|
|
unit_system: str = "metric" # "metric" | "imperial"
|
|
|
|
|
|
# ── Build Your Own schemas ──────────────────────────────────────────────────
|
|
|
|
|
|
class AssemblyRoleOut(BaseModel):
|
|
"""One role slot in a template, as returned by GET /api/recipes/templates."""
|
|
|
|
display: str
|
|
required: bool
|
|
keywords: list[str]
|
|
hint: str = ""
|
|
|
|
|
|
class AssemblyTemplateOut(BaseModel):
|
|
"""One assembly template, as returned by GET /api/recipes/templates."""
|
|
|
|
id: str # slug, e.g. "burrito_taco"
|
|
title: str
|
|
icon: str
|
|
descriptor: str
|
|
role_sequence: list[AssemblyRoleOut]
|
|
|
|
|
|
class RoleCandidateItem(BaseModel):
|
|
"""One candidate ingredient for a wizard picker step."""
|
|
|
|
name: str
|
|
in_pantry: bool
|
|
tags: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class RoleCandidatesResponse(BaseModel):
|
|
"""Response from GET /api/recipes/template-candidates."""
|
|
|
|
compatible: list[RoleCandidateItem] = Field(default_factory=list)
|
|
other: list[RoleCandidateItem] = Field(default_factory=list)
|
|
available_tags: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class BuildRequest(BaseModel):
|
|
"""Request body for POST /api/recipes/build."""
|
|
|
|
template_id: str
|
|
role_overrides: dict[str, str] = Field(default_factory=dict)
|