Restructure food suggestion data and builder logic
This commit is contained in:
+284
-1
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user