From 521cb419bc37d7036c5e150148cb29dd2f914a52 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 24 Apr 2026 09:47:48 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20sensory=20profile=20filter=20=E2=80=94?= =?UTF-8?q?=20texture/smell/noise=20filtering=20for=20Browse=20and=20Find?= =?UTF-8?q?=20(kiwi#51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/api/endpoints/recipes.py | 6 + app/api/endpoints/settings.py | 2 +- app/db/migrations/035_sensory_tags.sql | 12 ++ app/db/store.py | 18 +- app/services/recipe/recipe_engine.py | 12 +- app/services/recipe/sensory.py | 133 ++++++++++++ frontend/src/components/SettingsView.vue | 209 ++++++++++++++++++- frontend/src/services/api.ts | 18 ++ frontend/src/stores/settings.ts | 32 ++- scripts/tag_sensory_profiles.py | 247 +++++++++++++++++++++++ tests/api/test_settings.py | 33 ++- tests/services/test_sensory.py | 130 ++++++++++++ tests/test_tag_sensory_profiles.py | 141 +++++++++++++ 13 files changed, 985 insertions(+), 8 deletions(-) create mode 100644 app/db/migrations/035_sensory_tags.sql create mode 100644 app/services/recipe/sensory.py create mode 100644 scripts/tag_sensory_profiles.py create mode 100644 tests/services/test_sensory.py create mode 100644 tests/test_tag_sensory_profiles.py diff --git a/app/api/endpoints/recipes.py b/app/api/endpoints/recipes.py index 87a2e14..1b87606 100644 --- a/app/api/endpoints/recipes.py +++ b/app/api/endpoints/recipes.py @@ -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 ──────────────── diff --git a/app/api/endpoints/settings.py b/app/api/endpoints/settings.py index 4c1f826..7c0a548 100644 --- a/app/api/endpoints/settings.py +++ b/app/api/endpoints/settings.py @@ -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): diff --git a/app/db/migrations/035_sensory_tags.sql b/app/db/migrations/035_sensory_tags.sql new file mode 100644 index 0000000..005ea19 --- /dev/null +++ b/app/db/migrations/035_sensory_tags.sql @@ -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 '{}'; diff --git a/app/db/store.py b/app/db/store.py index 62e6941..b9fa0bf 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -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: diff --git a/app/services/recipe/recipe_engine.py b/app/services/recipe/recipe_engine.py index a594436..62661de 100644 --- a/app/services/recipe/recipe_engine.py +++ b/app/services/recipe/recipe_engine.py @@ -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. diff --git a/app/services/recipe/sensory.py b/app/services/recipe/sensory.py new file mode 100644 index 0000000..a346fc4 --- /dev/null +++ b/app/services/recipe/sensory.py @@ -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 diff --git a/frontend/src/components/SettingsView.vue b/frontend/src/components/SettingsView.vue index 3eb6033..e98f163 100644 --- a/frontend/src/components/SettingsView.vue +++ b/frontend/src/components/SettingsView.vue @@ -64,6 +64,89 @@ + +
+

Sensory Preferences

+

+ 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. +

+ + +
+ +
+ +
+
+ + +
+ +
+ +
+

+ Recipes stronger than {{ smellLabel(settingsStore.sensoryPreferences.max_smell) }} will be hidden. +

+
+ + +
+ +
+ +
+

+ Recipes louder than {{ noiseLabel(settingsStore.sensoryPreferences.max_noise) }} will be hidden. +

+
+ +
+ +
+
+

Units

@@ -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' +} + +/* ── 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); +} + \ No newline at end of file diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index ac38a93..71588dc 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1091,4 +1091,22 @@ export async function bootstrapSession(): Promise { } } +// ========== 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 diff --git a/frontend/src/stores/settings.ts b/frontend/src/stores/settings.ts index 1f46daf..1bff12e 100644 --- a/frontend/src/stores/settings.ts +++ b/frontend/src/stores/settings.ts @@ -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([]) const unitSystem = ref('metric') const shoppingLocale = ref('us') + const sensoryPreferences = ref({ ...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, } }) diff --git a/scripts/tag_sensory_profiles.py b/scripts/tag_sensory_profiles.py new file mode 100644 index 0000000..65b92ee --- /dev/null +++ b/scripts/tag_sensory_profiles.py @@ -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) diff --git a/tests/api/test_settings.py b/tests/api/test_settings.py index f670ce5..784d3a7 100644 --- a/tests/api/test_settings.py +++ b/tests/api/test_settings.py @@ -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 diff --git a/tests/services/test_sensory.py b/tests/services/test_sensory.py new file mode 100644 index 0000000..bd09b20 --- /dev/null +++ b/tests/services/test_sensory.py @@ -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 diff --git a/tests/test_tag_sensory_profiles.py b/tests/test_tag_sensory_profiles.py new file mode 100644 index 0000000..a1659c9 --- /dev/null +++ b/tests/test_tag_sensory_profiles.py @@ -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"