From 5a1c1d5c411963c8c001c068157868c09ae2ff85 Mon Sep 17 00:00:00 2001 From: Florian Heinz Date: Mon, 13 Apr 2026 18:47:39 +0200 Subject: [PATCH] Filter meal suggestions by simple flavor profiles --- nouri/constants.py | 18 ++++++ nouri/db.py | 73 ++++++++++++++++++++++++ nouri/main.py | 98 ++++++++++++++++++++++++--------- nouri/schema.sql | 1 + nouri/templates/items/form.html | 38 ++++++++++--- 5 files changed, 193 insertions(+), 35 deletions(-) diff --git a/nouri/constants.py b/nouri/constants.py index e690160..5034411 100644 --- a/nouri/constants.py +++ b/nouri/constants.py @@ -67,6 +67,24 @@ BUILDER_DESCRIPTIONS = { BUILDER_OPTIONS = [(key, label) for key, label in BUILDER_LABELS.items()] +FOOD_FLAVOR_OPTIONS = [ + ("neutral", "Neutral"), + ("sweet", "Süß"), + ("savory", "Herzhaft"), +] + +FOOD_FLAVOR_LABELS = { + "neutral": "Neutral", + "sweet": "Süß", + "savory": "Herzhaft", +} + +FOOD_FLAVOR_DESCRIPTIONS = { + "neutral": "Passt ruhig in beide Richtungen und bleibt flexibel.", + "sweet": "Passt eher zu süßen Kombinationen, Frühstücksideen oder kleinen Snacks.", + "savory": "Passt eher zu herzhaften Kombinationen und ruhigeren Hauptmahlzeiten.", +} + FOOD_ROLE_LABELS = { "main": "Hauptbaustein", "base": "Basis", diff --git a/nouri/db.py b/nouri/db.py index 59d65ac..33a4cf9 100644 --- a/nouri/db.py +++ b/nouri/db.py @@ -39,6 +39,39 @@ def normalize_name_for_profile(name: str | None) -> str: return (name or "").strip().lower() +def infer_food_flavor_profile( + name: str | None, + category: str | None, + base_type: str | None = None, + suggestion_role: str | None = None, +) -> str: + normalized_name = normalize_name_for_profile(name) + normalized_category = (category or "").strip().lower() + normalized_base_type = (base_type or "").strip().lower() + normalized_role = (suggestion_role or "").strip().lower() + + if any(token in normalized_name for token in ("proteinpulver", "eiweißpulver", "whey", "clear whey")): + return "neutral" + if any(token in normalized_name for token in ("schoko", "choco", "müsli", "granola", "cornflakes", "fruchtjoghurt", "vanillejoghurt", "pudding")): + return "sweet" + if any(token in normalized_name for token in ("banane", "apfel", "birne", "beeren", "himbeer", "erdbeer", "heidelbeer", "mango", "kiwi", "trauben")): + return "sweet" + if any(token in normalized_name for token in ("räucher", "tofu", "tempeh", "hack", "salami", "wurst", "thunfisch", "lachs", "fisch", "huhn", "hähn", "rind", "schwein", "aufstrich", "pesto", "humus", "hummus", "reisgericht", "chili", "curry")): + return "savory" + if any(token in normalized_name for token in ("naturjoghurt", "joghurt natur", "quark", "skyr", "haferflocken", "gurke", "karotte", "karotten", "kartoffel", "kartoffeln", "reis", "nudeln", "brot", "brötchen")): + return "neutral" + + if "obst" in normalized_category or normalized_base_type == "fruit": + return "sweet" + if any(token in normalized_category for token in ("eiweiß", "protein")) or normalized_base_type == "protein": + return "savory" + if any(token in normalized_category for token in ("gemüse",)) or normalized_base_type in {"veg", "carb", "dairy", "nuts", "seeds"}: + return "neutral" + if normalized_role in {"topping", "cooking"}: + return "neutral" + return "neutral" + + def infer_food_profile(name: str | None, category: str | None, energy_density: str | None) -> dict[str, object]: normalized_name = normalize_name_for_profile(name) normalized_category = (category or "").strip().lower() @@ -275,6 +308,42 @@ def migrate_item_profiles(database: sqlite3.Connection) -> None: ) +def migrate_food_flavor_profiles(database: sqlite3.Connection) -> None: + if get_meta(database, "food_flavor_profiles_migrated") == "1": + return + + rows = database.execute( + """ + SELECT id, name, category, base_type, suggestion_role, flavor_profile + FROM items + WHERE kind = 'food' + ORDER BY id + """ + ).fetchall() + for row in rows: + current_flavor = (row["flavor_profile"] or "").strip().lower() + if current_flavor in {"sweet", "savory"}: + continue + database.execute( + """ + UPDATE items + SET flavor_profile = ? + WHERE id = ? + """, + ( + infer_food_flavor_profile( + row["name"], + row["category"], + row["base_type"], + row["suggestion_role"], + ), + int(row["id"]), + ), + ) + + set_meta(database, "food_flavor_profiles_migrated", "1") + + def get_db() -> sqlite3.Connection: if "db" not in g: g.db = sqlite3.connect( @@ -392,6 +461,7 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None: add_column_if_missing(database, "items", "target_user_id INTEGER") add_column_if_missing(database, "items", "visibility TEXT NOT NULL DEFAULT 'shared'") add_column_if_missing(database, "items", "base_type TEXT NOT NULL DEFAULT 'neutral'") + add_column_if_missing(database, "items", "flavor_profile TEXT NOT NULL DEFAULT 'neutral'") add_column_if_missing(database, "items", "suggestion_role TEXT NOT NULL DEFAULT 'base'") add_column_if_missing(database, "items", "suggestion_priority TEXT NOT NULL DEFAULT 'normal'") add_column_if_missing(database, "items", "can_be_meal_core INTEGER NOT NULL DEFAULT 0") @@ -645,6 +715,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: add_column_if_missing(database, table_name, "visibility TEXT NOT NULL DEFAULT 'shared'") add_column_if_missing(database, "items", "target_user_id INTEGER") add_column_if_missing(database, "items", "base_type TEXT NOT NULL DEFAULT 'neutral'") + add_column_if_missing(database, "items", "flavor_profile TEXT NOT NULL DEFAULT 'neutral'") add_column_if_missing(database, "items", "suggestion_role TEXT NOT NULL DEFAULT 'base'") add_column_if_missing(database, "items", "suggestion_priority TEXT NOT NULL DEFAULT 'normal'") add_column_if_missing(database, "items", "can_be_meal_core INTEGER NOT NULL DEFAULT 0") @@ -699,6 +770,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: sync_default_categories(database) migrate_item_profiles(database) + migrate_food_flavor_profiles(database) database.execute( """ INSERT OR IGNORE INTO user_settings (user_id) @@ -707,6 +779,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None: ) database.execute("UPDATE items SET energy_density = 'neutral' WHERE energy_density IS NULL OR energy_density = ''") database.execute("UPDATE items SET base_type = 'neutral' WHERE base_type IS NULL OR base_type = ''") + database.execute("UPDATE items SET flavor_profile = 'neutral' WHERE flavor_profile IS NULL OR flavor_profile = ''") database.execute("UPDATE items SET suggestion_role = 'base' WHERE suggestion_role IS NULL OR suggestion_role = ''") database.execute("UPDATE items SET suggestion_priority = 'normal' WHERE suggestion_priority IS NULL OR suggestion_priority = ''") database.execute("UPDATE items SET can_be_meal_core = 0 WHERE can_be_meal_core IS NULL") diff --git a/nouri/main.py b/nouri/main.py index 7d54b87..4917fcd 100644 --- a/nouri/main.py +++ b/nouri/main.py @@ -33,6 +33,9 @@ from .constants import ( DEFAULT_CATEGORIES, ENERGY_DENSITY_LABELS, ENERGY_DENSITY_OPTIONS, + FOOD_FLAVOR_DESCRIPTIONS, + FOOD_FLAVOR_LABELS, + FOOD_FLAVOR_OPTIONS, FOOD_ROLE_DESCRIPTIONS, FOOD_ROLE_LABELS, FOOD_ROLE_OPTIONS, @@ -311,6 +314,11 @@ def normalize_base_type(raw: str | None, default: str = "neutral") -> str: return raw if raw in allowed else default +def normalize_food_flavor(raw: str | None, default: str = "neutral") -> str: + allowed = {value for value, _label in FOOD_FLAVOR_OPTIONS} + return raw if raw in allowed else default + + def normalize_food_role(raw: str | None, default: str = "base") -> str: allowed = {value for value, _label in FOOD_ROLE_OPTIONS} return raw if raw in allowed else default @@ -428,6 +436,8 @@ def describe_record(entry: dict) -> dict: entry["energy_density_label"] = ENERGY_DENSITY_LABELS.get(entry["energy_density"], ENERGY_DENSITY_LABELS["neutral"]) entry["base_type"] = normalize_base_type(entry.get("base_type"), "neutral") entry["base_type_label"] = BUILDER_LABELS.get(entry["base_type"], BUILDER_LABELS["neutral"]) + entry["flavor_profile"] = normalize_food_flavor(entry.get("flavor_profile"), "neutral") + entry["flavor_profile_label"] = FOOD_FLAVOR_LABELS.get(entry["flavor_profile"], FOOD_FLAVOR_LABELS["neutral"]) entry["suggestion_role"] = normalize_food_role(entry.get("suggestion_role"), "base") entry["suggestion_role_label"] = FOOD_ROLE_LABELS.get(entry["suggestion_role"], FOOD_ROLE_LABELS["base"]) entry["suggestion_priority"] = normalize_suggestion_priority(entry.get("suggestion_priority"), "normal") @@ -714,12 +724,29 @@ def food_supports_slot(food: dict, slot: dict) -> bool: base_type = normalize_base_type(food.get("base_type"), "neutral") accepted = set(slot.get("base_types", set())) - if "fiber" in accepted and base_type in {"veg", "fruit"}: + matches_base_type = ("fiber" in accepted and base_type in {"veg", "fruit"}) or base_type in accepted + if not matches_base_type: + return False + + accepted_flavors = set(slot.get("flavors", set())) + if not accepted_flavors: return True - return base_type in accepted + return normalize_food_flavor(food.get("flavor_profile"), "neutral") in accepted_flavors + + +def components_are_flavor_compatible(component_items: list[dict]) -> bool: + flavors = { + normalize_food_flavor(item.get("flavor_profile"), "neutral") + for item in component_items + if meaningful_component(item) + } + return not ({"sweet", "savory"} <= flavors) def score_suggestion_components(component_items: list[dict], daypart_slug: str, settings: dict) -> int: + if not components_are_flavor_compatible(component_items): + return -999 + meaningful_items = [item for item in component_items if meaningful_component(item)] builder_keys = {key for item in meaningful_items for key in item.get("builder_keys", ["neutral"])} energy_values = [normalize_energy_density(item.get("energy_density"), "neutral") for item in component_items] @@ -857,6 +884,7 @@ def extract_item_form_data(existing: dict | None = None) -> dict: "name": request.form.get("name", "").strip(), "category": request.form.get("category", "").strip(), "base_type": normalize_base_type(request.form.get("base_type"), form_data.get("base_type", "neutral")), + "flavor_profile": normalize_food_flavor(request.form.get("flavor_profile"), form_data.get("flavor_profile", "neutral")), "suggestion_role": normalize_food_role(request.form.get("suggestion_role"), form_data.get("suggestion_role", "base")), "suggestion_priority": normalize_suggestion_priority( request.form.get("suggestion_priority"), @@ -879,6 +907,10 @@ def extract_item_form_data(existing: dict | None = None) -> dict: request.form.get("quick_food_base_type"), form_data.get("quick_food_base_type", "neutral"), ), + "quick_food_flavor_profile": normalize_food_flavor( + request.form.get("quick_food_flavor_profile"), + form_data.get("quick_food_flavor_profile", "neutral"), + ), "quick_food_role": normalize_food_role( request.form.get("quick_food_role"), form_data.get("quick_food_role", "base"), @@ -899,9 +931,9 @@ def create_quick_food_from_form(form_data: dict) -> int: cursor = get_db().execute( """ INSERT INTO items ( - household_id, owner_user_id, target_user_id, visibility, kind, name, category, base_type, suggestion_role, suggestion_priority, can_be_meal_core, energy_density, note, created_by, updated_by + household_id, owner_user_id, target_user_id, visibility, kind, name, category, base_type, flavor_profile, suggestion_role, suggestion_priority, can_be_meal_core, energy_density, note, created_by, updated_by ) - VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( current_household_id(), @@ -911,6 +943,7 @@ def create_quick_food_from_form(form_data: dict) -> int: form_data["quick_food_name"], form_data["quick_food_category"], form_data["quick_food_base_type"], + form_data["quick_food_flavor_profile"], form_data["quick_food_role"], form_data["quick_food_priority"], 1 if form_data["quick_food_can_be_meal_core"] else 0, @@ -1522,17 +1555,17 @@ def meal_pattern_definitions(daypart_slug: str) -> list[dict]: { "reason": "Passt gut zu Frühstück", "slots": [ - {"base_types": {"carb"}, "roles": {"base", "main", "solo"}, "core_only": True}, - {"base_types": {"dairy", "protein"}, "roles": {"base", "main", "complement", "solo", "snack"}, "core_only": False}, - {"base_types": {"fruit"}, "roles": {"complement", "topping", "snack", "base"}, "core_only": False}, + {"base_types": {"carb"}, "roles": {"base", "main", "solo"}, "core_only": True, "flavors": {"sweet", "neutral"}}, + {"base_types": {"dairy", "protein"}, "roles": {"base", "main", "complement", "solo", "snack"}, "core_only": False, "flavors": {"sweet", "neutral"}}, + {"base_types": {"fruit"}, "roles": {"complement", "topping", "snack", "base"}, "core_only": False, "flavors": {"sweet", "neutral"}}, ], }, { "reason": "Passt gut für Frühstück", "slots": [ - {"base_types": {"dairy"}, "roles": {"base", "main", "solo", "snack"}, "core_only": True}, - {"base_types": {"carb"}, "roles": {"base", "main", "complement", "solo"}, "core_only": False}, - {"base_types": {"nuts", "seeds", "fruit"}, "roles": {"topping", "complement", "snack"}, "core_only": False}, + {"base_types": {"dairy"}, "roles": {"base", "main", "solo", "snack"}, "core_only": True, "flavors": {"sweet", "neutral"}}, + {"base_types": {"carb"}, "roles": {"base", "main", "complement", "solo"}, "core_only": False, "flavors": {"sweet", "neutral"}}, + {"base_types": {"nuts", "seeds", "fruit"}, "roles": {"topping", "complement", "snack"}, "core_only": False, "flavors": {"sweet", "neutral"}}, ], }, ] @@ -1542,22 +1575,22 @@ def meal_pattern_definitions(daypart_slug: str) -> list[dict]: { "reason": "Passt gut zu einem kleinen Snack", "slots": [ - {"base_types": {"dairy"}, "roles": {"base", "solo", "snack"}, "core_only": True}, - {"base_types": {"fruit"}, "roles": {"complement", "snack", "topping"}, "core_only": False}, + {"base_types": {"dairy"}, "roles": {"base", "solo", "snack"}, "core_only": True, "flavors": {"sweet", "neutral"}}, + {"base_types": {"fruit"}, "roles": {"complement", "snack", "topping"}, "core_only": False, "flavors": {"sweet", "neutral"}}, ], }, { "reason": "Zuhause schnell kombinierbar", "slots": [ - {"base_types": {"fruit"}, "roles": {"base", "snack", "complement"}, "core_only": True}, - {"base_types": {"nuts", "seeds"}, "roles": {"topping", "snack", "complement"}, "core_only": False}, + {"base_types": {"fruit"}, "roles": {"base", "snack", "complement"}, "core_only": True, "flavors": {"sweet", "neutral"}}, + {"base_types": {"nuts", "seeds"}, "roles": {"topping", "snack", "complement"}, "core_only": False, "flavors": {"sweet", "neutral"}}, ], }, { "reason": "Passt gut zu einem kleinen Snack", "slots": [ - {"base_types": {"carb"}, "roles": {"solo", "base", "snack"}, "core_only": True}, - {"base_types": {"protein", "dairy"}, "roles": {"complement", "snack", "base"}, "core_only": False}, + {"base_types": {"carb"}, "roles": {"solo", "base", "snack"}, "core_only": True, "flavors": {"sweet", "neutral"}}, + {"base_types": {"protein", "dairy"}, "roles": {"complement", "snack", "base"}, "core_only": False, "flavors": {"sweet", "neutral"}}, ], }, ] @@ -1566,24 +1599,24 @@ def meal_pattern_definitions(daypart_slug: str) -> list[dict]: { "reason": "Zuhause als vollständige Mahlzeit möglich", "slots": [ - {"base_types": {"protein"}, "roles": {"main", "base", "solo"}, "core_only": True}, - {"base_types": {"carb"}, "roles": {"base", "main", "solo"}, "core_only": True}, - {"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False}, + {"base_types": {"protein"}, "roles": {"main", "base", "solo"}, "core_only": True, "flavors": {"savory", "neutral"}}, + {"base_types": {"carb"}, "roles": {"base", "main", "solo"}, "core_only": True, "flavors": {"savory", "neutral"}}, + {"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False, "flavors": {"savory", "neutral"}}, ], }, { "reason": "Lässt sich gut ergänzen", "slots": [ - {"base_types": {"protein"}, "roles": {"main", "base", "solo"}, "core_only": True}, - {"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False}, - {"base_types": {"carb"}, "roles": {"base", "complement", "solo"}, "core_only": False}, + {"base_types": {"protein"}, "roles": {"main", "base", "solo"}, "core_only": True, "flavors": {"savory", "neutral"}}, + {"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False, "flavors": {"savory", "neutral"}}, + {"base_types": {"carb"}, "roles": {"base", "complement", "solo"}, "core_only": False, "flavors": {"savory", "neutral"}}, ], }, { "reason": "Schnell und alltagstauglich", "slots": [ - {"base_types": {"carb", "protein"}, "roles": {"solo"}, "core_only": True}, - {"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False}, + {"base_types": {"carb", "protein"}, "roles": {"solo"}, "core_only": True, "flavors": {"savory", "neutral"}}, + {"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False, "flavors": {"savory", "neutral"}}, ], }, ] @@ -1629,6 +1662,8 @@ def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, li continue seen_signatures.add(signature) combo_items = list(combo) + if not components_are_flavor_compatible(combo_items): + continue suggestions.append( { "title": build_generated_meal_name(combo_items, daypart_slug), @@ -1685,6 +1720,8 @@ def build_home_recipe_suggestions(daypart_id: int | None = None, limit: int = 4) continue component_items = [visible_food_map[component_id] for component_id in component_ids] + if not components_are_flavor_compatible(component_items): + continue available_items = [home_food_map[component_id] for component_id in component_ids if component_id in home_food_map] missing_items = [visible_food_map[component_id] for component_id in component_ids if component_id not in home_food_ids] @@ -2851,6 +2888,8 @@ def render_item_form(kind: str, *, item: dict | None, form_data: dict): ), form_data=form_data, builder_options=[(key, label) for key, label in BUILDER_LABELS.items()], + food_flavor_options=FOOD_FLAVOR_OPTIONS, + food_flavor_descriptions=FOOD_FLAVOR_DESCRIPTIONS, food_role_options=FOOD_ROLE_OPTIONS, food_role_descriptions=FOOD_ROLE_DESCRIPTIONS, suggestion_priority_options=SUGGESTION_PRIORITY_OPTIONS, @@ -3684,6 +3723,7 @@ def item_create(kind: str): "name": request.args.get("name", "").strip(), "category": "", "base_type": "neutral", + "flavor_profile": "neutral", "suggestion_role": "base", "suggestion_priority": "normal", "can_be_meal_core": False, @@ -3700,6 +3740,7 @@ def item_create(kind: str): "quick_food_name": "", "quick_food_category": "", "quick_food_base_type": "neutral", + "quick_food_flavor_profile": "neutral", "quick_food_role": "base", "quick_food_priority": "normal", "quick_food_can_be_meal_core": False, @@ -3742,9 +3783,9 @@ def item_create(kind: str): cursor = get_db().execute( """ INSERT INTO items ( - household_id, owner_user_id, target_user_id, visibility, kind, name, category, base_type, suggestion_role, suggestion_priority, can_be_meal_core, meal_type, meal_tags, energy_density, note, photo_filename, created_by, updated_by + household_id, owner_user_id, target_user_id, visibility, kind, name, category, base_type, flavor_profile, suggestion_role, suggestion_priority, can_be_meal_core, meal_type, meal_tags, energy_density, note, photo_filename, created_by, updated_by ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( current_household_id(), @@ -3755,6 +3796,7 @@ def item_create(kind: str): form_data["name"], form_data["category"] if kind == "food" else None, form_data["base_type"] if kind == "food" else "neutral", + form_data["flavor_profile"] if kind == "food" else "neutral", form_data["suggestion_role"] if kind == "food" else "base", form_data["suggestion_priority"] if kind == "food" else "normal", 1 if (form_data["can_be_meal_core"] if kind == "food" else False) else 0, @@ -3793,6 +3835,7 @@ def item_edit(item_id: int): "name": item["name"], "category": item["category"] or "", "base_type": item.get("base_type") or "neutral", + "flavor_profile": item.get("flavor_profile") or "neutral", "suggestion_role": item.get("suggestion_role") or "base", "suggestion_priority": item.get("suggestion_priority") or "normal", "can_be_meal_core": bool(item.get("can_be_meal_core")), @@ -3809,6 +3852,7 @@ def item_edit(item_id: int): "quick_food_name": "", "quick_food_category": "", "quick_food_base_type": "neutral", + "quick_food_flavor_profile": "neutral", "quick_food_role": "base", "quick_food_priority": "normal", "quick_food_can_be_meal_core": False, @@ -3854,6 +3898,7 @@ def item_edit(item_id: int): SET name = ?, category = ?, base_type = ?, + flavor_profile = ?, suggestion_role = ?, suggestion_priority = ?, can_be_meal_core = ?, @@ -3872,6 +3917,7 @@ def item_edit(item_id: int): form_data["name"], form_data["category"] if item["kind"] == "food" else None, form_data["base_type"] if item["kind"] == "food" else "neutral", + form_data["flavor_profile"] if item["kind"] == "food" else "neutral", form_data["suggestion_role"] if item["kind"] == "food" else "base", form_data["suggestion_priority"] if item["kind"] == "food" else "normal", 1 if (form_data["can_be_meal_core"] if item["kind"] == "food" else False) else 0, diff --git a/nouri/schema.sql b/nouri/schema.sql index 44d9567..f3c72ee 100644 --- a/nouri/schema.sql +++ b/nouri/schema.sql @@ -122,6 +122,7 @@ CREATE TABLE IF NOT EXISTS items ( name TEXT NOT NULL, category TEXT, base_type TEXT NOT NULL DEFAULT 'neutral', + flavor_profile TEXT NOT NULL DEFAULT 'neutral', suggestion_role TEXT NOT NULL DEFAULT 'base', suggestion_priority TEXT NOT NULL DEFAULT 'normal', can_be_meal_core INTEGER NOT NULL DEFAULT 0, diff --git a/nouri/templates/items/form.html b/nouri/templates/items/form.html index 5e41281..333b0f4 100644 --- a/nouri/templates/items/form.html +++ b/nouri/templates/items/form.html @@ -52,15 +52,27 @@ {% if kind == 'food' %} - +
+ + + +
+