- 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
133 lines
4.3 KiB
Python
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
|