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:
parent
8fd77bd1f2
commit
b2c546e86a
6 changed files with 81 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -526,6 +526,7 @@ export interface RecipeResult {
|
|||
|
||||
export interface RecipeRequest {
|
||||
pantry_items: string[]
|
||||
secondary_pantry_items: Record<string, string>
|
||||
level: number
|
||||
constraints: string[]
|
||||
allergies: string[]
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {},
|
||||
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<string, string> = {}) {
|
||||
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<string, string> = {}) {
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue