mitai-jinkendo/backend/data_layer/food_recipes.py
Lars fa542e0fb9
All checks were successful
Deploy Development / deploy (push) Successful in 1m7s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s
feat: Listen-Dialog mit Mengen/Einheiten, Mitai ohne Kochrezepte
Roh-Kombinationen statt Rezept-Sprache; Zutaten suchen, EL/TL/Prise auf Gramm. Gekochte Gerichte und Ausbeute bleiben Tandoor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 18:23:01 +02:00

324 lines
12 KiB
Python

"""FDDB recipe lists: upsert, link to diary items, catalog macros via ingredients."""
from __future__ import annotations
import uuid
from typing import Any
from data_layer.food_mapping import (
get_food_mapping_with_cursor,
normalize_food_name,
resolve_quantity,
)
from data_layer.nutrition_items import catalog_macros_for_item, _f
def _resolved_ingredient_qty(cur, profile_id: str, source_name_raw: str, ing: dict[str, Any]) -> dict[str, Any]:
mapping = get_food_mapping_with_cursor(cur, source_name_raw, profile_id)
gpu = ing.get("grams_per_unit")
if gpu in (None, ""):
gpu = mapping.get("grams_per_unit") if mapping else None
try:
gpu = float(gpu) if gpu not in (None, "") else None
except (TypeError, ValueError):
gpu = None
return resolve_quantity(
quantity_raw=ing.get("quantity_raw"),
quantity_amount=ing.get("quantity_amount"),
source_unit=ing.get("source_unit"),
quantity_g=ing.get("quantity_g"),
grams_per_unit=gpu,
)
def upsert_recipes(cur, profile_id: str, recipes: list[dict[str, Any]]) -> dict[str, int]:
inserted = updated = ingredients = 0
for rec in recipes:
norm = rec.get("name_normalized") or normalize_food_name(rec.get("name_raw"))
if not norm:
continue
cur.execute(
"SELECT id FROM food_recipes WHERE profile_id = %s AND name_normalized = %s",
(profile_id, norm),
)
row = cur.fetchone()
if row:
rid = row["id"]
cur.execute(
"""
UPDATE food_recipes
SET name_raw=%s, portions=%s, description=%s, updated_at=NOW()
WHERE id=%s
""",
(rec["name_raw"], rec.get("portions") or 1, rec.get("description"), rid),
)
cur.execute("DELETE FROM food_recipe_ingredients WHERE recipe_id = %s", (rid,))
updated += 1
else:
rid = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO food_recipes
(id, profile_id, name_raw, name_normalized, portions, description, source)
VALUES (%s,%s,%s,%s,%s,%s,'fddb_list')
""",
(rid, profile_id, rec["name_raw"], norm, rec.get("portions") or 1, rec.get("description")),
)
inserted += 1
for ing in rec.get("ingredients") or []:
inorm = ing.get("source_name_normalized") or normalize_food_name(ing.get("source_name_raw"))
if not inorm:
continue
raw_name = ing.get("source_name_raw") or inorm
qty = _resolved_ingredient_qty(cur, profile_id, raw_name, ing)
cur.execute(
"""
INSERT INTO food_recipe_ingredients
(id, recipe_id, source_name_raw, source_name_normalized,
quantity_raw, quantity_g, quantity_amount, source_unit, sort_order)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
""",
(
str(uuid.uuid4()), rid, raw_name, inorm,
qty["quantity_raw"], qty["quantity_g"], qty["quantity_amount"], qty["source_unit"],
ing.get("sort_order") or 0,
),
)
ingredients += 1
linked, dates = link_recipes_to_items(cur, profile_id)
return {
"inserted": inserted,
"updated": updated,
"ingredients": ingredients,
"items_linked": linked,
"dates_linked": dates,
}
def link_recipes_to_items(cur, profile_id: str) -> tuple[int, list[str]]:
cur.execute(
"""
SELECT DISTINCT i.date::text AS date
FROM nutrition_items i
JOIN food_recipes r ON r.profile_id = i.profile_id
AND i.source_name_normalized = r.name_normalized
WHERE i.profile_id = %s AND i.food_id IS NULL
""",
(profile_id,),
)
dates = [r["date"] for r in cur.fetchall()]
cur.execute(
"""
UPDATE nutrition_items i
SET recipe_id = r.id, updated_at = NOW()
FROM food_recipes r
WHERE i.profile_id = %s AND r.profile_id = %s
AND i.source_name_normalized = r.name_normalized
AND i.food_id IS NULL
""",
(profile_id, profile_id),
)
return cur.rowcount or 0, dates
def list_recipes(cur, profile_id: str) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT id, name_raw, name_normalized, portions, description, source
FROM food_recipes
WHERE profile_id = %s
ORDER BY name_normalized
""",
(profile_id,),
)
recipes = [dict(r) for r in cur.fetchall()]
if not recipes:
return []
ids = [str(r["id"]) for r in recipes]
cur.execute(
"""
SELECT recipe_id, source_name_raw, source_name_normalized,
quantity_raw, quantity_g, quantity_amount, source_unit, sort_order
FROM food_recipe_ingredients
WHERE recipe_id = ANY(%s::uuid[])
ORDER BY sort_order, source_name_raw
""",
(ids,),
)
by_r: dict[str, list] = {str(i): [] for i in ids}
for row in cur.fetchall():
by_r.setdefault(str(row["recipe_id"]), []).append(dict(row))
for rec in recipes:
rec["id"] = str(rec["id"])
rec["ingredients"] = by_r.get(rec["id"], [])
return recipes
def get_recipe(cur, profile_id: str, recipe_id: str) -> dict[str, Any] | None:
for rec in list_recipes(cur, profile_id):
if rec["id"] == str(recipe_id):
return rec
return None
def save_recipe(cur, profile_id: str, rec: dict[str, Any], recipe_id: str | None = None) -> dict[str, Any]:
name_raw = (rec.get("name_raw") or "").strip()
norm = rec.get("name_normalized") or normalize_food_name(name_raw)
if not name_raw or not norm:
raise ValueError("Rezeptname fehlt")
try:
portions = float(rec.get("portions") or 1)
except (TypeError, ValueError):
portions = 1.0
if portions <= 0:
portions = 1.0
description = rec.get("description")
source = (rec.get("source") or "manual").strip() or "manual"
if recipe_id:
cur.execute(
"SELECT id FROM food_recipes WHERE id = %s AND profile_id = %s",
(recipe_id, profile_id),
)
if not cur.fetchone():
raise KeyError("Rezept nicht gefunden")
cur.execute(
"""
SELECT id FROM food_recipes
WHERE profile_id = %s AND name_normalized = %s AND id <> %s
""",
(profile_id, norm, recipe_id),
)
if cur.fetchone():
raise ValueError("Ein Rezept mit diesem Namen existiert bereits")
cur.execute(
"""
UPDATE food_recipes
SET name_raw=%s, name_normalized=%s, portions=%s, description=%s, updated_at=NOW()
WHERE id=%s AND profile_id=%s
""",
(name_raw, norm, portions, description, recipe_id, profile_id),
)
cur.execute("DELETE FROM food_recipe_ingredients WHERE recipe_id = %s", (recipe_id,))
rid = recipe_id
else:
cur.execute(
"SELECT id FROM food_recipes WHERE profile_id = %s AND name_normalized = %s",
(profile_id, norm),
)
existing = cur.fetchone()
if existing:
return save_recipe(cur, profile_id, rec, str(existing["id"]))
rid = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO food_recipes
(id, profile_id, name_raw, name_normalized, portions, description, source)
VALUES (%s,%s,%s,%s,%s,%s,%s)
""",
(rid, profile_id, name_raw, norm, portions, description, source),
)
for i, ing in enumerate(rec.get("ingredients") or []):
raw = (ing.get("source_name_raw") or "").strip()
inorm = ing.get("source_name_normalized") or normalize_food_name(raw)
if not inorm:
continue
qty = _resolved_ingredient_qty(cur, profile_id, raw or inorm, ing)
cur.execute(
"""
INSERT INTO food_recipe_ingredients
(id, recipe_id, source_name_raw, source_name_normalized,
quantity_raw, quantity_g, quantity_amount, source_unit, sort_order)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
""",
(
str(uuid.uuid4()), rid, raw or inorm, inorm,
qty["quantity_raw"], qty["quantity_g"], qty["quantity_amount"], qty["source_unit"], i,
),
)
link_recipes_to_items(cur, profile_id)
saved = get_recipe(cur, profile_id, rid)
if not saved:
raise ValueError("Rezept konnte nicht gelesen werden")
return saved
def delete_recipe(cur, profile_id: str, recipe_id: str) -> None:
cur.execute(
"DELETE FROM food_recipes WHERE id = %s AND profile_id = %s RETURNING id",
(recipe_id, profile_id),
)
if not cur.fetchone():
raise KeyError("Rezept nicht gefunden")
def apply_recipe_to_items(cur, profile_id: str, source_name_normalized: str, recipe_id: str) -> int:
cur.execute(
"""
UPDATE nutrition_items
SET recipe_id = %s, food_id = NULL, mapping_id = NULL, value_origin = 'fddb', updated_at = NOW()
WHERE profile_id = %s AND source_name_normalized = %s
""",
(recipe_id, profile_id, source_name_normalized),
)
return cur.rowcount or 0
def mapped_ingredient_quantities(
cur, profile_id: str, recipe_id: str, eaten_qty_g: float | None
) -> list[dict[str, Any]] | None:
cur.execute(
"SELECT portions FROM food_recipes WHERE id = %s AND profile_id = %s",
(recipe_id, profile_id),
)
rec = cur.fetchone()
if not rec:
return None
cur.execute(
"""
SELECT source_name_raw, quantity_g, quantity_raw, quantity_amount, source_unit
FROM food_recipe_ingredients
WHERE recipe_id = %s
ORDER BY sort_order
""",
(recipe_id,),
)
ings = cur.fetchall()
if not ings:
return None
resolved_ings = []
for ing in ings:
mapping = get_food_mapping_with_cursor(cur, ing["source_name_raw"], profile_id)
if not mapping:
return None
qty = resolve_quantity(
quantity_raw=ing.get("quantity_raw"),
quantity_amount=ing.get("quantity_amount"),
source_unit=ing.get("source_unit"),
quantity_g=ing.get("quantity_g"),
grams_per_unit=mapping.get("grams_per_unit"),
)
grams = _f(qty.get("quantity_g"))
if grams <= 0:
return None
resolved_ings.append({"food_id": mapping["food_id"], "quantity_g": grams})
total_g = sum(i["quantity_g"] for i in resolved_ings)
if eaten_qty_g and eaten_qty_g > 0 and total_g > 0:
scale = float(eaten_qty_g) / total_g
else:
portions = float(rec.get("portions") or 1) or 1.0
scale = 1.0 / portions
return [{**i, "quantity_g": i["quantity_g"] * scale} for i in resolved_ings]
def catalog_macros_for_recipe(cur, profile_id: str, recipe_id: str, eaten_qty_g: float | None) -> dict[str, float] | None:
parts = mapped_ingredient_quantities(cur, profile_id, recipe_id, eaten_qty_g)
if not parts:
return None
acc = {"kcal": 0.0, "protein_g": 0.0, "fat_g": 0.0, "carbs_g": 0.0}
for part in parts:
cat = catalog_macros_for_item(cur, part["food_id"], part["quantity_g"])
if not cat:
return None
for k in acc:
acc[k] += cat[k]
return acc