kiwi/app/services/recipe/sensory.py
pyr0ball 521cb419bc 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
2026-04-24 09:47:48 -07:00

133 lines
4.3 KiB
Python

"""
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