mitai-jinkendo/backend/data_layer/food_knowledge.py
Lars a7e5511039
All checks were successful
Deploy Development / deploy (push) Successful in 1m1s
Build Test / pytest-backend (push) Successful in 8s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 29s
feat: Rezepte beim Zuordnen anlegen und offene Zutaten listen
Lebensmittel oder Rezept wählbar; gemappte Zutaten werden übernommen, offene erscheinen in der Liste. Dazu Fettgehalt-Suche, EPA-Stoffe, Rezept-CRUD und Wechsel bestehender Zuordnungen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 16:59:24 +02:00

264 lines
9.7 KiB
Python

"""Portable JSON for user food mappings, manual foods, and FDDB lists."""
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any
from uuid import UUID
from data_layer.food_mapping import (
apply_mapping_to_items,
apply_quantities_to_items,
normalize_food_name,
upsert_food_mapping,
)
from data_layer.food_recipes import list_recipes, upsert_recipes
from data_layer.nutrition_items import catalog_macros_for_item, dates_for_normalized_name, rebuild_daily_nutrients
BUNDLE_FORMAT = "mitai-food-knowledge"
BUNDLE_VERSION = 1
def _jsonable(value: Any) -> Any:
if value is None:
return None
if isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, Decimal):
return float(value)
if isinstance(value, UUID):
return str(value)
if isinstance(value, dict):
return {str(k): _jsonable(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_jsonable(v) for v in value]
if hasattr(value, "isoformat"):
return value.isoformat()
return str(value)
def parse_food_knowledge_bundle(data: Any) -> dict[str, Any]:
if not isinstance(data, dict):
raise ValueError("Die Datei ist kein JSON-Objekt")
if data.get("format") != BUNDLE_FORMAT:
raise ValueError("Keine Mitai-Zuordnungsdatei — bitte die exportierte JSON verwenden")
try:
version = int(data.get("version") or 0)
except (TypeError, ValueError) as exc:
raise ValueError("Unbekannte Dateiversion") from exc
if version != BUNDLE_VERSION:
raise ValueError(f"Nicht unterstützte Dateiversion {version}")
return data
def portable_mapping(row: dict[str, Any]) -> dict[str, Any]:
return {
"source_system": row.get("source_system") or "fddb",
"source_name_raw": row.get("source_name_raw"),
"source_name_normalized": row.get("source_name_normalized"),
"bls_code": row.get("bls_code"),
"food_name_de": row.get("food_name_de") or row.get("name_de"),
"catalog_kind": row.get("catalog_kind"),
"external_key": row.get("external_key"),
"grams_per_unit": _jsonable(row.get("grams_per_unit")),
"source_unit": row.get("source_unit"),
}
def resolve_catalog_food(cur, profile_id: str, ref: dict[str, Any]) -> str | None:
bls = (ref.get("bls_code") or "").strip()
if bls:
cur.execute(
"SELECT id FROM food_catalog WHERE bls_code = %s AND is_active = true",
(bls,),
)
row = cur.fetchone()
return str(row["id"]) if row else None
kind = ref.get("catalog_kind") or "manual_user"
name = (ref.get("food_name_de") or ref.get("name_de") or "").strip()
key = (ref.get("external_key") or "").strip()
if kind == "manual_user":
if key:
cur.execute(
"""
SELECT id FROM food_catalog
WHERE owner_profile_id = %s AND external_key = %s AND is_active = true
LIMIT 1
""",
(profile_id, key),
)
row = cur.fetchone()
if row:
return str(row["id"])
if name:
cur.execute(
"""
SELECT id FROM food_catalog
WHERE owner_profile_id = %s AND catalog_kind = 'manual_user'
AND lower(name_de) = lower(%s) AND is_active = true
LIMIT 1
""",
(profile_id, name),
)
row = cur.fetchone()
if row:
return str(row["id"])
return None
if kind == "manual_admin" and name:
cur.execute(
"""
SELECT id FROM food_catalog
WHERE catalog_kind = 'manual_admin' AND lower(name_de) = lower(%s)
AND is_active = true
LIMIT 1
""",
(name,),
)
row = cur.fetchone()
return str(row["id"]) if row else None
return None
def export_food_knowledge(cur, profile_id: str) -> dict[str, Any]:
cur.execute(
"""
SELECT id, name_de, name_en, catalog_kind, external_key
FROM food_catalog
WHERE owner_profile_id = %s AND catalog_kind = 'manual_user' AND is_active = true
ORDER BY lower(name_de)
""",
(profile_id,),
)
manuals = []
for food in cur.fetchall():
macros = catalog_macros_for_item(cur, food["id"], 100.0)
from data_layer.food_attributes import extra_attributes_for_food
manuals.append({
"name_de": food["name_de"],
"name_en": food.get("name_en"),
"catalog_kind": food["catalog_kind"],
"external_key": food.get("external_key"),
"macros_per_100g": macros,
"attributes": extra_attributes_for_food(cur, food["id"]),
})
cur.execute(
"""
SELECT m.source_system, m.source_name_raw, m.source_name_normalized,
m.grams_per_unit, m.source_unit,
f.bls_code, f.name_de AS food_name_de, f.catalog_kind, f.external_key
FROM food_name_mappings m
JOIN food_catalog f ON f.id = m.food_id
WHERE m.profile_id = %s
ORDER BY m.source_name_normalized
""",
(profile_id,),
)
mappings = [portable_mapping(dict(r)) for r in cur.fetchall()]
recipes = []
for rec in list_recipes(cur, profile_id):
recipes.append({
"name_raw": rec.get("name_raw"),
"name_normalized": rec.get("name_normalized"),
"portions": _jsonable(rec.get("portions")),
"description": rec.get("description"),
"source": rec.get("source") or "fddb_list",
"ingredients": [
{
"source_name_raw": ing.get("source_name_raw"),
"source_name_normalized": ing.get("source_name_normalized"),
"quantity_raw": ing.get("quantity_raw"),
"quantity_g": _jsonable(ing.get("quantity_g")),
"sort_order": ing.get("sort_order") or 0,
}
for ing in rec.get("ingredients") or []
],
})
return _jsonable({
"format": BUNDLE_FORMAT,
"version": BUNDLE_VERSION,
"exported_at": datetime.now(timezone.utc).isoformat(),
"manual_foods": manuals,
"mappings": mappings,
"recipes": recipes,
})
def _upsert_manual_food(cur, profile_id: str, food: dict[str, Any]) -> str | None:
name = (food.get("name_de") or "").strip()
if not name:
return None
existing = resolve_catalog_food(cur, profile_id, {**food, "food_name_de": name, "catalog_kind": "manual_user"})
from data_layer.food_attributes import macros_and_attributes_to_values, write_numeric_attributes
values = macros_and_attributes_to_values(food.get("macros_per_100g"), food.get("attributes"))
if existing:
write_numeric_attributes(cur, existing, values)
return existing
key = (food.get("external_key") or "").strip() or f"man-user-{normalize_food_name(name)[:40]}"
cur.execute(
"""
INSERT INTO food_catalog
(name_de, name_en, catalog_kind, owner_profile_id, source, external_key)
VALUES (%s, %s, 'manual_user', %s, 'import', %s)
RETURNING id
""",
(name, food.get("name_en"), profile_id, key),
)
food_id = str(cur.fetchone()["id"])
write_numeric_attributes(cur, food_id, values)
return food_id
def import_food_knowledge(cur, profile_id: str, data: dict[str, Any]) -> dict[str, Any]:
bundle = parse_food_knowledge_bundle(data)
foods_upserted = 0
for food in bundle.get("manual_foods") or []:
if _upsert_manual_food(cur, profile_id, food):
foods_upserted += 1
mappings_ok = mappings_skipped = items_updated = 0
skipped: list[str] = []
dates: set[str] = set()
for raw in bundle.get("mappings") or []:
name = (raw.get("source_name_raw") or "").strip()
if not name:
mappings_skipped += 1
continue
food_id = resolve_catalog_food(cur, profile_id, raw)
if not food_id:
mappings_skipped += 1
label = raw.get("bls_code") or raw.get("food_name_de") or name
skipped.append(str(label))
continue
mid = upsert_food_mapping(
cur,
source_name_raw=name,
food_id=food_id,
profile_id=profile_id,
source="import",
source_system=raw.get("source_system") or "fddb",
grams_per_unit=raw.get("grams_per_unit"),
source_unit=raw.get("source_unit"),
)
norm = normalize_food_name(name)
items_updated += apply_mapping_to_items(cur, profile_id, norm, food_id, mid)
apply_quantities_to_items(cur, profile_id, norm, raw.get("grams_per_unit"))
dates.update(dates_for_normalized_name(cur, profile_id, norm))
mappings_ok += 1
recipe_stats = {"inserted": 0, "updated": 0, "ingredients": 0, "items_linked": 0}
recipes = bundle.get("recipes") or []
if recipes:
recipe_stats = upsert_recipes(cur, profile_id, recipes)
dates.update(recipe_stats.pop("dates_linked", []) or [])
for day in dates:
rebuild_daily_nutrients(cur, profile_id, day)
from data_layer.food_suggest import invalidate_suggest_index
invalidate_suggest_index(profile_id)
return {
"ok": True,
"manual_foods": foods_upserted,
"mappings": mappings_ok,
"mappings_skipped": mappings_skipped,
"items_updated": items_updated,
"skipped_foods": skipped[:20],
**recipe_stats,
}