Restructure food suggestion data and builder logic

This commit is contained in:
2026-04-13 17:55:11 +02:00
parent 6c7c1f01c9
commit 305440a6b2
5 changed files with 820 additions and 67 deletions
+284 -1
View File
@@ -8,10 +8,272 @@ from flask import Flask, current_app, g
from flask.cli import with_appcontext
from werkzeug.security import generate_password_hash
from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS
from .constants import (
DAYPARTS,
DAYPART_SLUG_TO_MEAL_TYPE,
DEFAULT_CATEGORIES,
DEFAULT_CATEGORY_BUILDERS,
)
CURRENT_SCHEMA_VERSION = "1.2.1"
ANIMAL_HINTS = (
"huhn",
"hähn",
"rind",
"hack",
"schwein",
"speck",
"salami",
"wurst",
"thunfisch",
"lachs",
"fisch",
"garnelen",
"shrimp",
"sardinen",
)
def normalize_name_for_profile(name: str | None) -> str:
return (name or "").strip().lower()
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()
base_type = "neutral"
if "eiweiß" in normalized_category or "protein" in normalized_category:
base_type = "protein"
elif "kohlenhyd" in normalized_category or "brot" in normalized_category or "getreide" in normalized_category:
base_type = "carb"
elif "milch" in normalized_category:
base_type = "dairy"
elif "obst" in normalized_category:
base_type = "fruit"
elif "gemüse" in normalized_category:
base_type = "veg"
elif "nüsse" in normalized_name or "nuss" in normalized_name:
base_type = "nuts"
elif "saat" in normalized_name or "leinsamen" in normalized_name or "chia" in normalized_name:
base_type = "seeds"
suggestion_role = "base"
suggestion_priority = "normal"
can_be_meal_core = 0
if any(token in normalized_name for token in ("proteinpulver", "eiweißpulver", "whey", "clear whey")):
return {
"base_type": "protein",
"suggestion_role": "complement",
"suggestion_priority": "rare",
"can_be_meal_core": 0,
}
if any(token in normalized_name for token in ("flohsamen", "flohsamenschalen", "leinsamen", "chia", "hanfsamen")):
return {
"base_type": "seeds",
"suggestion_role": "topping",
"suggestion_priority": "normal",
"can_be_meal_core": 0,
}
if "tomatenmark" in normalized_name:
return {
"base_type": "neutral",
"suggestion_role": "cooking",
"suggestion_priority": "rare",
"can_be_meal_core": 0,
}
if any(token in normalized_name for token in ("saure gurken", "essiggurken", "cornichons", "gurkenscheiben")):
return {
"base_type": "veg",
"suggestion_role": "complement",
"suggestion_priority": "rare",
"can_be_meal_core": 0,
}
if any(token in normalized_name for token in ("tofu", "tempeh", "vegane schnitzel", "vegane nuggets", "veganes hack", "sojageschnetzeltes")):
return {
"base_type": "protein",
"suggestion_role": "main",
"suggestion_priority": "prefer",
"can_be_meal_core": 1,
}
if any(token in normalized_name for token in ("thunfisch", "lachs", "fisch", "huhn", "hähn", "rind", "schwein", "hack")):
return {
"base_type": "protein",
"suggestion_role": "main",
"suggestion_priority": "rare",
"can_be_meal_core": 1,
}
if any(token in normalized_name for token in ("joghurt", "skyr", "quark", "hüttenkäse", "körniger frischkäse")):
return {
"base_type": "dairy",
"suggestion_role": "base",
"suggestion_priority": "prefer",
"can_be_meal_core": 1,
}
if any(token in normalized_name for token in ("müsli", "hafer", "porridge", "cornflakes", "brot", "brötchen", "reis", "nudel", "kartoffel", "wrap")):
return {
"base_type": "carb",
"suggestion_role": "base",
"suggestion_priority": "normal",
"can_be_meal_core": 1,
}
if any(token in normalized_name for token in ("beeren", "banane", "apfel", "obst", "birne", "trauben", "kiwi")):
return {
"base_type": "fruit",
"suggestion_role": "complement",
"suggestion_priority": "prefer",
"can_be_meal_core": 0,
}
if any(token in normalized_name for token in ("gemüse", "brokkoli", "spinat", "erbsen", "paprika", "karotte", "zucchini", "salat", "tomate", "tk gemüse")):
return {
"base_type": "veg",
"suggestion_role": "complement",
"suggestion_priority": "prefer",
"can_be_meal_core": 0,
}
if any(token in normalized_name for token in ("nussmus", "erdnuss", "mandeln", "walnüsse", "cashew")):
return {
"base_type": "nuts",
"suggestion_role": "topping",
"suggestion_priority": "normal",
"can_be_meal_core": 0,
}
if any(token in normalized_name for token in ("terrine", "5-minuten", "instant", "cup noodles")):
return {
"base_type": "carb" if (energy_density or "neutral") != "high" else "neutral",
"suggestion_role": "solo",
"suggestion_priority": "rare",
"can_be_meal_core": 1,
}
if base_type in {"protein", "carb", "dairy"}:
suggestion_role = "base"
can_be_meal_core = 1
elif base_type in {"veg", "fruit"}:
suggestion_role = "complement"
elif base_type in {"nuts", "seeds"}:
suggestion_role = "topping"
return {
"base_type": base_type,
"suggestion_role": suggestion_role,
"suggestion_priority": suggestion_priority,
"can_be_meal_core": can_be_meal_core,
}
def infer_meal_tags(name: str | None, legacy_category: str | None) -> str:
normalized_name = normalize_name_for_profile(name)
normalized_category = (legacy_category or "").strip().lower()
tags: list[str] = []
if normalized_category == "warmes":
tags.extend(["warm", "savory"])
if normalized_category == "kleines essen":
tags.extend(["simple", "quick"])
if normalized_category == "snack":
tags.append("simple")
if any(token in normalized_name for token in ("porridge", "müsli", "joghurt", "quark")):
tags.append("sweet")
if any(token in normalized_name for token in ("salat", "brot", "toast", "tofu", "reis", "nudel", "pfanne")):
tags.append("savory")
if any(token in normalized_name for token in ("to go", "unterwegs", "wrap")):
tags.append("portable")
if any(token in normalized_name for token in ("overnight", "vorbereitet", "meal prep")):
tags.append("prep")
if any(token in normalized_name for token in ("schnell", "5-minuten", "instant")):
tags.append("quick")
if any(token in normalized_name for token in ("einfach", "ruhig")):
tags.append("simple")
unique_tags: list[str] = []
for tag in tags:
if tag and tag not in unique_tags:
unique_tags.append(tag)
return ",".join(unique_tags)
def infer_meal_type_from_dayparts(database: sqlite3.Connection, item_id: int) -> str:
row = database.execute(
"""
SELECT dayparts.slug
FROM item_dayparts
JOIN dayparts ON dayparts.id = item_dayparts.daypart_id
WHERE item_dayparts.item_id = ?
ORDER BY dayparts.sort_order
LIMIT 1
""",
(item_id,),
).fetchone()
if row is None:
return "snack"
return DAYPART_SLUG_TO_MEAL_TYPE.get(row["slug"], "snack")
def migrate_item_profiles(database: sqlite3.Connection) -> None:
rows = database.execute(
"""
SELECT id, kind, name, category, energy_density
FROM items
ORDER BY id
"""
).fetchall()
for row in rows:
item_id = int(row["id"])
if row["kind"] == "food":
profile = infer_food_profile(row["name"], row["category"], row["energy_density"])
database.execute(
"""
UPDATE items
SET base_type = ?,
suggestion_role = ?,
suggestion_priority = ?,
can_be_meal_core = ?
WHERE id = ?
""",
(
profile["base_type"],
profile["suggestion_role"],
profile["suggestion_priority"],
profile["can_be_meal_core"],
item_id,
),
)
continue
meal_type = infer_meal_type_from_dayparts(database, item_id)
meal_tags = infer_meal_tags(row["name"], row["category"])
database.execute(
"""
UPDATE items
SET meal_type = COALESCE(NULLIF(meal_type, ''), ?),
meal_tags = CASE
WHEN meal_tags IS NULL OR meal_tags = '' THEN ?
ELSE meal_tags
END,
category = CASE
WHEN kind = 'meal' AND category IN ('Kohlenhydrate', 'Milchprodukt', 'Obst', 'Gemüse', 'Eiweißquelle', 'Snack', 'Warmes', 'Kleines Essen')
THEN NULL
ELSE category
END
WHERE id = ?
""",
(meal_type, meal_tags, item_id),
)
def get_db() -> sqlite3.Connection:
if "db" not in g:
@@ -129,6 +391,12 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
add_column_if_missing(database, "items", "owner_user_id INTEGER")
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", "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")
add_column_if_missing(database, "items", "meal_type TEXT")
add_column_if_missing(database, "items", "meal_tags TEXT NOT NULL DEFAULT ''")
add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
if table_exists(database, "shopping_entries"):
@@ -254,6 +522,7 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
if table_exists(database, "user_settings"):
add_column_if_missing(database, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'")
add_column_if_missing(database, "user_settings", "energy_preference TEXT NOT NULL DEFAULT 'neutral'")
add_column_if_missing(database, "user_settings", "protein_preference TEXT NOT NULL DEFAULT 'mixed'")
add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0")
add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0")
add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0")
@@ -375,11 +644,18 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
add_column_if_missing(database, table_name, "owner_user_id INTEGER")
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", "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")
add_column_if_missing(database, "items", "meal_type TEXT")
add_column_if_missing(database, "items", "meal_tags TEXT NOT NULL DEFAULT ''")
add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT")
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER")
add_column_if_missing(database, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'")
add_column_if_missing(database, "user_settings", "energy_preference TEXT NOT NULL DEFAULT 'neutral'")
add_column_if_missing(database, "user_settings", "protein_preference TEXT NOT NULL DEFAULT 'mixed'")
add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0")
add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0")
add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0")
@@ -422,6 +698,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
database.execute("UPDATE plan_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''")
sync_default_categories(database)
migrate_item_profiles(database)
database.execute(
"""
INSERT OR IGNORE INTO user_settings (user_id)
@@ -429,8 +706,14 @@ 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 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")
database.execute("UPDATE items SET meal_tags = '' WHERE meal_tags IS NULL")
database.execute("UPDATE user_settings SET suggestion_style = 'balanced' WHERE suggestion_style IS NULL OR suggestion_style = ''")
database.execute("UPDATE user_settings SET energy_preference = 'neutral' WHERE energy_preference IS NULL OR energy_preference = ''")
database.execute("UPDATE user_settings SET protein_preference = 'mixed' WHERE protein_preference IS NULL OR protein_preference = ''")
database.execute("UPDATE user_settings SET push_missing_breakfast = 0 WHERE push_missing_breakfast IS NULL")
database.execute("UPDATE user_settings SET push_missing_lunch = 0 WHERE push_missing_lunch IS NULL")
database.execute("UPDATE user_settings SET push_missing_dinner = 0 WHERE push_missing_dinner IS NULL")