feat: sensory profile filter — texture/smell/noise filtering for Browse and Find (kiwi#51)

- Migration 035: add sensory_tags column to recipes (default '{}')
- scripts/tag_sensory_profiles.py: batch tagger using ingredient names,
  direction keywords, and ingredient_profiles texture data
- app/services/recipe/sensory.py: SensoryExclude frozen dataclass,
  build_sensory_exclude(), passes_sensory_filter() with graceful degradation
  (untagged recipes always pass; malformed JSON always passes)
- store.browse_recipes and _browse_by_match: accept SensoryExclude, apply
  filter in recipe-building loop (default path) and scoring loop (match sort)
- recipe_engine.suggest: load sensory_preferences from settings, apply
  passes_sensory_filter() after exclude_set check in the rows loop
- settings endpoint: add sensory_preferences to _ALLOWED_KEYS
- Frontend: SensoryPreferences types in api.ts; sensoryPreferences state and
  saveSensory() action in settings store; Sensory section in SettingsView with
  texture avoid pills, smell/noise tolerance scale pills with ok/limit/neutral
  color coding
- 66 new tests (29 classification + 13 sensory service + 2 settings); 281 total
This commit is contained in:
pyr0ball 2026-04-24 09:47:48 -07:00
parent 302285a1a5
commit 521cb419bc
13 changed files with 985 additions and 8 deletions

View file

@ -38,6 +38,7 @@ from app.services.recipe.browser_domains import (
)
from app.services.recipe.recipe_engine import RecipeEngine
from app.services.recipe.time_effort import parse_time_effort
from app.services.recipe.sensory import build_sensory_exclude
from app.services.heimdall_orch import check_orch_budget
from app.tiers import can_use
@ -285,6 +286,10 @@ async def browse_recipes(
def _browse(db_path: Path) -> dict:
store = Store(db_path)
try:
# Load sensory preferences
sensory_prefs_json = store.get_setting("sensory_preferences")
sensory_exclude = build_sensory_exclude(sensory_prefs_json)
result = store.browse_recipes(
keywords=keywords,
page=page,
@ -292,6 +297,7 @@ async def browse_recipes(
pantry_items=pantry_list,
q=q or None,
sort=sort,
sensory_exclude=sensory_exclude,
)
# ── Attach time/effort signals to each browse result ────────────────

View file

@ -10,7 +10,7 @@ from app.db.store import Store
router = APIRouter()
_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system", "shopping_locale"})
_ALLOWED_KEYS = frozenset({"cooking_equipment", "unit_system", "shopping_locale", "sensory_preferences"})
class SettingBody(BaseModel):

View file

@ -0,0 +1,12 @@
-- Migration 035: add sensory_tags column for sensory profile filtering
--
-- sensory_tags holds a JSON object with texture, smell, and noise signals:
-- {"textures": ["mushy", "creamy"], "smell": "pungent", "noise": "moderate"}
--
-- Empty object '{}' means untagged — these recipes pass ALL sensory filters
-- (graceful degradation when tag_sensory_profiles.py has not yet been run).
--
-- Populated offline by: python scripts/tag_sensory_profiles.py [path/to/kiwi.db]
-- No FTS rebuild needed — sensory_tags is filtered in Python after candidate fetch.
ALTER TABLE recipes ADD COLUMN sensory_tags TEXT NOT NULL DEFAULT '{}';

View file

@ -11,6 +11,7 @@ from typing import Any
from circuitforge_core.db.base import get_connection
from circuitforge_core.db.migrations import run_migrations
from app.services.recipe.sensory import SensoryExclude, passes_sensory_filter
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
@ -1153,6 +1154,7 @@ class Store:
pantry_items: list[str] | None = None,
q: str | None = None,
sort: str = "default",
sensory_exclude: SensoryExclude | None = None,
) -> dict:
"""Return a page of recipes matching the keyword set.
@ -1185,12 +1187,13 @@ class Store:
# ── match sort: push match_pct computation into SQL so ORDER BY works ──
if effective_sort == "match" and pantry_set:
return self._browse_by_match(
keywords, page, page_size, offset, pantry_set, q_param, c
keywords, page, page_size, offset, pantry_set, q_param, c,
sensory_exclude=sensory_exclude,
)
cols = (
f"SELECT id, title, category, keywords, ingredient_names,"
f" calories, fat_g, protein_g, sodium_mg, directions FROM {c}recipes"
f" calories, fat_g, protein_g, sodium_mg, directions, sensory_tags FROM {c}recipes"
)
if keywords is None:
@ -1236,6 +1239,10 @@ class Store:
recipes = []
for r in rows:
# Apply sensory filter -- untagged recipes (empty {}) always pass
if sensory_exclude and not sensory_exclude.is_empty():
if not passes_sensory_filter(r.get("sensory_tags"), sensory_exclude):
continue
entry = {
"id": r["id"],
"title": r["title"],
@ -1303,6 +1310,7 @@ class Store:
pantry_set: set[str],
q_param: str | None,
c: str,
sensory_exclude: SensoryExclude | None = None,
) -> dict:
"""Browse recipes sorted by pantry match percentage.
@ -1319,7 +1327,7 @@ class Store:
# ── Fetch candidate pool from FTS ────────────────────────────────────
base_cols = (
f"SELECT r.id, r.title, r.category, r.ingredient_names, r.directions"
f"SELECT r.id, r.title, r.category, r.ingredient_names, r.directions, r.sensory_tags"
f" FROM {c}recipes r"
)
@ -1372,6 +1380,10 @@ class Store:
scored = []
for r in rows:
row = dict(r)
# Sensory filter applied before scoring to keep hot path clean
if sensory_exclude and not sensory_exclude.is_empty():
if not passes_sensory_filter(row.get("sensory_tags"), sensory_exclude):
continue
try:
names = _json.loads(row["ingredient_names"] or "[]")
except Exception:

View file

@ -24,6 +24,7 @@ from app.models.schemas.recipe import GroceryLink, NutritionPanel, RecipeRequest
from app.services.recipe.element_classifier import ElementClassifier
from app.services.recipe.grocery_links import GroceryLinkBuilder
from app.services.recipe.substitution_engine import SubstitutionEngine
from app.services.recipe.sensory import SensoryExclude, build_sensory_exclude, passes_sensory_filter
_LEFTOVER_DAILY_MAX_FREE = 5
@ -705,8 +706,12 @@ class RecipeEngine:
if _l1 and effective_max_missing is None:
effective_max_missing = _L1_MAX_MISSING_DEFAULT
# Load sensory preferences -- applied as silent post-score filter
_sensory_prefs_json = self._store.get_setting("sensory_preferences")
_sensory_exclude = build_sensory_exclude(_sensory_prefs_json)
suggestions = []
hard_day_tier_map: dict[int, int] = {} # recipe_id → tier when hard_day_mode
hard_day_tier_map: dict[int, int] = {} # recipe_id -> tier when hard_day_mode
for row in rows:
ingredient_names: list[str] = row.get("ingredient_names") or []
@ -720,6 +725,11 @@ class RecipeEngine:
if exclude_set and any(_ingredient_in_pantry(n, exclude_set) for n in ingredient_names):
continue
# Sensory filter -- silent exclusion of recipes exceeding user tolerance
if not _sensory_exclude.is_empty():
if not passes_sensory_filter(row.get("sensory_tags"), _sensory_exclude):
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

@ -0,0 +1,133 @@
"""
Sensory filter dataclass and helpers.
SensoryExclude bridges user preferences (from user_settings) to the
store browse methods and recipe engine suggest flow.
Recipes with sensory_tags = '{}' (untagged) pass ALL filters --
graceful degradation when tag_sensory_profiles.py has not run.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
_SMELL_LEVELS: tuple[str, ...] = ("mild", "aromatic", "pungent", "fermented")
_NOISE_LEVELS: tuple[str, ...] = ("quiet", "moderate", "loud", "very_loud")
@dataclass(frozen=True)
class SensoryExclude:
"""Derived filter criteria from user sensory preferences.
textures: texture tags to exclude (empty tuple = no texture filter)
smell_above: if set, exclude recipes whose smell level is strictly above
this level in the smell spectrum
noise_above: if set, exclude recipes whose noise level is strictly above
this level in the noise spectrum
"""
textures: tuple[str, ...] = field(default_factory=tuple)
smell_above: str | None = None
noise_above: str | None = None
@classmethod
def empty(cls) -> "SensoryExclude":
"""No filtering -- pass-through for users with no preferences set."""
return cls()
def is_empty(self) -> bool:
"""True when no filtering will be applied."""
return not self.textures and self.smell_above is None and self.noise_above is None
def build_sensory_exclude(prefs_json: str | None) -> SensoryExclude:
"""Parse user_settings value for 'sensory_preferences' into a SensoryExclude.
Expected JSON shape:
{
"avoid_textures": ["mushy", "slimy"],
"max_smell": "pungent",
"max_noise": "loud"
}
Returns SensoryExclude.empty() on missing, null, or malformed input.
"""
if not prefs_json:
return SensoryExclude.empty()
try:
prefs = json.loads(prefs_json)
except (json.JSONDecodeError, TypeError):
return SensoryExclude.empty()
if not isinstance(prefs, dict):
return SensoryExclude.empty()
avoid_textures = tuple(
t for t in (prefs.get("avoid_textures") or [])
if isinstance(t, str)
)
max_smell: str | None = prefs.get("max_smell") or None
max_noise: str | None = prefs.get("max_noise") or None
if max_smell and max_smell not in _SMELL_LEVELS:
max_smell = None
if max_noise and max_noise not in _NOISE_LEVELS:
max_noise = None
return SensoryExclude(
textures=avoid_textures,
smell_above=max_smell,
noise_above=max_noise,
)
def passes_sensory_filter(
sensory_tags_raw: str | dict | None,
exclude: SensoryExclude,
) -> bool:
"""Return True if the recipe passes the sensory exclude criteria.
sensory_tags_raw: the sensory_tags column value (JSON string or already-parsed dict).
exclude: derived filter criteria.
Untagged recipes (empty dict or '{}') always pass -- graceful degradation.
Empty SensoryExclude always passes -- no preferences set.
"""
if exclude.is_empty():
return True
if sensory_tags_raw is None:
return True
if isinstance(sensory_tags_raw, str):
try:
tags: dict = json.loads(sensory_tags_raw)
except (json.JSONDecodeError, TypeError):
return True
else:
tags = sensory_tags_raw
if not tags:
return True
if exclude.textures:
recipe_textures: list[str] = tags.get("textures") or []
for t in recipe_textures:
if t in exclude.textures:
return False
if exclude.smell_above is not None:
recipe_smell: str | None = tags.get("smell")
if recipe_smell and recipe_smell in _SMELL_LEVELS:
max_idx = _SMELL_LEVELS.index(exclude.smell_above)
recipe_idx = _SMELL_LEVELS.index(recipe_smell)
if recipe_idx > max_idx:
return False
if exclude.noise_above is not None:
recipe_noise: str | None = tags.get("noise")
if recipe_noise and recipe_noise in _NOISE_LEVELS:
max_idx = _NOISE_LEVELS.index(exclude.noise_above)
recipe_idx = _NOISE_LEVELS.index(recipe_noise)
if recipe_idx > max_idx:
return False
return True

View file

@ -64,6 +64,89 @@
</div>
</section>
<!-- Sensory Preferences -->
<section class="mt-md">
<h3 class="text-lg font-semibold mb-xs">Sensory Preferences</h3>
<p class="text-sm text-secondary mb-md">
Tell Kiwi what your senses prefer. Recipes that don't match will be
filtered out quietly in Browse and Find. Leave everything unset and nothing is filtered.
</p>
<!-- Texture avoid pills -->
<div class="form-group">
<label class="form-label">
<span class="mr-xs">Texture avoid</span>
<span class="text-xs text-muted">(select any textures you'd rather skip)</span>
</label>
<div class="flex flex-wrap gap-xs mt-xs" role="group" aria-label="Texture avoidance">
<button
v-for="tex in TEXTURE_OPTIONS"
:key="tex.tag"
:class="[
'sensory-pill',
settingsStore.sensoryPreferences.avoid_textures.includes(tex.tag)
? 'sensory-pill--avoided'
: 'sensory-pill--neutral',
]"
:aria-pressed="settingsStore.sensoryPreferences.avoid_textures.includes(tex.tag)"
@click="toggleTexture(tex.tag)"
>{{ tex.emoji }} {{ tex.label }}</button>
</div>
</div>
<!-- Smell tolerance -->
<div class="form-group mt-sm">
<label class="form-label">
<span class="mr-xs">Smell max I'm ok with</span>
<span class="text-xs text-muted">(tap to set your limit; tap again to clear)</span>
</label>
<div class="flex flex-wrap gap-xs mt-xs" role="group" aria-label="Smell tolerance">
<button
v-for="(level, idx) in SMELL_LEVELS"
:key="String(level.value)"
:class="['sensory-pill', getSmellClass(level.value, idx)]"
:aria-pressed="settingsStore.sensoryPreferences.max_smell === level.value"
@click="toggleSmell(level.value)"
>{{ level.emoji }} {{ level.label }}</button>
</div>
<p v-if="settingsStore.sensoryPreferences.max_smell" class="text-xs text-muted mt-xs">
Recipes stronger than <strong>{{ smellLabel(settingsStore.sensoryPreferences.max_smell) }}</strong> will be hidden.
</p>
</div>
<!-- Noise tolerance -->
<div class="form-group mt-sm">
<label class="form-label">
<span class="mr-xs">Noise max I'm ok with</span>
<span class="text-xs text-muted">(tap to set your limit; tap again to clear)</span>
</label>
<div class="flex flex-wrap gap-xs mt-xs" role="group" aria-label="Noise tolerance">
<button
v-for="(level, idx) in NOISE_LEVELS"
:key="String(level.value)"
:class="['sensory-pill', getNoiseClass(level.value, idx)]"
:aria-pressed="settingsStore.sensoryPreferences.max_noise === level.value"
@click="toggleNoise(level.value)"
>{{ level.emoji }} {{ level.label }}</button>
</div>
<p v-if="settingsStore.sensoryPreferences.max_noise" class="text-xs text-muted mt-xs">
Recipes louder than <strong>{{ noiseLabel(settingsStore.sensoryPreferences.max_noise) }}</strong> will be hidden.
</p>
</div>
<div class="flex-start gap-sm mt-sm">
<button
class="btn btn-primary btn-sm"
:disabled="settingsStore.loading"
@click="settingsStore.saveSensory()"
>
<span v-if="settingsStore.loading">Saving</span>
<span v-else-if="settingsStore.saved">Saved!</span>
<span v-else>Save sensory preferences</span>
</button>
</div>
</section>
<!-- Units -->
<section class="mt-md">
<h3 class="text-lg font-semibold mb-xs">Units</h3>
@ -261,6 +344,7 @@ import { ref, computed, onMounted } from 'vue'
import { useSettingsStore } from '../stores/settings'
import { useRecipesStore } from '../stores/recipes'
import { householdAPI, type HouseholdStatus } from '../services/api'
import type { TextureTag, SmellLevel, NoiseLevel } from '../services/api'
import { useOrchUsage } from '../composables/useOrchUsage'
const settingsStore = useSettingsStore()
@ -411,6 +495,84 @@ onMounted(async () => {
await settingsStore.load()
await loadHouseholdStatus()
})
// Sensory taxonomy
const TEXTURE_OPTIONS: { tag: TextureTag; label: string; emoji: string }[] = [
{ tag: 'mushy', label: 'Mushy', emoji: '🦫' },
{ tag: 'slimy', label: 'Slimy', emoji: '🫙' },
{ tag: 'crunchy', label: 'Crunchy', emoji: '🥜' },
{ tag: 'chewy', label: 'Chewy', emoji: '🍖' },
{ tag: 'creamy', label: 'Creamy', emoji: '🥣' },
{ tag: 'chunky', label: 'Chunky', emoji: '🫕' },
]
const SMELL_LEVELS: { value: SmellLevel; label: string; emoji: string }[] = [
{ value: 'mild', label: 'Mild', emoji: '🌿' },
{ value: 'aromatic', label: 'Aromatic', emoji: '🌸' },
{ value: 'pungent', label: 'Pungent', emoji: '🧄' },
{ value: 'fermented', label: 'Fermented', emoji: '🧀' },
]
const NOISE_LEVELS: { value: NoiseLevel; label: string; emoji: string }[] = [
{ value: 'quiet', label: 'Quiet', emoji: '🤫' },
{ value: 'moderate', label: 'Moderate', emoji: '🍳' },
{ value: 'loud', label: 'Loud', emoji: '🔥' },
{ value: 'very_loud', label: 'Very loud', emoji: '💥' },
]
function smellLabel(value: SmellLevel): string {
return SMELL_LEVELS.find(l => l.value === value)?.label ?? ''
}
function noiseLabel(value: NoiseLevel): string {
return NOISE_LEVELS.find(l => l.value === value)?.label ?? ''
}
function toggleTexture(tag: TextureTag) {
const current = settingsStore.sensoryPreferences.avoid_textures
const updated = current.includes(tag)
? current.filter(t => t !== tag)
: [...current, tag]
settingsStore.sensoryPreferences = {
...settingsStore.sensoryPreferences,
avoid_textures: updated,
}
}
function toggleSmell(value: SmellLevel) {
const current = settingsStore.sensoryPreferences.max_smell
settingsStore.sensoryPreferences = {
...settingsStore.sensoryPreferences,
max_smell: current === value ? null : value,
}
}
function toggleNoise(value: NoiseLevel) {
const current = settingsStore.sensoryPreferences.max_noise
settingsStore.sensoryPreferences = {
...settingsStore.sensoryPreferences,
max_noise: current === value ? null : value,
}
}
function getSmellClass(value: SmellLevel, idx: number): string {
const maxSmell = settingsStore.sensoryPreferences.max_smell
if (!maxSmell) return 'sensory-pill--neutral'
const maxIdx = SMELL_LEVELS.findIndex(l => l.value === maxSmell)
if (idx === maxIdx) return 'sensory-pill--limit'
if (idx < maxIdx) return 'sensory-pill--ok'
return 'sensory-pill--neutral'
}
function getNoiseClass(value: NoiseLevel, idx: number): string {
const maxNoise = settingsStore.sensoryPreferences.max_noise
if (!maxNoise) return 'sensory-pill--neutral'
const maxIdx = NOISE_LEVELS.findIndex(l => l.value === maxNoise)
if (idx === maxIdx) return 'sensory-pill--limit'
if (idx < maxIdx) return 'sensory-pill--ok'
return 'sensory-pill--neutral'
}
</script>
<style scoped>
@ -567,4 +729,49 @@ onMounted(async () => {
height: 1rem;
flex-shrink: 0;
}
</style>
/* ── Sensory pills ───────────────────────────────────────────────────────── */
.sensory-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.3rem 0.75rem;
border-radius: 9999px;
border: 1.5px solid var(--color-border, #e0e0e0);
background: transparent;
color: var(--color-text-secondary, #888);
font-size: 0.85rem;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
user-select: none;
}
.sensory-pill:hover {
opacity: 0.85;
}
.sensory-pill--avoided {
background: rgba(220, 80, 60, 0.18);
border-color: rgba(220, 80, 60, 0.40);
color: #f08070;
}
.sensory-pill--ok {
background: rgba(74, 140, 64, 0.15);
border-color: rgba(74, 140, 64, 0.35);
color: #7fc073;
}
.sensory-pill--limit {
background: rgba(200, 140, 30, 0.18);
border-color: rgba(200, 140, 30, 0.45);
color: #c8a020;
}
.sensory-pill--neutral {
background: transparent;
border-color: var(--color-border, #e0e0e0);
color: var(--color-text-secondary, #888);
}
</style>

View file

@ -1091,4 +1091,22 @@ export async function bootstrapSession(): Promise<SessionInfo | null> {
}
}
// ========== Sensory Preferences Types ==========
export type TextureTag = 'mushy' | 'slimy' | 'crunchy' | 'chewy' | 'creamy' | 'chunky'
export type SmellLevel = 'mild' | 'aromatic' | 'pungent' | 'fermented' | null
export type NoiseLevel = 'quiet' | 'moderate' | 'loud' | 'very_loud' | null
export interface SensoryPreferences {
avoid_textures: TextureTag[]
max_smell: SmellLevel
max_noise: NoiseLevel
}
export const DEFAULT_SENSORY_PREFERENCES: SensoryPreferences = {
avoid_textures: [],
max_smell: null,
max_noise: null,
}
export default api

View file

@ -8,12 +8,15 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { settingsAPI } from '../services/api'
import type { UnitSystem } from '../utils/units'
import type { SensoryPreferences } from '../services/api'
import { DEFAULT_SENSORY_PREFERENCES } from '../services/api'
export const useSettingsStore = defineStore('settings', () => {
// State
const cookingEquipment = ref<string[]>([])
const unitSystem = ref<UnitSystem>('metric')
const shoppingLocale = ref<string>('us')
const sensoryPreferences = ref<SensoryPreferences>({ ...DEFAULT_SENSORY_PREFERENCES })
const loading = ref(false)
const saved = ref(false)
@ -21,10 +24,11 @@ export const useSettingsStore = defineStore('settings', () => {
async function load() {
loading.value = true
try {
const [rawEquipment, rawUnits, rawLocale] = await Promise.allSettled([
const [rawEquipment, rawUnits, rawLocale, rawSensory] = await Promise.allSettled([
settingsAPI.getSetting('cooking_equipment'),
settingsAPI.getSetting('unit_system'),
settingsAPI.getSetting('shopping_locale'),
settingsAPI.getSetting('sensory_preferences'),
])
if (rawEquipment.status === 'fulfilled' && rawEquipment.value) {
cookingEquipment.value = JSON.parse(rawEquipment.value)
@ -35,6 +39,13 @@ export const useSettingsStore = defineStore('settings', () => {
if (rawLocale.status === 'fulfilled' && rawLocale.value) {
shoppingLocale.value = rawLocale.value
}
if (rawSensory.status === 'fulfilled' && rawSensory.value) {
try {
sensoryPreferences.value = JSON.parse(rawSensory.value)
} catch {
sensoryPreferences.value = { ...DEFAULT_SENSORY_PREFERENCES }
}
}
} catch (err: unknown) {
console.error('Failed to load settings:', err)
} finally {
@ -49,6 +60,7 @@ export const useSettingsStore = defineStore('settings', () => {
settingsAPI.setSetting('cooking_equipment', JSON.stringify(cookingEquipment.value)),
settingsAPI.setSetting('unit_system', unitSystem.value),
settingsAPI.setSetting('shopping_locale', shoppingLocale.value),
settingsAPI.setSetting('sensory_preferences', JSON.stringify(sensoryPreferences.value)),
])
saved.value = true
setTimeout(() => {
@ -61,16 +73,34 @@ export const useSettingsStore = defineStore('settings', () => {
}
}
async function saveSensory() {
loading.value = true
try {
await settingsAPI.setSetting(
'sensory_preferences',
JSON.stringify(sensoryPreferences.value),
)
saved.value = true
setTimeout(() => { saved.value = false }, 2000)
} catch (err: unknown) {
console.error('Failed to save sensory preferences:', err)
} finally {
loading.value = false
}
}
return {
// State
cookingEquipment,
unitSystem,
shoppingLocale,
sensoryPreferences,
loading,
saved,
// Actions
load,
save,
saveSensory,
}
})

View file

@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""
Tag recipes with sensory_tags (texture, smell, noise) based on ingredient
names and direction keywords.
Stores results in the sensory_tags JSON column added by migration 035.
Empty "{}" means untagged -- these recipes pass all sensory filters.
Run:
python scripts/tag_sensory_profiles.py [path/to/kiwi.db]
"""
from __future__ import annotations
import json
import re
import sqlite3
import sys
from pathlib import Path
_DEFAULT_PATHS = [
"/devl/kiwi-cloud-data/local-dev/kiwi.db",
"/devl/kiwi-data/kiwi.db",
]
BATCH_SIZE = 2_000
TEXTURE_TAGS = ("mushy", "slimy", "crunchy", "chewy", "creamy", "chunky")
_PROFILE_TO_TEXTURE: dict[str, str] = {
"creamy": "creamy",
"fatty": "creamy",
}
_DIR_TEXTURE_PATTERNS: dict[str, list[str]] = {
"mushy": ["stew", "braise", "slow.cook", "slow cook", "soften", "mash", "slow-cook"],
"crunchy": ["fry", "roast", "toast", "bake", "crispy", "raw"],
"creamy": ["blend", "puree", "mash smooth"],
"chunky": ["chunk", "cube", "dice"],
}
_ING_TEXTURE_PATTERNS: dict[str, list[str]] = {
"slimy": ["okra", "seaweed", "natto", "enoki", "oyster mushroom"],
"chewy": ["calamari", "squid", "octopus", "jerky", "dried fruit",
"sourdough", "bagel", "pretzel"],
"crunchy": ["nuts", "seeds", "breadcrumbs", "crackers", "croutons",
"granola", "cornflakes"],
}
_SMELL_KEYWORDS: dict[str, list[str]] = {
"fermented": [
"fish sauce", "soy sauce", "miso", "kimchi", "natto",
"blue cheese", "aged cheese", "balsamic",
],
"pungent": [
"garlic", "curry powder", "garam masala",
"fish fillet", "fish steak", "fish filet", "liver",
],
"aromatic": [
"basil", "rosemary", "thyme", "cilantro", "citrus zest",
"cinnamon", "vanilla", "cardamom",
],
}
_SMELL_ORDER = ("fermented", "pungent", "aromatic", "mild")
_NOISE_PATTERNS: dict[str, list[str]] = {
"very_loud": ["deep fry", "deep-fry", "pressure cook", "instant pot"],
"loud": ["sear", "high heat", "wok", "stir-fry", "stir fry"],
"moderate": ["saute", "pan-fry", "pan fry", "bake", "roast"],
}
_NOISE_ORDER = ("very_loud", "loud", "moderate", "quiet")
def _classify_textures(
ingredient_names: list[str],
directions: list[str],
profile_textures: set[str],
) -> list[str]:
"""Return list of texture tags that apply to this recipe."""
dirs_text = " ".join(directions).lower()
ings_text = " ".join(ingredient_names).lower()
result: list[str] = []
for tag in TEXTURE_TAGS:
fired = False
if not fired and tag == "creamy" and ("creamy" in profile_textures or "fatty" in profile_textures):
fired = True
if not fired and tag in _DIR_TEXTURE_PATTERNS:
for kw in _DIR_TEXTURE_PATTERNS[tag]:
if kw in dirs_text:
fired = True
break
if not fired and tag in _ING_TEXTURE_PATTERNS:
for kw in _ING_TEXTURE_PATTERNS[tag]:
if kw in ings_text:
fired = True
break
if fired:
result.append(tag)
return result
def _classify_smell(ingredient_names: list[str]) -> str:
"""Return highest smell level present in ingredient list."""
ings_lower = " ".join(ingredient_names).lower()
for level in ("fermented", "pungent", "aromatic"):
for kw in _SMELL_KEYWORDS[level]:
if kw in ings_lower:
return level
return "mild"
def _classify_noise(directions: list[str]) -> str:
"""Return highest noise level present in direction steps."""
dirs_lower = " ".join(directions).lower()
for kw in _NOISE_PATTERNS["very_loud"]:
if kw in dirs_lower:
return "very_loud"
for kw in _NOISE_PATTERNS["loud"]:
if kw in dirs_lower:
return "loud"
if re.search(r"\bfry\b", dirs_lower) and "deep" not in dirs_lower:
return "loud"
for kw in _NOISE_PATTERNS["moderate"]:
if kw in dirs_lower:
return "moderate"
return "quiet"
def tag_recipes(db_path: str) -> None:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
total = conn.execute("SELECT COUNT(*) FROM recipes").fetchone()[0]
print(f"Total recipes: {total:,}")
updated = 0
offset = 0
texture_counts: dict[str, int] = {t: 0 for t in TEXTURE_TAGS}
smell_counts: dict[str, int] = {s: 0 for s in _SMELL_ORDER}
noise_counts: dict[str, int] = {n: 0 for n in _NOISE_ORDER}
while True:
rows = conn.execute(
"""SELECT r.id, r.ingredient_names, r.directions
FROM recipes r
LIMIT ? OFFSET ?""",
(BATCH_SIZE, offset),
).fetchall()
if not rows:
break
batch: list[tuple[str, int]] = []
for row in rows:
recipe_id = row["id"]
try:
ingredient_names: list[str] = json.loads(row["ingredient_names"] or "[]")
except (json.JSONDecodeError, TypeError):
ingredient_names = []
try:
directions: list[str] = json.loads(row["directions"] or "[]")
except (json.JSONDecodeError, TypeError):
directions = []
if ingredient_names:
placeholders = ",".join("?" * len(ingredient_names))
profile_rows = conn.execute(
f"""SELECT DISTINCT texture_profile
FROM ingredient_profiles
WHERE LOWER(name) IN ({placeholders})""",
[n.lower() for n in ingredient_names],
).fetchall()
profile_textures = {r["texture_profile"] for r in profile_rows if r["texture_profile"]}
else:
profile_textures = set()
textures = _classify_textures(ingredient_names, directions, profile_textures)
smell = _classify_smell(ingredient_names)
noise = _classify_noise(directions)
for t in textures:
texture_counts[t] = texture_counts.get(t, 0) + 1
smell_counts[smell] = smell_counts.get(smell, 0) + 1
noise_counts[noise] = noise_counts.get(noise, 0) + 1
sensory_tags = json.dumps({
"textures": textures,
"smell": smell,
"noise": noise,
})
batch.append((sensory_tags, recipe_id))
conn.executemany(
"UPDATE recipes SET sensory_tags = ? WHERE id = ?",
batch,
)
conn.commit()
updated += len(batch)
offset += BATCH_SIZE
print(f" {updated:,} / {total:,} tagged...", end="\r")
print(f"\nDone. {updated:,} recipes tagged.\n")
print("Texture tag distribution:")
for tag, count in sorted(texture_counts.items(), key=lambda x: -x[1]):
pct = count / updated * 100 if updated else 0
print(f" {tag:12s} {count:8,} ({pct:.1f}%)")
print("\nSmell level distribution:")
for level in _SMELL_ORDER:
count = smell_counts.get(level, 0)
pct = count / updated * 100 if updated else 0
print(f" {level:12s} {count:8,} ({pct:.1f}%)")
print("\nNoise level distribution:")
for level in _NOISE_ORDER:
count = noise_counts.get(level, 0)
pct = count / updated * 100 if updated else 0
print(f" {level:12s} {count:8,} ({pct:.1f}%)")
conn.close()
if __name__ == "__main__":
if len(sys.argv) > 1:
path = sys.argv[1]
else:
path = next((p for p in _DEFAULT_PATHS if Path(p).exists()), None)
if not path:
print(f"No DB found. Pass path as argument or create one of: {_DEFAULT_PATHS}")
sys.exit(1)
print(f"Tagging sensory profiles in: {path}")
tag_recipes(path)

View file

@ -86,7 +86,7 @@ def test_hard_day_mode_uses_equipment_setting(tmp_store: MagicMock) -> None:
result = engine.suggest(req)
# Engine should have read the equipment setting
tmp_store.get_setting.assert_called_with("cooking_equipment")
tmp_store.get_setting.assert_any_call("cooking_equipment")
# Result is a valid RecipeResult (no crash)
assert result is not None
assert hasattr(result, "suggestions")
@ -108,3 +108,34 @@ def test_put_null_value_returns_422(tmp_store: MagicMock) -> None:
json={"value": None},
)
assert resp.status_code == 422
def test_set_and_get_sensory_preferences(tmp_store: MagicMock) -> None:
"""PUT then GET round-trips the sensory_preferences value."""
prefs = json.dumps({
"avoid_textures": ["mushy", "slimy"],
"max_smell": "pungent",
"max_noise": "loud",
})
put_resp = client.put(
"/api/v1/settings/sensory_preferences",
json={"value": prefs},
)
assert put_resp.status_code == 200
assert put_resp.json()["key"] == "sensory_preferences"
tmp_store.set_setting.assert_called_with("sensory_preferences", prefs)
tmp_store.get_setting.return_value = prefs
get_resp = client.get("/api/v1/settings/sensory_preferences")
assert get_resp.status_code == 200
assert get_resp.json()["value"] == prefs
def test_sensory_preferences_unknown_key_still_422(tmp_store: MagicMock) -> None:
"""Confirm unknown keys still 422 after adding sensory_preferences."""
resp = client.put(
"/api/v1/settings/sensory_taste_buds",
json={"value": "{}"},
)
assert resp.status_code == 422

View file

@ -0,0 +1,130 @@
"""Tests for app/services/recipe/sensory.py."""
from __future__ import annotations
import json
from app.services.recipe.sensory import (
SensoryExclude,
build_sensory_exclude,
passes_sensory_filter,
)
class TestBuildSensoryExclude:
def test_none_input_returns_empty(self):
assert build_sensory_exclude(None).is_empty()
def test_empty_string_returns_empty(self):
assert build_sensory_exclude("").is_empty()
def test_malformed_json_returns_empty(self):
assert build_sensory_exclude("{not valid json}").is_empty()
def test_parses_avoid_textures(self):
prefs = json.dumps({"avoid_textures": ["mushy", "slimy"], "max_smell": None, "max_noise": None})
result = build_sensory_exclude(prefs)
assert "mushy" in result.textures
assert "slimy" in result.textures
def test_parses_max_smell(self):
prefs = json.dumps({"avoid_textures": [], "max_smell": "pungent", "max_noise": None})
result = build_sensory_exclude(prefs)
assert result.smell_above == "pungent"
def test_parses_max_noise(self):
prefs = json.dumps({"avoid_textures": [], "max_smell": None, "max_noise": "loud"})
result = build_sensory_exclude(prefs)
assert result.noise_above == "loud"
def test_unknown_smell_level_becomes_none(self):
prefs = json.dumps({"avoid_textures": [], "max_smell": "extremely_pungent", "max_noise": None})
result = build_sensory_exclude(prefs)
assert result.smell_above is None
def test_unknown_noise_level_becomes_none(self):
prefs = json.dumps({"avoid_textures": [], "max_smell": None, "max_noise": "ear_splitting"})
result = build_sensory_exclude(prefs)
assert result.noise_above is None
def test_null_max_smell_is_none(self):
prefs = json.dumps({"avoid_textures": [], "max_smell": None, "max_noise": None})
assert build_sensory_exclude(prefs).smell_above is None
def test_is_empty_all_none(self):
prefs = json.dumps({"avoid_textures": [], "max_smell": None, "max_noise": None})
assert build_sensory_exclude(prefs).is_empty()
def test_is_not_empty_with_textures(self):
prefs = json.dumps({"avoid_textures": ["mushy"]})
assert not build_sensory_exclude(prefs).is_empty()
class TestPassesSensoryFilter:
def _tags(self, textures=None, smell="mild", noise="quiet") -> str:
return json.dumps({"textures": textures or [], "smell": smell, "noise": noise})
def test_empty_exclude_always_passes(self):
tags = self._tags(textures=["mushy"], smell="fermented", noise="very_loud")
assert passes_sensory_filter(tags, SensoryExclude.empty()) is True
def test_untagged_recipe_always_passes(self):
exclude = SensoryExclude(textures=("mushy",), smell_above="pungent")
assert passes_sensory_filter("{}", exclude) is True
assert passes_sensory_filter(None, exclude) is True
assert passes_sensory_filter({}, exclude) is True
def test_texture_hit_returns_false(self):
tags = self._tags(textures=["mushy", "creamy"])
exclude = SensoryExclude(textures=("mushy",))
assert passes_sensory_filter(tags, exclude) is False
def test_texture_no_overlap_passes(self):
tags = self._tags(textures=["crunchy"])
exclude = SensoryExclude(textures=("mushy", "slimy"))
assert passes_sensory_filter(tags, exclude) is True
def test_smell_above_threshold_excluded(self):
tags = self._tags(smell="fermented")
exclude = SensoryExclude(smell_above="pungent")
assert passes_sensory_filter(tags, exclude) is False
def test_smell_at_threshold_passes(self):
tags = self._tags(smell="pungent")
exclude = SensoryExclude(smell_above="pungent")
assert passes_sensory_filter(tags, exclude) is True
def test_smell_below_threshold_passes(self):
for smell in ("aromatic", "mild"):
tags = self._tags(smell=smell)
exclude = SensoryExclude(smell_above="pungent")
assert passes_sensory_filter(tags, exclude) is True
def test_noise_above_threshold_excluded(self):
tags = self._tags(noise="very_loud")
exclude = SensoryExclude(noise_above="loud")
assert passes_sensory_filter(tags, exclude) is False
def test_noise_at_threshold_passes(self):
tags = self._tags(noise="loud")
exclude = SensoryExclude(noise_above="loud")
assert passes_sensory_filter(tags, exclude) is True
def test_noise_below_threshold_passes(self):
for noise in ("quiet", "moderate"):
tags = self._tags(noise=noise)
exclude = SensoryExclude(noise_above="loud")
assert passes_sensory_filter(tags, exclude) is True
def test_combined_texture_and_smell(self):
tags = self._tags(textures=["creamy"], smell="fermented")
exclude = SensoryExclude(textures=("creamy",), smell_above="pungent")
assert passes_sensory_filter(tags, exclude) is False
def test_dict_input_works(self):
tags_dict = {"textures": ["mushy"], "smell": "mild", "noise": "quiet"}
exclude = SensoryExclude(textures=("mushy",))
assert passes_sensory_filter(tags_dict, exclude) is False
def test_malformed_sensory_tags_passes(self):
exclude = SensoryExclude(textures=("mushy",), smell_above="pungent")
assert passes_sensory_filter("{bad json", exclude) is True

View file

@ -0,0 +1,141 @@
"""Tests for scripts/tag_sensory_profiles.py classification logic."""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from scripts.tag_sensory_profiles import (
_classify_textures,
_classify_smell,
_classify_noise,
)
class TestClassifyTextures:
def test_mushy_from_direction(self):
assert "mushy" in _classify_textures([], ["stew the vegetables until soft"], set())
def test_mushy_from_braise(self):
assert "mushy" in _classify_textures([], ["braise for 2 hours"], set())
def test_crunchy_from_roast(self):
assert "crunchy" in _classify_textures([], ["roast at 425F until golden"], set())
def test_crunchy_from_ingredient_name(self):
assert "crunchy" in _classify_textures(["breadcrumbs", "chicken"], [], set())
def test_slimy_from_okra(self):
assert "slimy" in _classify_textures(["okra", "tomatoes"], [], set())
def test_slimy_from_natto(self):
assert "slimy" in _classify_textures(["natto", "rice"], [], set())
def test_chewy_from_calamari(self):
assert "chewy" in _classify_textures(["calamari", "lemon"], [], set())
def test_chewy_from_jerky(self):
assert "chewy" in _classify_textures(["beef jerky"], [], set())
def test_creamy_from_profile(self):
assert "creamy" in _classify_textures([], [], {"creamy"})
def test_creamy_from_fatty_profile(self):
assert "creamy" in _classify_textures([], [], {"fatty"})
def test_creamy_from_blend_direction(self):
assert "creamy" in _classify_textures([], ["blend until smooth"], set())
def test_chunky_from_dice_direction(self):
assert "chunky" in _classify_textures([], ["dice the potatoes", "add to stew"], set())
def test_multiple_textures_can_fire(self):
textures = _classify_textures(["okra", "breadcrumbs"], ["roast until crispy"], set())
assert "slimy" in textures
assert "crunchy" in textures
def test_no_signals_returns_list(self):
result = _classify_textures(["chicken", "rice"], ["cook for 20 minutes"], set())
assert isinstance(result, list)
def test_case_insensitive_matching(self):
assert "slimy" in _classify_textures(["OKRA", "Tomatoes"], [], set())
class TestClassifySmell:
def test_fermented_from_fish_sauce(self):
assert _classify_smell(["fish sauce", "lime juice"]) == "fermented"
def test_fermented_from_miso(self):
assert _classify_smell(["miso paste", "ginger"]) == "fermented"
def test_fermented_from_soy_sauce(self):
assert _classify_smell(["soy sauce", "garlic"]) == "fermented"
def test_fermented_wins_over_pungent(self):
assert _classify_smell(["garlic", "soy sauce"]) == "fermented"
def test_pungent_from_garlic(self):
assert _classify_smell(["garlic", "onion", "chicken"]) == "pungent"
def test_pungent_from_curry_powder(self):
assert _classify_smell(["curry powder", "rice"]) == "pungent"
def test_aromatic_from_basil(self):
assert _classify_smell(["basil", "tomatoes", "pasta"]) == "aromatic"
def test_aromatic_from_cinnamon(self):
assert _classify_smell(["cinnamon", "apples", "sugar"]) == "aromatic"
def test_mild_default(self):
assert _classify_smell(["chicken", "broth", "salt"]) == "mild"
def test_empty_ingredients_mild(self):
assert _classify_smell([]) == "mild"
def test_case_insensitive(self):
assert _classify_smell(["Fish Sauce", "lime"]) == "fermented"
class TestClassifyNoise:
def test_very_loud_from_deep_fry(self):
assert _classify_noise(["deep fry the chicken at 375F"]) == "very_loud"
def test_very_loud_from_pressure_cook(self):
assert _classify_noise(["pressure cook on high for 20 minutes"]) == "very_loud"
def test_very_loud_from_instant_pot(self):
assert _classify_noise(["add to instant pot, seal, cook 15 min"]) == "very_loud"
def test_loud_from_sear(self):
assert _classify_noise(["sear the steak over high heat"]) == "loud"
def test_loud_from_stir_fry(self):
assert _classify_noise(["stir fry the vegetables"]) == "loud"
def test_loud_from_wok(self):
assert _classify_noise(["heat the wok until smoking"]) == "loud"
def test_loud_from_bare_fry_no_deep(self):
assert _classify_noise(["fry the eggs until set"]) == "loud"
def test_very_loud_wins_over_loud(self):
assert _classify_noise(["deep fry for 3 minutes"]) == "very_loud"
def test_moderate_from_saute(self):
assert _classify_noise(["saute the onions until translucent"]) == "moderate"
def test_moderate_from_bake(self):
assert _classify_noise(["bake at 350F for 30 minutes"]) == "moderate"
def test_moderate_from_roast(self):
assert _classify_noise(["roast the vegetables for 25 minutes"]) == "moderate"
def test_quiet_default(self):
assert _classify_noise(["mix the ingredients", "chill for 1 hour"]) == "quiet"
def test_empty_directions_quiet(self):
assert _classify_noise([]) == "quiet"
def test_case_insensitive(self):
assert _classify_noise(["Deep Fry the chicken"]) == "very_loud"