feat: wire secondary-use window hints into recipe engine (#83)

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
This commit is contained in:
pyr0ball 2026-04-18 19:06:53 -07:00
parent 8fd77bd1f2
commit b2c546e86a
6 changed files with 81 additions and 9 deletions

View file

@ -71,6 +71,10 @@ class NutritionFilters(BaseModel):
class RecipeRequest(BaseModel): class RecipeRequest(BaseModel):
pantry_items: list[str] 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) level: int = Field(default=1, ge=1, le=4)
constraints: list[str] = Field(default_factory=list) constraints: list[str] = Field(default_factory=list)
expiry_first: bool = False expiry_first: bool = False

View file

@ -194,6 +194,18 @@ class ExpirationPredictor:
'uses': ['broth', 'soups', 'risotto', 'gratins'], 'uses': ['broth', 'soups', 'risotto', 'gratins'],
'warning': None, '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: def days_after_opening(self, category: str | None) -> int | None:

View file

@ -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, # Matches leading quantity/unit prefixes in recipe ingredient strings,
# e.g. "2 cups flour" → "flour", "1/2 c. ketchup" → "ketchup", # e.g. "2 cups flour" → "flour", "1/2 c. ketchup" → "ketchup",
# "3 oz. butter" → "butter" # "3 oz. butter" → "butter"
@ -284,14 +302,24 @@ def _prep_note_for(ingredient: str) -> str | None:
return template.format(ingredient=ingredient_name) 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. """Return pantry_set expanded with canonical recipe-corpus synonyms.
For each pantry item, checks _PANTRY_LABEL_SYNONYMS for substring matches For each pantry item, checks _PANTRY_LABEL_SYNONYMS for substring matches
and adds the canonical form. This lets single-word recipe ingredients and adds the canonical form. This lets single-word recipe ingredients
("hamburger", "chicken") match product-label pantry entries ("hamburger", "chicken") match product-label pantry entries
("burger patties", "rotisserie chicken"). ("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() expanded: set[str] = set()
for item in pantry_items: for item in pantry_items:
lower = item.lower().strip() 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(): for pattern, canonical in _PANTRY_LABEL_SYNONYMS.items():
if pattern in lower: if pattern in lower:
expanded.add(canonical) 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 return expanded
@ -634,7 +671,7 @@ class RecipeEngine:
profiles = self._classifier.classify_batch(req.pantry_items) profiles = self._classifier.classify_batch(req.pantry_items)
gaps = self._classifier.identify_gaps(profiles) 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: if req.level >= 3:
from app.services.recipe.llm_recipe import LLMRecipeGenerator from app.services.recipe.llm_recipe import LLMRecipeGenerator

View file

@ -951,6 +951,19 @@ const pantryItems = computed(() => {
return sorted.map((item) => item.product_name).filter(Boolean) as string[] 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<Record<string, string>>(() => {
const result: Record<string, string> = {}
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 // Grocery links relevant to a specific recipe's missing ingredients
function groceryLinksForRecipe(recipe: RecipeSuggestion): GroceryLink[] { function groceryLinksForRecipe(recipe: RecipeSuggestion): GroceryLink[] {
if (!recipesStore.result) return [] if (!recipesStore.result) return []
@ -1025,12 +1038,12 @@ function onNutritionInput(key: NutritionKey, e: Event) {
// Suggest handler // Suggest handler
async function handleSuggest() { async function handleSuggest() {
isLoadingMore.value = false isLoadingMore.value = false
await recipesStore.suggest(pantryItems.value) await recipesStore.suggest(pantryItems.value, secondaryPantryItems.value)
} }
async function handleLoadMore() { async function handleLoadMore() {
isLoadingMore.value = true isLoadingMore.value = true
await recipesStore.loadMore(pantryItems.value) await recipesStore.loadMore(pantryItems.value, secondaryPantryItems.value)
isLoadingMore.value = false isLoadingMore.value = false
} }

View file

@ -526,6 +526,7 @@ export interface RecipeResult {
export interface RecipeRequest { export interface RecipeRequest {
pantry_items: string[] pantry_items: string[]
secondary_pantry_items: Record<string, string>
level: number level: number
constraints: string[] constraints: string[]
allergies: string[] allergies: string[]

View file

@ -163,10 +163,15 @@ export const useRecipesStore = defineStore('recipes', () => {
const dismissedCount = computed(() => dismissedIds.value.size) const dismissedCount = computed(() => dismissedIds.value.size)
function _buildRequest(pantryItems: string[], extraExcluded: number[] = []): RecipeRequest { function _buildRequest(
pantryItems: string[],
secondaryPantryItems: Record<string, string> = {},
extraExcluded: number[] = [],
): RecipeRequest {
const excluded = new Set([...dismissedIds.value, ...extraExcluded]) const excluded = new Set([...dismissedIds.value, ...extraExcluded])
return { return {
pantry_items: pantryItems, pantry_items: pantryItems,
secondary_pantry_items: secondaryPantryItems,
level: level.value, level: level.value,
constraints: constraints.value, constraints: constraints.value,
allergies: allergies.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<string, string> = {}) {
loading.value = true loading.value = true
error.value = null error.value = null
seenIds.value = new Set() seenIds.value = new Set()
try { try {
result.value = await recipesAPI.suggest(_buildRequest(pantryItems)) result.value = await recipesAPI.suggest(_buildRequest(pantryItems, secondaryPantryItems))
_trackSeen(result.value.suggestions) _trackSeen(result.value.suggestions)
} catch (err: unknown) { } catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to get recipe suggestions' 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<string, string> = {}) {
if (!result.value || loading.value) return if (!result.value || loading.value) return
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
// Exclude everything already shown (dismissed + all seen this session) // 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) { if (more.suggestions.length === 0) {
error.value = 'No more recipes found — try clearing dismissed or adjusting filters.' error.value = 'No more recipes found — try clearing dismissed or adjusting filters.'
} else { } else {