Filter meal suggestions by simple flavor profiles

This commit is contained in:
2026-04-13 18:47:39 +02:00
parent f85ec81851
commit 5a1c1d5c41
5 changed files with 193 additions and 35 deletions
+72 -26
View File
@@ -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,