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)
nutrition_filters: NutritionFilters = Field(default_factory=NutritionFilters)
excluded_ids: list[int] = Field(default_factory=list)
exclude_ingredients: list[str] = Field(default_factory=list)
shopping_mode: bool = False
pantry_match_only: bool = False # when True, only return recipes with zero missing ingredients
complexity_filter: str | None = None # 'easy' | 'moderate' | 'involved' — None = any

View file

@ -68,6 +68,9 @@ class LLMRecipeGenerator:
if 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(f"Covered culinary elements: {', '.join(covered_elements) or 'none'}")
@ -124,6 +127,9 @@ class LLMRecipeGenerator:
if 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 = (
"Use metric units (grams, ml, Celsius) for all quantities and temperatures."
if req.unit_system == "metric"

View file

@ -672,6 +672,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, req.secondary_pantry_items or None)
exclude_set = _expand_pantry_set(req.exclude_ingredients) if req.exclude_ingredients else set()
if req.level >= 3:
from app.services.recipe.llm_recipe import LLMRecipeGenerator
@ -715,6 +716,10 @@ class RecipeEngine:
except Exception:
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.
# When covered, collect any prep-state annotations (e.g. "melted butter"
# → 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>
</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 -->
<div class="form-group">
<label class="flex-start gap-sm shopping-toggle">
@ -771,6 +796,7 @@ const levelLabels: Record<number, string> = {
// Local input state for tags
const constraintInput = ref('')
const allergyInput = ref('')
const excludeIngredientInput = ref('')
const categoryInput = ref('')
const isLoadingMore = ref(false)
@ -918,6 +944,7 @@ function toggleAllergy(value: string) {
const dietaryActive = computed(() =>
recipesStore.constraints.length > 0 ||
recipesStore.allergies.length > 0 ||
recipesStore.excludeIngredients.length > 0 ||
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
function onMaxMissingInput(e: Event) {
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 ALLERGIES_KEY = 'kiwi:allergies'
const EXCLUDE_INGREDIENTS_KEY = 'kiwi:exclude_ingredients'
function loadConstraints(): string[] {
try {
@ -50,6 +51,19 @@ function saveAllergies(vals: string[]) {
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 BuilderFilterMode = 'text' | 'tags'
@ -127,6 +141,7 @@ export const useRecipesStore = defineStore('recipes', () => {
const level = ref(1)
const constraints = ref<string[]>(loadConstraints())
const allergies = ref<string[]>(loadAllergies())
const excludeIngredients = ref<string[]>(loadExcludeIngredients())
const hardDayMode = ref(false)
const maxMissing = ref<number | 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(constraints, (val) => saveConstraints(val), { deep: true })
watch(allergies, (val) => saveAllergies(val), { deep: true })
watch(excludeIngredients, (val) => saveExcludeIngredients(val), { deep: true })
const dismissedCount = computed(() => dismissedIds.value.size)
@ -184,6 +200,7 @@ export const useRecipesStore = defineStore('recipes', () => {
wildcard_confirmed: wildcardConfirmed.value,
nutrition_filters: nutritionFilters.value,
excluded_ids: [...excluded],
exclude_ingredients: excludeIngredients.value,
shopping_mode: shoppingMode.value,
pantry_match_only: pantryMatchOnly.value,
complexity_filter: complexityFilter.value,
@ -338,6 +355,11 @@ export const useRecipesStore = defineStore('recipes', () => {
localStorage.removeItem(ALLERGIES_KEY)
}
function clearExcludeIngredients() {
excludeIngredients.value = []
localStorage.removeItem(EXCLUDE_INGREDIENTS_KEY)
}
function clearResult() {
result.value = null
error.value = null
@ -352,6 +374,7 @@ export const useRecipesStore = defineStore('recipes', () => {
level,
constraints,
allergies,
excludeIngredients,
hardDayMode,
maxMissing,
styleId,
@ -373,6 +396,7 @@ export const useRecipesStore = defineStore('recipes', () => {
clearBookmarks,
clearConstraints,
clearAllergies,
clearExcludeIngredients,
missingIngredientMode,
builderFilterMode,
suggest,