Katalog, lernendes Mapping ohne KI, optionale Items und Import-Policy. Playwright-Smoke und Issue-Audit um Ernährung/Zuordnen/API ergänzt. Co-authored-by: Cursor <cursoragent@cursor.com>
438 lines
15 KiB
Python
438 lines
15 KiB
Python
"""Nutrition diary items, three macro sums, import policy, attribute resolve."""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import date, datetime
|
|
from typing import Any
|
|
|
|
from data_layer.food_mapping import (
|
|
get_food_mapping_with_cursor,
|
|
normalize_food_name,
|
|
parse_quantity_g,
|
|
)
|
|
|
|
MACRO_ATTR_KEYS = {
|
|
"kcal": "ENERCC",
|
|
"protein_g": "PROT625",
|
|
"fat_g": "FAT",
|
|
"carbs_g": "CHO",
|
|
}
|
|
|
|
POLICIES = frozenset({"prompt", "overwrite_catalog", "overwrite_fddb", "keep_existing"})
|
|
|
|
|
|
def _f(v: Any) -> float:
|
|
if v is None or v == "":
|
|
return 0.0
|
|
try:
|
|
return float(v)
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
|
|
|
|
def _round_macros(d: dict[str, float]) -> dict[str, float]:
|
|
return {
|
|
"kcal": round(_f(d.get("kcal")), 1),
|
|
"protein_g": round(_f(d.get("protein_g")), 1),
|
|
"fat_g": round(_f(d.get("fat_g")), 1),
|
|
"carbs_g": round(_f(d.get("carbs_g")), 1),
|
|
}
|
|
|
|
|
|
def macros_differ(a: dict[str, float], b: dict[str, float]) -> bool:
|
|
aa, bb = _round_macros(a), _round_macros(b)
|
|
return any(aa[k] != bb[k] for k in aa)
|
|
|
|
|
|
def get_import_policy(cur, profile_id: str) -> str:
|
|
cur.execute(
|
|
"SELECT nutrition_import_conflict_policy FROM profiles WHERE id = %s",
|
|
(profile_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return "prompt"
|
|
pol = row.get("nutrition_import_conflict_policy") or "prompt"
|
|
return pol if pol in POLICIES else "prompt"
|
|
|
|
|
|
def catalog_macros_for_item(cur, food_id: str | None, quantity_g: float | None) -> dict[str, float] | None:
|
|
if not food_id or quantity_g is None or quantity_g <= 0:
|
|
return None
|
|
cur.execute(
|
|
"""
|
|
SELECT a.attr_key, v.value_num, v.is_trace
|
|
FROM food_attribute_values v
|
|
JOIN food_attributes a ON a.id = v.attribute_id
|
|
WHERE v.food_id = %s AND a.attr_key = ANY(%s) AND a.data_type = 'num_per_100g'
|
|
""",
|
|
(food_id, list(MACRO_ATTR_KEYS.values())),
|
|
)
|
|
by_key = {r["attr_key"]: r for r in cur.fetchall()}
|
|
if not by_key:
|
|
return None
|
|
out = {}
|
|
factor = float(quantity_g) / 100.0
|
|
missing = False
|
|
for field, key in MACRO_ATTR_KEYS.items():
|
|
row = by_key.get(key)
|
|
if not row or row.get("is_trace") or row.get("value_num") is None:
|
|
missing = True
|
|
break
|
|
out[field] = float(row["value_num"]) * factor
|
|
return None if missing else out
|
|
|
|
|
|
def compute_day_macro_sums(cur, profile_id: str, day: date | str) -> dict[str, Any]:
|
|
cur.execute(
|
|
"""
|
|
SELECT kcal, protein_g, fat_g, carbs_g, macro_origin, has_items
|
|
FROM nutrition_log WHERE profile_id = %s AND date = %s
|
|
""",
|
|
(profile_id, day),
|
|
)
|
|
existing = cur.fetchone()
|
|
existing_macros = (
|
|
_round_macros(existing)
|
|
if existing
|
|
else None
|
|
)
|
|
cur.execute(
|
|
"""
|
|
SELECT food_id, quantity_g, fddb_kcal, fddb_protein_g, fddb_fat_g, fddb_carbs_g, value_origin
|
|
FROM nutrition_items
|
|
WHERE profile_id = %s AND date = %s
|
|
""",
|
|
(profile_id, day),
|
|
)
|
|
items = cur.fetchall()
|
|
fddb = {"kcal": 0.0, "protein_g": 0.0, "fat_g": 0.0, "carbs_g": 0.0}
|
|
catalog = {"kcal": 0.0, "protein_g": 0.0, "fat_g": 0.0, "carbs_g": 0.0}
|
|
mapped = unmapped = 0
|
|
used_bls = used_fddb = False
|
|
for it in items:
|
|
fddb["kcal"] += _f(it.get("fddb_kcal"))
|
|
fddb["protein_g"] += _f(it.get("fddb_protein_g"))
|
|
fddb["fat_g"] += _f(it.get("fddb_fat_g"))
|
|
fddb["carbs_g"] += _f(it.get("fddb_carbs_g"))
|
|
cat = catalog_macros_for_item(cur, it.get("food_id"), it.get("quantity_g"))
|
|
if cat:
|
|
mapped += 1
|
|
used_bls = True
|
|
for k in catalog:
|
|
catalog[k] += cat[k]
|
|
else:
|
|
unmapped += 1
|
|
used_fddb = True
|
|
catalog["kcal"] += _f(it.get("fddb_kcal"))
|
|
catalog["protein_g"] += _f(it.get("fddb_protein_g"))
|
|
catalog["fat_g"] += _f(it.get("fddb_fat_g"))
|
|
catalog["carbs_g"] += _f(it.get("fddb_carbs_g"))
|
|
origin = "mixed"
|
|
if used_bls and not used_fddb:
|
|
origin = "bls"
|
|
elif used_fddb and not used_bls:
|
|
origin = "fddb"
|
|
if not items:
|
|
origin = "manual"
|
|
return {
|
|
"existing": existing_macros,
|
|
"fddb": _round_macros(fddb) if items else None,
|
|
"catalog": _round_macros(catalog) if items else None,
|
|
"mapped_item_count": mapped,
|
|
"unmapped_item_count": unmapped,
|
|
"has_items": bool(items),
|
|
"catalog_origin": origin,
|
|
"has_log": existing is not None,
|
|
"macro_origin": existing["macro_origin"] if existing else None,
|
|
}
|
|
|
|
|
|
def apply_nutrition_day_macros(
|
|
cur,
|
|
profile_id: str,
|
|
day: date | str,
|
|
macros: dict[str, float],
|
|
*,
|
|
macro_origin: str,
|
|
source: str = "csv",
|
|
confirm: bool = False,
|
|
) -> str:
|
|
m = _round_macros(macros)
|
|
cur.execute("SELECT id FROM nutrition_log WHERE profile_id = %s AND date = %s", (profile_id, day))
|
|
row = cur.fetchone()
|
|
counts = compute_day_macro_sums(cur, profile_id, day)
|
|
extra = (
|
|
counts["mapped_item_count"],
|
|
counts["unmapped_item_count"],
|
|
counts["has_items"],
|
|
)
|
|
confirmed = datetime.utcnow() if confirm else None
|
|
if row:
|
|
cur.execute(
|
|
"""
|
|
UPDATE nutrition_log
|
|
SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s, source=%s,
|
|
macro_origin=%s, mapped_item_count=%s, unmapped_item_count=%s,
|
|
has_items=%s, last_import_at=NOW(), macros_confirmed_at=COALESCE(%s, macros_confirmed_at)
|
|
WHERE profile_id=%s AND date=%s
|
|
""",
|
|
(
|
|
m["kcal"], m["protein_g"], m["fat_g"], m["carbs_g"], source,
|
|
macro_origin, extra[0], extra[1], extra[2], confirmed, profile_id, day,
|
|
),
|
|
)
|
|
return "updated"
|
|
eid = str(uuid.uuid4())
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO nutrition_log (
|
|
id, profile_id, date, kcal, protein_g, fat_g, carbs_g, source,
|
|
macro_origin, mapped_item_count, unmapped_item_count, has_items,
|
|
last_import_at, macros_confirmed_at, created
|
|
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW(),%s,CURRENT_TIMESTAMP)
|
|
""",
|
|
(
|
|
eid, profile_id, day, m["kcal"], m["protein_g"], m["fat_g"], m["carbs_g"], source,
|
|
macro_origin, extra[0], extra[1], extra[2], confirmed,
|
|
),
|
|
)
|
|
return "created"
|
|
|
|
|
|
def rebuild_daily_nutrients(cur, profile_id: str, day: date | str) -> None:
|
|
cur.execute(
|
|
"DELETE FROM nutrition_daily_nutrients WHERE profile_id = %s AND date = %s",
|
|
(profile_id, day),
|
|
)
|
|
cur.execute(
|
|
"""
|
|
SELECT i.food_id, i.quantity_g
|
|
FROM nutrition_items i
|
|
WHERE i.profile_id = %s AND i.date = %s
|
|
AND i.food_id IS NOT NULL AND i.quantity_g IS NOT NULL AND i.quantity_g > 0
|
|
""",
|
|
(profile_id, day),
|
|
)
|
|
acc: dict[int, list[float]] = {}
|
|
for it in cur.fetchall():
|
|
cur.execute(
|
|
"""
|
|
SELECT v.attribute_id, v.value_num, v.is_trace, a.data_type
|
|
FROM food_attribute_values v
|
|
JOIN food_attributes a ON a.id = v.attribute_id
|
|
WHERE v.food_id = %s AND a.data_type = 'num_per_100g'
|
|
AND v.value_num IS NOT NULL AND v.is_trace = false
|
|
""",
|
|
(it["food_id"],),
|
|
)
|
|
factor = float(it["quantity_g"]) / 100.0
|
|
for row in cur.fetchall():
|
|
acc.setdefault(row["attribute_id"], []).append(float(row["value_num"]) * factor)
|
|
for attr_id, vals in acc.items():
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO nutrition_daily_nutrients
|
|
(profile_id, date, attribute_id, value, contributing_item_count, updated_at)
|
|
VALUES (%s, %s, %s, %s, %s, NOW())
|
|
""",
|
|
(profile_id, day, attr_id, round(sum(vals), 6), len(vals)),
|
|
)
|
|
|
|
|
|
def _item_value_origin(mapping: dict | None) -> str:
|
|
if not mapping:
|
|
return "fddb"
|
|
kind = mapping.get("catalog_kind")
|
|
if kind == "official_bls":
|
|
return "bls"
|
|
if kind in ("manual_admin", "manual_user"):
|
|
return "manual_catalog"
|
|
return "fddb"
|
|
|
|
|
|
def replace_csv_items_for_dates(
|
|
cur,
|
|
profile_id: str,
|
|
rows: list[dict[str, Any]],
|
|
*,
|
|
policy: str,
|
|
policy_override: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Replace csv-sourced items for the dates present in rows.
|
|
Returns conflicts when policy is prompt and existing macros differ.
|
|
"""
|
|
effective = policy_override if policy_override in POLICIES else policy
|
|
by_date: dict[str, list[dict]] = {}
|
|
for row in rows:
|
|
d = row.get("date")
|
|
if hasattr(d, "isoformat"):
|
|
iso = d.isoformat()
|
|
else:
|
|
iso = str(d)[:10]
|
|
if not iso:
|
|
continue
|
|
by_date.setdefault(iso, []).append(row)
|
|
|
|
conflicts = []
|
|
days_written = 0
|
|
items_written = 0
|
|
new_log_days = 0
|
|
|
|
for iso, day_rows in by_date.items():
|
|
cur.execute(
|
|
"""
|
|
DELETE FROM nutrition_items
|
|
WHERE profile_id = %s AND date = %s AND source = 'csv'
|
|
""",
|
|
(profile_id, iso),
|
|
)
|
|
for raw in day_rows:
|
|
name = (raw.get("food_name") or raw.get("source_name_raw") or "").strip()
|
|
if not name:
|
|
continue
|
|
qty_raw = raw.get("quantity_raw")
|
|
qty_g = parse_quantity_g(qty_raw if qty_raw is not None else name)
|
|
mapping = get_food_mapping_with_cursor(cur, name, profile_id)
|
|
logged_at = raw.get("logged_at")
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO nutrition_items (
|
|
id, profile_id, date, logged_at, source_name_raw, source_name_normalized,
|
|
source_system, quantity_raw, quantity_g,
|
|
fddb_kcal, fddb_protein_g, fddb_fat_g, fddb_carbs_g,
|
|
food_id, mapping_id, value_origin, source
|
|
) VALUES (
|
|
%s,%s,%s,%s,%s,%s,'fddb',%s,%s,%s,%s,%s,%s,%s,%s,%s,'csv'
|
|
)
|
|
""",
|
|
(
|
|
str(uuid.uuid4()),
|
|
profile_id,
|
|
iso,
|
|
logged_at,
|
|
name,
|
|
normalize_food_name(name),
|
|
str(qty_raw) if qty_raw is not None else None,
|
|
qty_g,
|
|
_f(raw.get("fddb_kcal") if raw.get("fddb_kcal") is not None else raw.get("kcal")),
|
|
_f(raw.get("fddb_protein_g") if raw.get("fddb_protein_g") is not None else raw.get("protein_g")),
|
|
_f(raw.get("fddb_fat_g") if raw.get("fddb_fat_g") is not None else raw.get("fat_g")),
|
|
_f(raw.get("fddb_carbs_g") if raw.get("fddb_carbs_g") is not None else raw.get("carbs_g")),
|
|
mapping["food_id"] if mapping else None,
|
|
mapping["mapping_id"] if mapping else None,
|
|
_item_value_origin(mapping),
|
|
),
|
|
)
|
|
items_written += 1
|
|
days_written += 1
|
|
sums = compute_day_macro_sums(cur, profile_id, iso)
|
|
rebuild_daily_nutrients(cur, profile_id, iso)
|
|
if not sums["has_items"]:
|
|
continue
|
|
if not sums["has_log"]:
|
|
apply_nutrition_day_macros(
|
|
cur, profile_id, iso, sums["catalog"],
|
|
macro_origin=sums["catalog_origin"], source="csv",
|
|
)
|
|
new_log_days += 1
|
|
continue
|
|
existing = sums["existing"]
|
|
catalog = sums["catalog"]
|
|
fddb = sums["fddb"]
|
|
differ = macros_differ(existing, catalog) or macros_differ(existing, fddb)
|
|
if effective == "keep_existing":
|
|
_touch_item_counts(cur, profile_id, iso, sums)
|
|
continue
|
|
if effective == "overwrite_catalog":
|
|
apply_nutrition_day_macros(
|
|
cur, profile_id, iso, catalog,
|
|
macro_origin=sums["catalog_origin"], source="csv",
|
|
)
|
|
continue
|
|
if effective == "overwrite_fddb":
|
|
apply_nutrition_day_macros(
|
|
cur, profile_id, iso, fddb, macro_origin="fddb", source="csv",
|
|
)
|
|
continue
|
|
# prompt
|
|
if differ:
|
|
conflicts.append({
|
|
"date": iso,
|
|
"existing": existing,
|
|
"fddb": fddb,
|
|
"catalog": catalog,
|
|
"catalog_origin": sums["catalog_origin"],
|
|
"mapped_item_count": sums["mapped_item_count"],
|
|
"unmapped_item_count": sums["unmapped_item_count"],
|
|
})
|
|
else:
|
|
apply_nutrition_day_macros(
|
|
cur, profile_id, iso, catalog,
|
|
macro_origin=sums["catalog_origin"], source="csv",
|
|
)
|
|
|
|
return {
|
|
"days_written": days_written,
|
|
"items_written": items_written,
|
|
"new_log_days": new_log_days,
|
|
"conflicts": conflicts,
|
|
"policy": effective,
|
|
}
|
|
|
|
|
|
def _touch_item_counts(cur, profile_id: str, day: str, sums: dict) -> None:
|
|
cur.execute(
|
|
"""
|
|
UPDATE nutrition_log
|
|
SET mapped_item_count=%s, unmapped_item_count=%s, has_items=%s, last_import_at=NOW()
|
|
WHERE profile_id=%s AND date=%s
|
|
""",
|
|
(
|
|
sums["mapped_item_count"],
|
|
sums["unmapped_item_count"],
|
|
sums["has_items"],
|
|
profile_id,
|
|
day,
|
|
),
|
|
)
|
|
|
|
|
|
def resolve_choice_macros(sums: dict[str, Any], choice: str) -> tuple[dict[str, float], str]:
|
|
if choice == "existing":
|
|
return sums["existing"], "user_confirmed"
|
|
if choice == "fddb":
|
|
return sums["fddb"], "fddb"
|
|
if choice == "catalog":
|
|
return sums["catalog"], sums.get("catalog_origin") or "mixed"
|
|
raise ValueError("Ungültige Wahl (existing|fddb|catalog)")
|
|
|
|
|
|
def resolve_food_attributes(cur, food_id: str) -> list[dict]:
|
|
cur.execute(
|
|
"""
|
|
SELECT a.attr_key, a.name_de, a.unit, a.category, a.data_type, a.origin AS attr_origin,
|
|
v.value_num, v.value_bool, v.value_text, v.is_trace, v.origin_code
|
|
FROM food_attributes a
|
|
LEFT JOIN food_attribute_values v
|
|
ON v.attribute_id = a.id AND v.food_id = %s
|
|
WHERE a.is_active = true
|
|
ORDER BY a.sort_order, a.attr_key
|
|
""",
|
|
(food_id,),
|
|
)
|
|
return [dict(r) for r in cur.fetchall()]
|
|
|
|
|
|
def dates_for_normalized_name(cur, profile_id: str, source_name_normalized: str) -> list[str]:
|
|
cur.execute(
|
|
"""
|
|
SELECT DISTINCT date::text AS date
|
|
FROM nutrition_items
|
|
WHERE profile_id = %s AND source_name_normalized = %s
|
|
""",
|
|
(profile_id, source_name_normalized),
|
|
)
|
|
return [r["date"] for r in cur.fetchall()]
|