feat(recipes): 'Not today' per-session ingredient exclusions

Users often have ingredients they want to avoid today (out of stock, not feeling it)
that aren't true allergies. The new 'Not today' filter lets them exclude specific
ingredients per session without permanently modifying their allergy list.

- recipe.py schema: exclude_ingredients field (list[str], default [])
- recipe_engine.py: filters corpus results when any ingredient is in exclude_set
- llm_recipe.py: injects exclusions into both prompt templates so LLM-generated
  recipes respect the constraint at generation time
- RecipesView.vue: tag-chip UI with Enter/comma input, removes on × click
- stores/recipes.ts: excludeIngredients reactive list (not persisted to localStorage)
This commit is contained in:
pyr0ball 2026-04-21 15:05:16 -07:00
parent 1ac7e3d76a
commit f1d35dd1ac
5 changed files with 88 additions and 0 deletions

View file

@ -100,6 +100,7 @@ class RecipeRequest(BaseModel):
allergies: list[str] = Field(default_factory=list) allergies: list[str] = Field(default_factory=list)
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters) nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
excluded_ids: list[int] = Field(default_factory=list) excluded_ids: list[int] = Field(default_factory=list)
exclude_ingredients: list[str] = Field(default_factory=list)
shopping_mode: bool = False shopping_mode: bool = False
pantry_match_only: bool = False # when True, only return recipes with zero missing ingredients pantry_match_only: bool = False # when True, only return recipes with zero missing ingredients
complexity_filter: str | None = None # 'easy' | 'moderate' | 'involved' — None = any complexity_filter: str | None = None # 'easy' | 'moderate' | 'involved' — None = any

View file

@ -68,6 +68,9 @@ class LLMRecipeGenerator:
if allergy_list: if allergy_list:
lines.append(f"IMPORTANT — must NOT contain: {', '.join(allergy_list)}") lines.append(f"IMPORTANT — must NOT contain: {', '.join(allergy_list)}")
if req.exclude_ingredients:
lines.append(f"IMPORTANT — user does not want these today: {', '.join(req.exclude_ingredients)}. Do not include them.")
lines.append("") lines.append("")
lines.append(f"Covered culinary elements: {', '.join(covered_elements) or 'none'}") lines.append(f"Covered culinary elements: {', '.join(covered_elements) or 'none'}")
@ -124,6 +127,9 @@ class LLMRecipeGenerator:
if allergy_list: if allergy_list:
lines.append(f"Must NOT contain: {', '.join(allergy_list)}") lines.append(f"Must NOT contain: {', '.join(allergy_list)}")
if req.exclude_ingredients:
lines.append(f"Do not use today: {', '.join(req.exclude_ingredients)}")
unit_line = ( unit_line = (
"Use metric units (grams, ml, Celsius) for all quantities and temperatures." "Use metric units (grams, ml, Celsius) for all quantities and temperatures."
if req.unit_system == "metric" if req.unit_system == "metric"

View file

@ -672,6 +672,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, req.secondary_pantry_items or None) pantry_set = _expand_pantry_set(req.pantry_items, req.secondary_pantry_items or None)
exclude_set = _expand_pantry_set(req.exclude_ingredients) if req.exclude_ingredients else set()
if req.level >= 3: if req.level >= 3:
from app.services.recipe.llm_recipe import LLMRecipeGenerator from app.services.recipe.llm_recipe import LLMRecipeGenerator
@ -715,6 +716,10 @@ class RecipeEngine:
except Exception: except Exception:
ingredient_names = [] ingredient_names = []
# Skip recipes that require any ingredient the user has excluded.
if exclude_set and any(_ingredient_in_pantry(n, exclude_set) for n in ingredient_names):
continue
# Compute missing ingredients, detecting pantry coverage first. # Compute missing ingredients, detecting pantry coverage first.
# When covered, collect any prep-state annotations (e.g. "melted butter" # When covered, collect any prep-state annotations (e.g. "melted butter"
# → note "Melt the butter before starting.") to surface separately. # → note "Melt the butter before starting.") to surface separately.

View file

@ -169,6 +169,31 @@
<span id="allergy-hint" class="form-hint">No recipes containing these ingredients will appear.</span> <span id="allergy-hint" class="form-hint">No recipes containing these ingredients will appear.</span>
</div> </div>
<!-- Not Today temporary per-session ingredient exclusions -->
<div class="form-group">
<label class="form-label">Not today <span class="text-muted text-xs">(skip these ingredients this session)</span></label>
<div v-if="recipesStore.excludeIngredients.length > 0" class="tags-wrap flex flex-wrap gap-xs mb-xs">
<span
v-for="tag in recipesStore.excludeIngredients"
:key="tag"
class="tag-chip status-badge status-warning"
>
{{ tag }}
<button class="chip-remove" @click="removeExcludeIngredient(tag)" :aria-label="'Stop excluding: ' + tag">×</button>
</span>
</div>
<input
class="form-input"
v-model="excludeIngredientInput"
placeholder="e.g. eggs, chicken, broccoli"
aria-describedby="exclude-hint"
@keydown="onExcludeIngredientKey"
@blur="commitExcludeIngredientInput"
autocomplete="off"
/>
<span id="exclude-hint" class="form-hint">Recipes containing these won't appear. Press Enter or comma to add.</span>
</div>
<!-- Can Make Now toggle --> <!-- Can Make Now toggle -->
<div class="form-group"> <div class="form-group">
<label class="flex-start gap-sm shopping-toggle"> <label class="flex-start gap-sm shopping-toggle">
@ -771,6 +796,7 @@ const levelLabels: Record<number, string> = {
// Local input state for tags // Local input state for tags
const constraintInput = ref('') const constraintInput = ref('')
const allergyInput = ref('') const allergyInput = ref('')
const excludeIngredientInput = ref('')
const categoryInput = ref('') const categoryInput = ref('')
const isLoadingMore = ref(false) const isLoadingMore = ref(false)
@ -918,6 +944,7 @@ function toggleAllergy(value: string) {
const dietaryActive = computed(() => const dietaryActive = computed(() =>
recipesStore.constraints.length > 0 || recipesStore.constraints.length > 0 ||
recipesStore.allergies.length > 0 || recipesStore.allergies.length > 0 ||
recipesStore.excludeIngredients.length > 0 ||
recipesStore.shoppingMode recipesStore.shoppingMode
) )
@ -1025,6 +1052,31 @@ function commitAllergyInput() {
} }
} }
function addExcludeIngredient(value: string) {
const tag = value.trim().toLowerCase()
if (tag && !recipesStore.excludeIngredients.includes(tag)) {
recipesStore.excludeIngredients = [...recipesStore.excludeIngredients, tag]
}
excludeIngredientInput.value = ''
}
function removeExcludeIngredient(tag: string) {
recipesStore.excludeIngredients = recipesStore.excludeIngredients.filter((i) => i !== tag)
}
function onExcludeIngredientKey(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addExcludeIngredient(excludeIngredientInput.value)
}
}
function commitExcludeIngredientInput() {
if (excludeIngredientInput.value.trim()) {
addExcludeIngredient(excludeIngredientInput.value)
}
}
// Max missing number input // Max missing number input
function onMaxMissingInput(e: Event) { function onMaxMissingInput(e: Event) {
const target = e.target as HTMLInputElement const target = e.target as HTMLInputElement

View file

@ -23,6 +23,7 @@ const FILTER_MODE_KEY = 'kiwi:builder_filter_mode'
const CONSTRAINTS_KEY = 'kiwi:constraints' const CONSTRAINTS_KEY = 'kiwi:constraints'
const ALLERGIES_KEY = 'kiwi:allergies' const ALLERGIES_KEY = 'kiwi:allergies'
const EXCLUDE_INGREDIENTS_KEY = 'kiwi:exclude_ingredients'
function loadConstraints(): string[] { function loadConstraints(): string[] {
try { try {
@ -50,6 +51,19 @@ function saveAllergies(vals: string[]) {
localStorage.setItem(ALLERGIES_KEY, JSON.stringify(vals)) localStorage.setItem(ALLERGIES_KEY, JSON.stringify(vals))
} }
function loadExcludeIngredients(): string[] {
try {
const raw = localStorage.getItem(EXCLUDE_INGREDIENTS_KEY)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
}
function saveExcludeIngredients(vals: string[]) {
localStorage.setItem(EXCLUDE_INGREDIENTS_KEY, JSON.stringify(vals))
}
type MissingIngredientMode = 'hidden' | 'greyed' | 'add-to-cart' type MissingIngredientMode = 'hidden' | 'greyed' | 'add-to-cart'
type BuilderFilterMode = 'text' | 'tags' type BuilderFilterMode = 'text' | 'tags'
@ -127,6 +141,7 @@ export const useRecipesStore = defineStore('recipes', () => {
const level = ref(1) const level = ref(1)
const constraints = ref<string[]>(loadConstraints()) const constraints = ref<string[]>(loadConstraints())
const allergies = ref<string[]>(loadAllergies()) const allergies = ref<string[]>(loadAllergies())
const excludeIngredients = ref<string[]>(loadExcludeIngredients())
const hardDayMode = ref(false) const hardDayMode = ref(false)
const maxMissing = ref<number | null>(null) const maxMissing = ref<number | null>(null)
const styleId = ref<string | null>(null) const styleId = ref<string | null>(null)
@ -161,6 +176,7 @@ export const useRecipesStore = defineStore('recipes', () => {
watch(builderFilterMode, (val) => localStorage.setItem(FILTER_MODE_KEY, val)) watch(builderFilterMode, (val) => localStorage.setItem(FILTER_MODE_KEY, val))
watch(constraints, (val) => saveConstraints(val), { deep: true }) watch(constraints, (val) => saveConstraints(val), { deep: true })
watch(allergies, (val) => saveAllergies(val), { deep: true }) watch(allergies, (val) => saveAllergies(val), { deep: true })
watch(excludeIngredients, (val) => saveExcludeIngredients(val), { deep: true })
const dismissedCount = computed(() => dismissedIds.value.size) const dismissedCount = computed(() => dismissedIds.value.size)
@ -184,6 +200,7 @@ export const useRecipesStore = defineStore('recipes', () => {
wildcard_confirmed: wildcardConfirmed.value, wildcard_confirmed: wildcardConfirmed.value,
nutrition_filters: nutritionFilters.value, nutrition_filters: nutritionFilters.value,
excluded_ids: [...excluded], excluded_ids: [...excluded],
exclude_ingredients: excludeIngredients.value,
shopping_mode: shoppingMode.value, shopping_mode: shoppingMode.value,
pantry_match_only: pantryMatchOnly.value, pantry_match_only: pantryMatchOnly.value,
complexity_filter: complexityFilter.value, complexity_filter: complexityFilter.value,
@ -338,6 +355,11 @@ export const useRecipesStore = defineStore('recipes', () => {
localStorage.removeItem(ALLERGIES_KEY) localStorage.removeItem(ALLERGIES_KEY)
} }
function clearExcludeIngredients() {
excludeIngredients.value = []
localStorage.removeItem(EXCLUDE_INGREDIENTS_KEY)
}
function clearResult() { function clearResult() {
result.value = null result.value = null
error.value = null error.value = null
@ -352,6 +374,7 @@ export const useRecipesStore = defineStore('recipes', () => {
level, level,
constraints, constraints,
allergies, allergies,
excludeIngredients,
hardDayMode, hardDayMode,
maxMissing, maxMissing,
styleId, styleId,
@ -373,6 +396,7 @@ export const useRecipesStore = defineStore('recipes', () => {
clearBookmarks, clearBookmarks,
clearConstraints, clearConstraints,
clearAllergies, clearAllergies,
clearExcludeIngredients,
missingIngredientMode, missingIngredientMode,
builderFilterMode, builderFilterMode,
suggest, suggest,