Restructure food suggestion data and builder logic
This commit is contained in:
+408
-66
@@ -26,17 +26,30 @@ from .backup import RESTORE_CONFIRMATION_TEXT, export_backup_archive, restore_ba
|
||||
from .constants import (
|
||||
AVAILABILITY_LABELS,
|
||||
BUILDER_LABELS,
|
||||
BUILDER_OPTIONS,
|
||||
DAYPART_SLUG_TO_MEAL_TYPE,
|
||||
DEFAULT_CATEGORY_BUILDERS,
|
||||
DAY_TEMPLATE_NAME_SUGGESTIONS,
|
||||
DEFAULT_CATEGORIES,
|
||||
ENERGY_DENSITY_LABELS,
|
||||
ENERGY_DENSITY_OPTIONS,
|
||||
FOOD_ROLE_DESCRIPTIONS,
|
||||
FOOD_ROLE_LABELS,
|
||||
FOOD_ROLE_OPTIONS,
|
||||
ITEM_KIND_LABELS,
|
||||
ITEM_KIND_SINGULAR_LABELS,
|
||||
ITEM_SET_NAME_SUGGESTIONS,
|
||||
MEAL_STYLE_LABELS,
|
||||
MEAL_STYLE_OPTIONS,
|
||||
MEAL_TYPE_LABELS,
|
||||
MEAL_TYPE_OPTIONS,
|
||||
NOTIFICATION_CHANNEL_OPTIONS,
|
||||
PROTEIN_PREFERENCE_LABELS,
|
||||
PROTEIN_PREFERENCE_OPTIONS,
|
||||
SUGGESTION_STYLE_LABELS,
|
||||
SUGGESTION_STYLE_OPTIONS,
|
||||
SUGGESTION_PRIORITY_LABELS,
|
||||
SUGGESTION_PRIORITY_OPTIONS,
|
||||
VISIBILITY_DESCRIPTIONS,
|
||||
VISIBILITY_LABELS,
|
||||
WEEKDAY_OPTIONS,
|
||||
@@ -196,6 +209,7 @@ def default_user_settings() -> dict:
|
||||
"notification_channel": "in_app",
|
||||
"suggestion_style": suggestion_style,
|
||||
"energy_preference": suggestion_style_energy_preference(suggestion_style),
|
||||
"protein_preference": "mixed",
|
||||
"remind_before_shopping": True,
|
||||
"remind_on_shopping_day": True,
|
||||
"show_missing_for_upcoming_week": True,
|
||||
@@ -261,6 +275,7 @@ def get_user_settings() -> dict:
|
||||
settings["notification_channel"] = settings.get("notification_channel") or "in_app"
|
||||
settings["suggestion_style"] = normalize_suggestion_style(settings.get("suggestion_style"), "balanced")
|
||||
settings["energy_preference"] = suggestion_style_energy_preference(settings["suggestion_style"])
|
||||
settings["protein_preference"] = normalize_protein_preference(settings.get("protein_preference"), "mixed")
|
||||
return settings
|
||||
|
||||
|
||||
@@ -283,8 +298,6 @@ def normalize_notification_channel(raw: str | None, default: str = "in_app") ->
|
||||
|
||||
def normalize_suggestion_style(raw: str | None, default: str = "balanced") -> str:
|
||||
allowed = {value for value, _label in SUGGESTION_STYLE_OPTIONS}
|
||||
if raw == "easy" or raw == "snack":
|
||||
return "balanced"
|
||||
return raw if raw in allowed else default
|
||||
|
||||
|
||||
@@ -293,9 +306,57 @@ def normalize_energy_density(raw: str | None, default: str = "neutral") -> str:
|
||||
return raw if raw in allowed else default
|
||||
|
||||
|
||||
def normalize_base_type(raw: str | None, default: str = "neutral") -> str:
|
||||
allowed = {value for value, _label in BUILDER_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
|
||||
|
||||
|
||||
def normalize_suggestion_priority(raw: str | None, default: str = "normal") -> str:
|
||||
allowed = {value for value, _label in SUGGESTION_PRIORITY_OPTIONS}
|
||||
return raw if raw in allowed else default
|
||||
|
||||
|
||||
def normalize_meal_type(raw: str | None, default: str = "snack") -> str:
|
||||
allowed = {value for value, _label in MEAL_TYPE_OPTIONS}
|
||||
return raw if raw in allowed else default
|
||||
|
||||
|
||||
def normalize_meal_tags(values: list[str] | None) -> list[str]:
|
||||
allowed = {value for value, _label in MEAL_STYLE_OPTIONS}
|
||||
normalized: list[str] = []
|
||||
for value in values or []:
|
||||
if value in allowed and value not in normalized:
|
||||
normalized.append(value)
|
||||
return normalized
|
||||
|
||||
|
||||
def encode_tag_list(values: list[str] | None) -> str:
|
||||
return ",".join(normalize_meal_tags(values))
|
||||
|
||||
|
||||
def decode_tag_list(raw: str | None) -> list[str]:
|
||||
if not raw:
|
||||
return []
|
||||
return normalize_meal_tags([part.strip() for part in str(raw).split(",") if part.strip()])
|
||||
|
||||
|
||||
def normalize_protein_preference(raw: str | None, default: str = "mixed") -> str:
|
||||
allowed = {value for value, _label in PROTEIN_PREFERENCE_OPTIONS}
|
||||
return raw if raw in allowed else default
|
||||
|
||||
|
||||
def suggestion_style_energy_preference(style: str) -> str:
|
||||
if style == "fitness":
|
||||
return "low"
|
||||
if style == "easy":
|
||||
return "low"
|
||||
if style == "snack":
|
||||
return "neutral"
|
||||
return "neutral"
|
||||
|
||||
|
||||
@@ -365,6 +426,23 @@ def describe_record(entry: dict) -> dict:
|
||||
entry["target_name"] = target_name
|
||||
entry["energy_density"] = normalize_energy_density(entry.get("energy_density"), "neutral")
|
||||
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["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")
|
||||
entry["suggestion_priority_label"] = SUGGESTION_PRIORITY_LABELS.get(
|
||||
entry["suggestion_priority"],
|
||||
SUGGESTION_PRIORITY_LABELS["normal"],
|
||||
)
|
||||
entry["can_be_meal_core"] = bool(entry.get("can_be_meal_core"))
|
||||
entry["meal_type"] = normalize_meal_type(
|
||||
entry.get("meal_type"),
|
||||
DAYPART_SLUG_TO_MEAL_TYPE.get(entry.get("daypart_slug"), "snack"),
|
||||
)
|
||||
entry["meal_type_label"] = MEAL_TYPE_LABELS.get(entry["meal_type"], MEAL_TYPE_LABELS["snack"])
|
||||
entry["meal_tags"] = decode_tag_list(entry.get("meal_tags"))
|
||||
entry["meal_tag_labels"] = [MEAL_STYLE_LABELS.get(tag, tag) for tag in entry["meal_tags"]]
|
||||
entry["is_personal"] = entry.get("visibility") == "personal"
|
||||
entry["is_shared"] = entry.get("visibility") == "shared"
|
||||
entry["is_mine"] = entry.get("owner_user_id") == g.user["id"]
|
||||
@@ -516,7 +594,6 @@ def attach_builder_keys(items: list[dict]) -> list[dict]:
|
||||
if not items:
|
||||
return []
|
||||
|
||||
category_builder_map = get_category_builder_map()
|
||||
meal_ids = [item["id"] for item in items if item["kind"] == "meal"]
|
||||
meal_builder_map: dict[int, set[str]] = defaultdict(set)
|
||||
|
||||
@@ -525,7 +602,7 @@ def attach_builder_keys(items: list[dict]) -> list[dict]:
|
||||
rows = get_db().execute(
|
||||
f"""
|
||||
SELECT meal_components.meal_item_id,
|
||||
component.category
|
||||
component.base_type
|
||||
FROM meal_components
|
||||
JOIN items AS component ON component.id = meal_components.food_item_id
|
||||
WHERE meal_components.meal_item_id IN ({placeholders})
|
||||
@@ -533,7 +610,7 @@ def attach_builder_keys(items: list[dict]) -> list[dict]:
|
||||
meal_ids,
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
builder_key = category_builder_map.get(row["category"] or "", "neutral")
|
||||
builder_key = normalize_base_type(row["base_type"], "neutral")
|
||||
meal_builder_map[int(row["meal_item_id"])].add(builder_key)
|
||||
|
||||
for item in items:
|
||||
@@ -541,9 +618,9 @@ def attach_builder_keys(items: list[dict]) -> list[dict]:
|
||||
if item["kind"] == "meal":
|
||||
builder_keys = sorted(meal_builder_map.get(item["id"], set()))
|
||||
if not builder_keys:
|
||||
builder_keys = [category_builder_map.get(item.get("category") or "", "neutral")]
|
||||
builder_keys = [normalize_base_type(item.get("base_type"), "neutral")]
|
||||
else:
|
||||
builder_keys = [category_builder_map.get(item.get("category") or "", "neutral")]
|
||||
builder_keys = [normalize_base_type(item.get("base_type"), "neutral")]
|
||||
item["builder_keys"] = builder_keys
|
||||
item["builder_labels"] = [BUILDER_LABELS.get(key, BUILDER_LABELS["neutral"]) for key in builder_keys]
|
||||
item["primary_builder_key"] = builder_keys[0] if builder_keys else "neutral"
|
||||
@@ -557,23 +634,22 @@ def decorate_items(rows) -> list[dict]:
|
||||
def fetch_builder_keys_for_item_ids(item_ids: list[int]) -> dict[int, set[str]]:
|
||||
if not item_ids:
|
||||
return {}
|
||||
category_builder_map = get_category_builder_map()
|
||||
placeholders = ",".join("?" for _ in item_ids)
|
||||
rows = get_db().execute(
|
||||
f"""
|
||||
SELECT id, kind, category
|
||||
SELECT id, kind, base_type
|
||||
FROM items
|
||||
WHERE id IN ({placeholders})
|
||||
""",
|
||||
item_ids,
|
||||
).fetchall()
|
||||
builder_map: dict[int, set[str]] = {int(row["id"]): {category_builder_map.get(row["category"] or "", "neutral")} for row in rows}
|
||||
builder_map: dict[int, set[str]] = {int(row["id"]): {normalize_base_type(row["base_type"], "neutral")} for row in rows}
|
||||
meal_ids = [int(row["id"]) for row in rows if row["kind"] == "meal"]
|
||||
if meal_ids:
|
||||
meal_placeholders = ",".join("?" for _ in meal_ids)
|
||||
component_rows = get_db().execute(
|
||||
f"""
|
||||
SELECT meal_components.meal_item_id, component.category
|
||||
SELECT meal_components.meal_item_id, component.base_type
|
||||
FROM meal_components
|
||||
JOIN items AS component ON component.id = meal_components.food_item_id
|
||||
WHERE meal_components.meal_item_id IN ({meal_placeholders})
|
||||
@@ -582,15 +658,74 @@ def fetch_builder_keys_for_item_ids(item_ids: list[int]) -> dict[int, set[str]]:
|
||||
).fetchall()
|
||||
for row in component_rows:
|
||||
builder_map.setdefault(int(row["meal_item_id"]), set()).add(
|
||||
category_builder_map.get(row["category"] or "", "neutral")
|
||||
normalize_base_type(row["base_type"], "neutral")
|
||||
)
|
||||
return builder_map
|
||||
|
||||
|
||||
def suggestion_priority_score(priority: str) -> int:
|
||||
return {
|
||||
"prefer": 8,
|
||||
"normal": 3,
|
||||
"rare": -6,
|
||||
"never": -50,
|
||||
}.get(priority, 0)
|
||||
|
||||
|
||||
def is_animal_protein_item(item: dict) -> bool:
|
||||
normalized = (item.get("name") or "").strip().lower()
|
||||
return any(
|
||||
token in normalized
|
||||
for token in ("huhn", "hähn", "rind", "schwein", "speck", "salami", "wurst", "thunfisch", "lachs", "fisch", "garnelen", "shrimp", "sardinen")
|
||||
)
|
||||
|
||||
|
||||
def protein_preference_score(item: dict, settings: dict) -> int:
|
||||
preference = normalize_protein_preference(settings.get("protein_preference"), "mixed")
|
||||
if not is_animal_protein_item(item):
|
||||
return 2 if preference in {"veg-friendly", "rare-animal", "plant-forward"} else 0
|
||||
if preference == "mixed":
|
||||
return 0
|
||||
if preference == "veg-friendly":
|
||||
return -4
|
||||
if preference == "rare-animal":
|
||||
return -8
|
||||
if preference == "plant-forward":
|
||||
return -14
|
||||
return 0
|
||||
|
||||
|
||||
def meaningful_component(item: dict) -> bool:
|
||||
role = normalize_food_role(item.get("suggestion_role"), "base")
|
||||
if role in {"topping", "cooking"}:
|
||||
return False
|
||||
return bool(item.get("can_be_meal_core")) or role in {"base", "main", "solo", "snack", "complement"}
|
||||
|
||||
|
||||
def food_supports_slot(food: dict, slot: dict) -> bool:
|
||||
if normalize_suggestion_priority(food.get("suggestion_priority"), "normal") == "never":
|
||||
return False
|
||||
if slot.get("core_only") and not bool(food.get("can_be_meal_core")):
|
||||
return False
|
||||
|
||||
role = normalize_food_role(food.get("suggestion_role"), "base")
|
||||
if slot.get("roles") and role not in slot["roles"]:
|
||||
return False
|
||||
|
||||
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"}:
|
||||
return True
|
||||
return base_type in accepted
|
||||
|
||||
|
||||
def score_suggestion_components(component_items: list[dict], daypart_slug: str, settings: dict) -> int:
|
||||
builder_keys = {key for item in component_items for key in item.get("builder_keys", ["neutral"])}
|
||||
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]
|
||||
score = 0
|
||||
score = sum(suggestion_priority_score(normalize_suggestion_priority(item.get("suggestion_priority"), "normal")) for item in component_items)
|
||||
score += sum(protein_preference_score(item, settings) for item in component_items)
|
||||
score += sum(2 for item in component_items if bool(item.get("can_be_meal_core")))
|
||||
|
||||
style = settings.get("suggestion_style", "balanced")
|
||||
if style == "fitness":
|
||||
@@ -600,6 +735,12 @@ def score_suggestion_components(component_items: list[dict], daypart_slug: str,
|
||||
elif style == "protein":
|
||||
score += 8 if "protein" in builder_keys else 0
|
||||
score += 3 if daypart_slug in {"lunch", "dinner"} and "veg" in builder_keys else 0
|
||||
elif style == "easy":
|
||||
score += 5 if any(normalize_food_role(item.get("suggestion_role"), "base") in {"solo", "base"} for item in component_items) else 0
|
||||
score += 4 if len(component_items) <= 3 else -2
|
||||
elif style == "snack":
|
||||
score += 5 if daypart_slug in {"morning-snack", "afternoon-snack", "late-snack"} else 0
|
||||
score += 3 if any(item.get("base_type") in {"fruit", "dairy", "nuts", "seeds"} for item in component_items) else 0
|
||||
else:
|
||||
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}:
|
||||
score += 5 if "carb" in builder_keys else 0
|
||||
@@ -607,7 +748,7 @@ def score_suggestion_components(component_items: list[dict], daypart_slug: str,
|
||||
else:
|
||||
score += 5 if "protein" in builder_keys else 0
|
||||
score += 4 if "carb" in builder_keys else 0
|
||||
score += 4 if "veg" in builder_keys else 0
|
||||
score += 4 if builder_keys & {"veg", "fruit"} else 0
|
||||
|
||||
energy_preference = settings.get("energy_preference", "neutral")
|
||||
if style == "fitness":
|
||||
@@ -709,20 +850,44 @@ def group_items_by_availability(items: list[dict]) -> list[dict]:
|
||||
|
||||
def extract_item_form_data(existing: dict | None = None) -> dict:
|
||||
form_data = existing or {}
|
||||
daypart_ids = [int(value) for value in request.form.getlist("daypart_ids") if value.isdigit()]
|
||||
meal_type_default = form_data.get("meal_type") or meal_type_for_daypart(daypart_ids[0] if daypart_ids else None)
|
||||
form_data.update(
|
||||
{
|
||||
"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")),
|
||||
"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"),
|
||||
form_data.get("suggestion_priority", "normal"),
|
||||
),
|
||||
"can_be_meal_core": request.form.get("can_be_meal_core", "0") == "1",
|
||||
"meal_type": normalize_meal_type(request.form.get("meal_type"), meal_type_default),
|
||||
"meal_tags": normalize_meal_tags(request.form.getlist("meal_tags")),
|
||||
"energy_density": normalize_energy_density(request.form.get("energy_density"), form_data.get("energy_density", "neutral")),
|
||||
"note": request.form.get("note", "").strip(),
|
||||
"visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")),
|
||||
"target_user_id": normalize_target_user_id(request.form.get("target_user_id")),
|
||||
"target_user_raw": request.form.get("target_user_id", TARGET_USER_OPTIONS_DEFAULT),
|
||||
"food_search": request.form.get("food_search", "").strip(),
|
||||
"daypart_ids": [int(value) for value in request.form.getlist("daypart_ids") if value.isdigit()],
|
||||
"daypart_ids": daypart_ids,
|
||||
"component_ids": [int(value) for value in request.form.getlist("component_ids") if value.isdigit()],
|
||||
"quick_food_name": request.form.get("quick_food_name", "").strip(),
|
||||
"quick_food_category": request.form.get("quick_food_category", "").strip(),
|
||||
"quick_food_base_type": normalize_base_type(
|
||||
request.form.get("quick_food_base_type"),
|
||||
form_data.get("quick_food_base_type", "neutral"),
|
||||
),
|
||||
"quick_food_role": normalize_food_role(
|
||||
request.form.get("quick_food_role"),
|
||||
form_data.get("quick_food_role", "base"),
|
||||
),
|
||||
"quick_food_priority": normalize_suggestion_priority(
|
||||
request.form.get("quick_food_priority"),
|
||||
form_data.get("quick_food_priority", "normal"),
|
||||
),
|
||||
"quick_food_can_be_meal_core": request.form.get("quick_food_can_be_meal_core", "0") == "1",
|
||||
"quick_food_energy_density": normalize_energy_density(request.form.get("quick_food_energy_density"), form_data.get("quick_food_energy_density", "neutral")),
|
||||
"quick_food_note": request.form.get("quick_food_note", "").strip(),
|
||||
}
|
||||
@@ -734,9 +899,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, energy_density, note, created_by, updated_by
|
||||
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
|
||||
)
|
||||
VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
current_household_id(),
|
||||
@@ -745,6 +910,10 @@ def create_quick_food_from_form(form_data: dict) -> int:
|
||||
form_data["visibility"],
|
||||
form_data["quick_food_name"],
|
||||
form_data["quick_food_category"],
|
||||
form_data["quick_food_base_type"],
|
||||
form_data["quick_food_role"],
|
||||
form_data["quick_food_priority"],
|
||||
1 if form_data["quick_food_can_be_meal_core"] else 0,
|
||||
form_data["quick_food_energy_density"],
|
||||
form_data["quick_food_note"],
|
||||
g.user["id"],
|
||||
@@ -1269,6 +1438,41 @@ def get_daypart_by_id(daypart_id: int):
|
||||
return None
|
||||
|
||||
|
||||
def meal_type_for_daypart(daypart_id: int | None) -> str:
|
||||
daypart = get_daypart_by_id(daypart_id) if daypart_id else None
|
||||
if not daypart:
|
||||
return "snack"
|
||||
return DAYPART_SLUG_TO_MEAL_TYPE.get(daypart["slug"], "snack")
|
||||
|
||||
|
||||
def meal_tags_for_generated_meal(daypart_id: int, foods: list[dict]) -> list[str]:
|
||||
daypart = get_daypart_by_id(daypart_id)
|
||||
slug = daypart["slug"] if daypart else ""
|
||||
tags: list[str] = []
|
||||
|
||||
if slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}:
|
||||
if any(item.get("base_type") in {"fruit", "dairy"} for item in foods):
|
||||
tags.append("sweet")
|
||||
if any(item.get("base_type") == "carb" for item in foods):
|
||||
tags.append("simple")
|
||||
else:
|
||||
if any(item.get("base_type") in {"protein", "veg"} for item in foods):
|
||||
tags.append("savory")
|
||||
if any(item.get("suggestion_role") == "solo" for item in foods):
|
||||
tags.append("quick")
|
||||
|
||||
if any(item.get("base_type") in {"dairy", "fruit"} for item in foods):
|
||||
tags.append("cold")
|
||||
if any(item.get("base_type") in {"protein", "veg"} for item in foods) and slug in {"lunch", "dinner"}:
|
||||
tags.append("warm")
|
||||
|
||||
normalized: list[str] = []
|
||||
for tag in tags:
|
||||
if tag not in normalized:
|
||||
normalized.append(tag)
|
||||
return normalized
|
||||
|
||||
|
||||
def format_item_names(items: list[dict], limit: int = 3) -> str:
|
||||
return ", ".join(item["name"] for item in items[:limit])
|
||||
|
||||
@@ -1312,56 +1516,110 @@ def build_generated_meal_name(combo: list[dict], daypart_slug: str) -> str:
|
||||
return names[0]
|
||||
|
||||
|
||||
def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, limit: int) -> list[dict]:
|
||||
if daypart_slug in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"}:
|
||||
target_patterns = [
|
||||
def meal_pattern_definitions(daypart_slug: str) -> list[dict]:
|
||||
if daypart_slug == "breakfast":
|
||||
return [
|
||||
{
|
||||
"slots": ({"carb"}, {"dairy", "protein"}, {"fruit", "nuts", "seeds"}),
|
||||
"reason": "Passt gut zu Frühstück oder Snack",
|
||||
"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},
|
||||
],
|
||||
},
|
||||
{
|
||||
"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},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
if daypart_slug in {"morning-snack", "afternoon-snack", "late-snack"}:
|
||||
return [
|
||||
{
|
||||
"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},
|
||||
],
|
||||
},
|
||||
{
|
||||
"slots": ({"carb"}, {"dairy", "protein"}),
|
||||
"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},
|
||||
],
|
||||
},
|
||||
{
|
||||
"slots": ({"dairy", "protein"}, {"fruit", "nuts", "seeds"}),
|
||||
"reason": "Lässt sich gut als kleiner Snack vormerken",
|
||||
},
|
||||
]
|
||||
else:
|
||||
target_patterns = [
|
||||
{
|
||||
"slots": ({"protein"}, {"carb"}, {"veg"}),
|
||||
"reason": "Zuhause als vollständige Mahlzeit möglich",
|
||||
},
|
||||
{
|
||||
"slots": ({"protein"}, {"carb"}),
|
||||
"reason": "Lässt sich leicht ergänzen",
|
||||
},
|
||||
{
|
||||
"slots": ({"protein"}, {"veg"}),
|
||||
"reason": "Zuhause schon gut kombinierbar",
|
||||
},
|
||||
{
|
||||
"slots": ({"carb"}, {"veg"}),
|
||||
"reason": "Daraus kann schnell etwas Einfaches werden",
|
||||
"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},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
return [
|
||||
{
|
||||
"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},
|
||||
],
|
||||
},
|
||||
{
|
||||
"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},
|
||||
],
|
||||
},
|
||||
{
|
||||
"reason": "Schnell und alltagstauglich",
|
||||
"slots": [
|
||||
{"base_types": {"carb", "protein"}, "roles": {"solo"}, "core_only": True},
|
||||
{"base_types": {"fiber"}, "roles": {"complement", "base", "main"}, "core_only": False},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def score_food_for_pattern(food: dict, settings: dict) -> int:
|
||||
score = suggestion_priority_score(normalize_suggestion_priority(food.get("suggestion_priority"), "normal"))
|
||||
score += protein_preference_score(food, settings)
|
||||
if bool(food.get("can_be_meal_core")):
|
||||
score += 3
|
||||
role = normalize_food_role(food.get("suggestion_role"), "base")
|
||||
if role == "main":
|
||||
score += 3
|
||||
elif role == "base":
|
||||
score += 2
|
||||
elif role == "solo":
|
||||
score += 4
|
||||
return score
|
||||
|
||||
|
||||
def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, limit: int) -> list[dict]:
|
||||
settings = get_user_settings()
|
||||
target_patterns = meal_pattern_definitions(daypart_slug)
|
||||
|
||||
suggestions: list[dict] = []
|
||||
seen_signatures: set[tuple[int, ...]] = set()
|
||||
|
||||
def slot_matches(food: dict, slot_keys: set[str]) -> bool:
|
||||
return bool(slot_keys & set(food.get("builder_keys", ["neutral"])))
|
||||
|
||||
for pattern in target_patterns:
|
||||
slot_candidates = []
|
||||
for slot_keys in pattern["slots"]:
|
||||
matches = [food for food in home_foods if slot_matches(food, slot_keys)]
|
||||
for slot in pattern["slots"]:
|
||||
matches = [food for food in home_foods if food_supports_slot(food, slot)]
|
||||
matches = sorted(matches, key=lambda food: (-score_food_for_pattern(food, settings), food["name"].lower()))
|
||||
if not matches:
|
||||
slot_candidates = []
|
||||
break
|
||||
slot_candidates.append(matches)
|
||||
slot_candidates.append(matches[:6])
|
||||
if not slot_candidates:
|
||||
continue
|
||||
|
||||
@@ -1384,6 +1642,7 @@ def build_dynamic_meal_suggestions(home_foods: list[dict], daypart_slug: str, li
|
||||
"needs_shopping": False,
|
||||
"is_generated": True,
|
||||
"suggestion_key": generated_suggestion_key([item["id"] for item in combo_items]),
|
||||
"score": score_suggestion_components(combo_items, daypart_slug=daypart_slug, settings=settings),
|
||||
}
|
||||
)
|
||||
if len(suggestions) >= limit * 3:
|
||||
@@ -1511,14 +1770,25 @@ def build_balance_suggestion(daypart_id: int, item_ids: list[int]) -> dict | Non
|
||||
present = set()
|
||||
for keys in builder_map.values():
|
||||
present.update(keys)
|
||||
target_order = ["protein", "carb", "veg"]
|
||||
missing = [key for key in target_order if key not in present]
|
||||
has_fiber = bool(present & {"veg", "fruit"})
|
||||
missing = []
|
||||
if "protein" not in present:
|
||||
missing.append("protein")
|
||||
if "carb" not in present:
|
||||
missing.append("carb")
|
||||
if not has_fiber:
|
||||
missing.append("fiber")
|
||||
if not missing:
|
||||
return None
|
||||
first_missing = missing[0]
|
||||
home_matches = [
|
||||
item for item in fetch_items(kind="food", availability="home", daypart_id=daypart_id)
|
||||
if first_missing in item.get("builder_keys", [])
|
||||
if (
|
||||
(first_missing == "fiber" and bool(set(item.get("builder_keys", [])) & {"veg", "fruit"}))
|
||||
or first_missing in item.get("builder_keys", [])
|
||||
)
|
||||
and meaningful_component(item)
|
||||
and normalize_suggestion_priority(item.get("suggestion_priority"), "normal") != "never"
|
||||
]
|
||||
home_matches = sorted(
|
||||
home_matches,
|
||||
@@ -1527,7 +1797,7 @@ def build_balance_suggestion(daypart_id: int, item_ids: list[int]) -> dict | Non
|
||||
text_map = {
|
||||
"protein": "Dazu könnte noch eine Proteinquelle gut passen.",
|
||||
"carb": "Das lässt sich gut mit einer Kohlenhydratquelle ergänzen.",
|
||||
"veg": "Dazu könnte noch etwas Gemüse gut passen.",
|
||||
"fiber": "Dazu könnte noch etwas Gemüse oder Obst gut passen.",
|
||||
}
|
||||
return {
|
||||
"text": text_map.get(first_missing, "Dazu könnte noch etwas Kleines gut passen."),
|
||||
@@ -1598,7 +1868,7 @@ def build_dashboard_hints(today: date) -> list[str]:
|
||||
visible_params(),
|
||||
).fetchone()
|
||||
if int(dinner_home["count"]) > 0:
|
||||
hints.append("Zuhause ist bereits etwas da, das gut zu Abendessen passt.")
|
||||
hints.append("Für heute Abend ist zuhause schon etwas Passendes da.")
|
||||
|
||||
if settings.get("remind_before_shopping") and (today + timedelta(days=1)).weekday() == household_settings["shopping_weekday"]:
|
||||
upcoming = fetch_upcoming_shopping_needs(limit=3)
|
||||
@@ -1608,7 +1878,7 @@ def build_dashboard_hints(today: date) -> list[str]:
|
||||
if settings.get("remind_nuts"):
|
||||
nut_items = [item for item in fetch_items(kind="food", availability="home") if {"nuts", "seeds"} & set(item.get("builder_keys", []))]
|
||||
if nut_items:
|
||||
hints.append("Heute schon an Nüsse gedacht?")
|
||||
hints.append("Vielleicht passt heute noch etwas mit Nüssen oder Saaten dazu.")
|
||||
|
||||
if settings.get("suggest_templates"):
|
||||
old_template = get_db().execute(
|
||||
@@ -2580,6 +2850,12 @@ def render_item_form(kind: str, *, item: dict | None, form_data: dict):
|
||||
form_data.get("category") or form_data.get("quick_food_category")
|
||||
),
|
||||
form_data=form_data,
|
||||
builder_options=[(key, label) for key, label in BUILDER_LABELS.items()],
|
||||
food_role_options=FOOD_ROLE_OPTIONS,
|
||||
food_role_descriptions=FOOD_ROLE_DESCRIPTIONS,
|
||||
suggestion_priority_options=SUGGESTION_PRIORITY_OPTIONS,
|
||||
meal_type_options=MEAL_TYPE_OPTIONS,
|
||||
meal_style_options=MEAL_STYLE_OPTIONS,
|
||||
energy_density_options=ENERGY_DENSITY_OPTIONS,
|
||||
visibility_options=VISIBILITY_FORM_OPTIONS,
|
||||
target_user_options=get_target_user_options(),
|
||||
@@ -2634,27 +2910,52 @@ def create_or_get_generated_meal(
|
||||
get_db().execute(
|
||||
"""
|
||||
UPDATE items
|
||||
SET updated_by = ?, updated_at = CURRENT_TIMESTAMP
|
||||
SET meal_type = COALESCE(meal_type, ?),
|
||||
meal_tags = CASE
|
||||
WHEN COALESCE(meal_tags, '') = '' THEN ?
|
||||
ELSE meal_tags
|
||||
END,
|
||||
updated_by = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(g.user["id"], meal_id),
|
||||
(
|
||||
meal_type_for_daypart(daypart_id),
|
||||
encode_tag_list(meal_tags_for_generated_meal(daypart_id, fetch_items_by_ids(list(normalized_ids)))),
|
||||
g.user["id"],
|
||||
meal_id,
|
||||
),
|
||||
)
|
||||
get_db().commit()
|
||||
return meal_id
|
||||
|
||||
component_foods = fetch_items_by_ids(list(normalized_ids))
|
||||
cursor = get_db().execute(
|
||||
"""
|
||||
INSERT INTO items (
|
||||
household_id, owner_user_id, visibility, kind, name, category, created_by, updated_by
|
||||
household_id,
|
||||
owner_user_id,
|
||||
visibility,
|
||||
kind,
|
||||
name,
|
||||
category,
|
||||
meal_type,
|
||||
meal_tags,
|
||||
energy_density,
|
||||
created_by,
|
||||
updated_by
|
||||
)
|
||||
VALUES (?, ?, ?, 'meal', ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, 'meal', ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
current_household_id(),
|
||||
g.user["id"],
|
||||
visibility,
|
||||
name,
|
||||
"Kleines Essen" if get_daypart_by_id(daypart_id)["slug"] in {"breakfast", "morning-snack", "afternoon-snack", "late-snack"} else "Warmes",
|
||||
None,
|
||||
meal_type_for_daypart(daypart_id),
|
||||
encode_tag_list(meal_tags_for_generated_meal(daypart_id, component_foods)),
|
||||
"neutral",
|
||||
g.user["id"],
|
||||
g.user["id"],
|
||||
),
|
||||
@@ -3124,6 +3425,7 @@ def settings_view():
|
||||
elif form_name == "reminders":
|
||||
ensure_user_settings_row()
|
||||
suggestion_style = normalize_suggestion_style(request.form.get("suggestion_style"), "balanced")
|
||||
protein_preference = normalize_protein_preference(request.form.get("protein_preference"), "mixed")
|
||||
get_db().execute(
|
||||
"""
|
||||
UPDATE user_settings
|
||||
@@ -3132,6 +3434,7 @@ def settings_view():
|
||||
notification_channel = ?,
|
||||
suggestion_style = ?,
|
||||
energy_preference = ?,
|
||||
protein_preference = ?,
|
||||
remind_before_shopping = ?,
|
||||
remind_on_shopping_day = ?,
|
||||
show_missing_for_upcoming_week = ?,
|
||||
@@ -3157,6 +3460,7 @@ def settings_view():
|
||||
normalize_notification_channel(request.form.get("notification_channel"), "in_app"),
|
||||
suggestion_style,
|
||||
suggestion_style_energy_preference(suggestion_style),
|
||||
protein_preference,
|
||||
parse_checkbox("remind_before_shopping", True),
|
||||
parse_checkbox("remind_on_shopping_day", True),
|
||||
parse_checkbox("show_missing_for_upcoming_week", True),
|
||||
@@ -3379,6 +3683,12 @@ def item_create(kind: str):
|
||||
form_data = {
|
||||
"name": request.args.get("name", "").strip(),
|
||||
"category": "",
|
||||
"base_type": "neutral",
|
||||
"suggestion_role": "base",
|
||||
"suggestion_priority": "normal",
|
||||
"can_be_meal_core": False,
|
||||
"meal_type": normalize_meal_type(request.args.get("meal_type"), "snack"),
|
||||
"meal_tags": [],
|
||||
"energy_density": "neutral",
|
||||
"note": "",
|
||||
"visibility": "shared",
|
||||
@@ -3389,6 +3699,10 @@ def item_create(kind: str):
|
||||
"component_ids": [int(value) for value in request.args.getlist("component_ids") if value.isdigit()],
|
||||
"quick_food_name": "",
|
||||
"quick_food_category": "",
|
||||
"quick_food_base_type": "neutral",
|
||||
"quick_food_role": "base",
|
||||
"quick_food_priority": "normal",
|
||||
"quick_food_can_be_meal_core": False,
|
||||
"quick_food_energy_density": "neutral",
|
||||
"quick_food_note": "",
|
||||
}
|
||||
@@ -3428,9 +3742,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, energy_density, note, photo_filename, created_by, updated_by
|
||||
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
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
current_household_id(),
|
||||
@@ -3439,7 +3753,13 @@ def item_create(kind: str):
|
||||
form_data["visibility"],
|
||||
kind,
|
||||
form_data["name"],
|
||||
form_data["category"],
|
||||
form_data["category"] if kind == "food" else None,
|
||||
form_data["base_type"] 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,
|
||||
form_data["meal_type"] if kind == "meal" else None,
|
||||
encode_tag_list(form_data["meal_tags"]) if kind == "meal" else "",
|
||||
form_data["energy_density"],
|
||||
form_data["note"],
|
||||
photo_filename,
|
||||
@@ -3472,6 +3792,12 @@ def item_edit(item_id: int):
|
||||
form_data = {
|
||||
"name": item["name"],
|
||||
"category": item["category"] or "",
|
||||
"base_type": item.get("base_type") 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")),
|
||||
"meal_type": item.get("meal_type") or meal_type_for_daypart(item.get("primary_daypart_id")),
|
||||
"meal_tags": decode_tag_list(item.get("meal_tags")),
|
||||
"energy_density": item.get("energy_density") or "neutral",
|
||||
"note": item["note"] or "",
|
||||
"visibility": item["visibility"],
|
||||
@@ -3482,6 +3808,10 @@ def item_edit(item_id: int):
|
||||
"component_ids": get_meal_component_ids(item_id) if item["kind"] == "meal" else [],
|
||||
"quick_food_name": "",
|
||||
"quick_food_category": "",
|
||||
"quick_food_base_type": "neutral",
|
||||
"quick_food_role": "base",
|
||||
"quick_food_priority": "normal",
|
||||
"quick_food_can_be_meal_core": False,
|
||||
"quick_food_energy_density": "neutral",
|
||||
"quick_food_note": "",
|
||||
}
|
||||
@@ -3523,6 +3853,12 @@ def item_edit(item_id: int):
|
||||
UPDATE items
|
||||
SET name = ?,
|
||||
category = ?,
|
||||
base_type = ?,
|
||||
suggestion_role = ?,
|
||||
suggestion_priority = ?,
|
||||
can_be_meal_core = ?,
|
||||
meal_type = ?,
|
||||
meal_tags = ?,
|
||||
energy_density = ?,
|
||||
note = ?,
|
||||
visibility = ?,
|
||||
@@ -3534,7 +3870,13 @@ def item_edit(item_id: int):
|
||||
""",
|
||||
(
|
||||
form_data["name"],
|
||||
form_data["category"],
|
||||
form_data["category"] if item["kind"] == "food" else None,
|
||||
form_data["base_type"] 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,
|
||||
form_data["meal_type"] if item["kind"] == "meal" else None,
|
||||
encode_tag_list(form_data["meal_tags"]) if item["kind"] == "meal" else "",
|
||||
form_data["energy_density"],
|
||||
form_data["note"],
|
||||
form_data["visibility"],
|
||||
|
||||
Reference in New Issue
Block a user