kiwi/app/models/schemas/recipe.py
pyr0ball 2ad71f2636 feat(recipes): pantry match floor filter — 'can make now' toggle
Adds pantry_match_only flag to RecipeRequest. When enabled, any recipe
with one or more missing ingredients (after swaps) is excluded from
results. Swapped ingredients count as covered.

Frontend: toggle checkbox in recipe settings panel, disabled when
shopping mode is active (the two modes are mutually exclusive). Hides
the max-missing input when pantry-match-only is on (irrelevant there).

Closes #63
2026-04-16 09:12:24 -07:00

132 lines
4.2 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
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
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)