From b2c546e86a7f84dccd28da491ac8d05345e16bb4 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 18 Apr 2026 19:06:53 -0700 Subject: [PATCH] feat: wire secondary-use window hints into recipe engine (#83) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Secondary-state items (stale bread, overripe bananas, day-old rice, etc.) are now surfaced to the recipe engine so relevant recipes get matched even when the ingredient is phrased differently in the corpus (e.g. "day-old rice" vs. "rice"). Backend: - Add rice and tortillas entries to SECONDARY_WINDOW in expiration_predictor - Add secondary_pantry_items: dict[str, str] field to RecipeRequest schema (maps product_name → secondary_state label, e.g. {"Bread": "stale"}) - Add _SECONDARY_STATE_SYNONYMS lookup in recipe_engine — keyed by (category, state_label), returns corpus-matching ingredient phrases - Update _expand_pantry_set() to accept secondary_pantry_items and inject synonym terms into the expanded pantry set used for FTS matching Frontend: - Add secondary_pantry_items to RecipeRequest interface in api.ts - Add secondaryPantryItems param to _buildRequest / suggest / loadMore in the recipes store - Add secondaryPantryItems computed to RecipesView — reads secondary_state from inventory items (expired but still in secondary window) and builds the product_name → state_label map - Pass secondaryPantryItems into handleSuggest and handleLoadMore Closes #83 --- app/models/schemas/recipe.py | 4 +++ app/services/expiration_predictor.py | 12 ++++++++ app/services/recipe/recipe_engine.py | 41 +++++++++++++++++++++++-- frontend/src/components/RecipesView.vue | 17 ++++++++-- frontend/src/services/api.ts | 1 + frontend/src/stores/recipes.ts | 15 ++++++--- 6 files changed, 81 insertions(+), 9 deletions(-) diff --git a/app/models/schemas/recipe.py b/app/models/schemas/recipe.py index 50fc293..64c5298 100644 --- a/app/models/schemas/recipe.py +++ b/app/models/schemas/recipe.py @@ -71,6 +71,10 @@ class NutritionFilters(BaseModel): class RecipeRequest(BaseModel): pantry_items: list[str] + # Maps product name → secondary state label for items past nominal expiry + # but still within their secondary use window (e.g. {"Bread": "stale"}). + # Used by the recipe engine to boost recipes suited to those specific states. + secondary_pantry_items: dict[str, str] = Field(default_factory=dict) level: int = Field(default=1, ge=1, le=4) constraints: list[str] = Field(default_factory=list) expiry_first: bool = False diff --git a/app/services/expiration_predictor.py b/app/services/expiration_predictor.py index 267fc0a..ead15cf 100644 --- a/app/services/expiration_predictor.py +++ b/app/services/expiration_predictor.py @@ -194,6 +194,18 @@ class ExpirationPredictor: 'uses': ['broth', 'soups', 'risotto', 'gratins'], 'warning': None, }, + 'rice': { + 'window_days': 2, + 'label': 'day-old', + 'uses': ['fried rice', 'rice bowls', 'rice porridge'], + 'warning': 'Refrigerate immediately after cooking — do not leave at room temp.', + }, + 'tortillas': { + 'window_days': 5, + 'label': 'stale', + 'uses': ['chilaquiles', 'migas', 'tortilla soup', 'casserole'], + 'warning': None, + }, } def days_after_opening(self, category: str | None) -> int | None: diff --git a/app/services/recipe/recipe_engine.py b/app/services/recipe/recipe_engine.py index 0c9fcda..d8bdbca 100644 --- a/app/services/recipe/recipe_engine.py +++ b/app/services/recipe/recipe_engine.py @@ -155,6 +155,24 @@ _PANTRY_LABEL_SYNONYMS: dict[str, str] = { } +# When a pantry item is in a secondary state (e.g. bread → "stale"), expand +# the pantry set with terms that recipe ingredients commonly use to describe +# that state. This lets "stale bread" in a recipe ingredient match a pantry +# entry that is simply called "Bread" but is past its nominal use-by date. +# Each key is (category_in_SECONDARY_WINDOW, label_returned_by_secondary_state). +# Values are additional strings added to the pantry set for FTS coverage. +_SECONDARY_STATE_SYNONYMS: dict[tuple[str, str], list[str]] = { + ("bread", "stale"): ["stale bread", "day-old bread", "old bread", "dried bread"], + ("bakery", "day-old"): ["day-old bread", "stale bread", "stale pastry"], + ("bananas", "overripe"): ["overripe bananas", "very ripe banana", "ripe bananas", "mashed banana"], + ("milk", "sour"): ["sour milk", "slightly sour milk", "buttermilk"], + ("dairy", "sour"): ["sour milk", "slightly sour milk"], + ("cheese", "well-aged"): ["parmesan rind", "cheese rind", "aged cheese"], + ("rice", "day-old"): ["day-old rice", "leftover rice", "cold rice", "cooked rice"], + ("tortillas", "stale"): ["stale tortillas", "dried tortillas", "day-old tortillas"], +} + + # Matches leading quantity/unit prefixes in recipe ingredient strings, # e.g. "2 cups flour" → "flour", "1/2 c. ketchup" → "ketchup", # "3 oz. butter" → "butter" @@ -284,14 +302,24 @@ def _prep_note_for(ingredient: str) -> str | None: return template.format(ingredient=ingredient_name) -def _expand_pantry_set(pantry_items: list[str]) -> set[str]: +def _expand_pantry_set( + pantry_items: list[str], + secondary_pantry_items: dict[str, str] | None = None, +) -> set[str]: """Return pantry_set expanded with canonical recipe-corpus synonyms. For each pantry item, checks _PANTRY_LABEL_SYNONYMS for substring matches and adds the canonical form. This lets single-word recipe ingredients ("hamburger", "chicken") match product-label pantry entries ("burger patties", "rotisserie chicken"). + + If secondary_pantry_items is provided (product_name → state label), items + in a secondary state also receive state-specific synonym expansion so that + recipe ingredients like "stale bread" or "day-old rice" are matched. """ + from app.services.expiration_predictor import ExpirationPredictor + _predictor = ExpirationPredictor() + expanded: set[str] = set() for item in pantry_items: lower = item.lower().strip() @@ -299,6 +327,15 @@ def _expand_pantry_set(pantry_items: list[str]) -> set[str]: for pattern, canonical in _PANTRY_LABEL_SYNONYMS.items(): if pattern in lower: expanded.add(canonical) + + # Secondary state expansion — adds terms like "stale bread", "day-old rice" + if secondary_pantry_items and item in secondary_pantry_items: + state_label = secondary_pantry_items[item] + category = _predictor.get_category_from_product(item) + if category: + synonyms = _SECONDARY_STATE_SYNONYMS.get((category, state_label), []) + expanded.update(synonyms) + return expanded @@ -634,7 +671,7 @@ class RecipeEngine: profiles = self._classifier.classify_batch(req.pantry_items) gaps = self._classifier.identify_gaps(profiles) - pantry_set = _expand_pantry_set(req.pantry_items) + pantry_set = _expand_pantry_set(req.pantry_items, req.secondary_pantry_items or None) if req.level >= 3: from app.services.recipe.llm_recipe import LLMRecipeGenerator diff --git a/frontend/src/components/RecipesView.vue b/frontend/src/components/RecipesView.vue index fe5910a..fd13cb7 100644 --- a/frontend/src/components/RecipesView.vue +++ b/frontend/src/components/RecipesView.vue @@ -951,6 +951,19 @@ const pantryItems = computed(() => { return sorted.map((item) => item.product_name).filter(Boolean) as string[] }) +// Secondary-state items: expired but still usable in specific recipes. +// Maps product_name → secondary_state label (e.g. "Bread" → "stale"). +// Sent alongside pantry_items so the recipe engine can boost relevant recipes. +const secondaryPantryItems = computed>(() => { + const result: Record = {} + for (const item of inventoryStore.items) { + if (item.secondary_state && item.product_name) { + result[item.product_name] = item.secondary_state + } + } + return result +}) + // Grocery links relevant to a specific recipe's missing ingredients function groceryLinksForRecipe(recipe: RecipeSuggestion): GroceryLink[] { if (!recipesStore.result) return [] @@ -1025,12 +1038,12 @@ function onNutritionInput(key: NutritionKey, e: Event) { // Suggest handler async function handleSuggest() { isLoadingMore.value = false - await recipesStore.suggest(pantryItems.value) + await recipesStore.suggest(pantryItems.value, secondaryPantryItems.value) } async function handleLoadMore() { isLoadingMore.value = true - await recipesStore.loadMore(pantryItems.value) + await recipesStore.loadMore(pantryItems.value, secondaryPantryItems.value) isLoadingMore.value = false } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 4818e15..c4a7467 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -526,6 +526,7 @@ export interface RecipeResult { export interface RecipeRequest { pantry_items: string[] + secondary_pantry_items: Record level: number constraints: string[] allergies: string[] diff --git a/frontend/src/stores/recipes.ts b/frontend/src/stores/recipes.ts index 9d01d65..4798e17 100644 --- a/frontend/src/stores/recipes.ts +++ b/frontend/src/stores/recipes.ts @@ -163,10 +163,15 @@ export const useRecipesStore = defineStore('recipes', () => { const dismissedCount = computed(() => dismissedIds.value.size) - function _buildRequest(pantryItems: string[], extraExcluded: number[] = []): RecipeRequest { + function _buildRequest( + pantryItems: string[], + secondaryPantryItems: Record = {}, + extraExcluded: number[] = [], + ): RecipeRequest { const excluded = new Set([...dismissedIds.value, ...extraExcluded]) return { pantry_items: pantryItems, + secondary_pantry_items: secondaryPantryItems, level: level.value, constraints: constraints.value, allergies: allergies.value, @@ -191,13 +196,13 @@ export const useRecipesStore = defineStore('recipes', () => { } } - async function suggest(pantryItems: string[]) { + async function suggest(pantryItems: string[], secondaryPantryItems: Record = {}) { loading.value = true error.value = null seenIds.value = new Set() try { - result.value = await recipesAPI.suggest(_buildRequest(pantryItems)) + result.value = await recipesAPI.suggest(_buildRequest(pantryItems, secondaryPantryItems)) _trackSeen(result.value.suggestions) } catch (err: unknown) { error.value = err instanceof Error ? err.message : 'Failed to get recipe suggestions' @@ -206,14 +211,14 @@ export const useRecipesStore = defineStore('recipes', () => { } } - async function loadMore(pantryItems: string[]) { + async function loadMore(pantryItems: string[], secondaryPantryItems: Record = {}) { if (!result.value || loading.value) return loading.value = true error.value = null try { // Exclude everything already shown (dismissed + all seen this session) - const more = await recipesAPI.suggest(_buildRequest(pantryItems, [...seenIds.value])) + const more = await recipesAPI.suggest(_buildRequest(pantryItems, secondaryPantryItems, [...seenIds.value])) if (more.suggestions.length === 0) { error.value = 'No more recipes found — try clearing dismissed or adjusting filters.' } else {