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>
129 lines
4.0 KiB
Python
129 lines
4.0 KiB
Python
"""Catalog attribute definitions and numeric values (BLS EAV + extensions)."""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
MACRO_TO_KEY = {
|
|
"kcal": "ENERCC",
|
|
"protein_g": "PROT625",
|
|
"fat_g": "FAT",
|
|
"carbs_g": "CHO",
|
|
}
|
|
|
|
|
|
def scale_to_per_100g(value: float, serving_g: float | None) -> float:
|
|
if not serving_g or serving_g <= 0 or serving_g == 100:
|
|
return float(value)
|
|
return float(value) * (100.0 / float(serving_g))
|
|
|
|
|
|
def list_numeric_attributes(cur, query: str = "", limit: int = 40) -> list[dict[str, Any]]:
|
|
q = (query or "").strip()
|
|
lim = min(max(int(limit or 40), 1), 80)
|
|
if q:
|
|
like = f"%{q}%"
|
|
cur.execute(
|
|
"""
|
|
SELECT id, attr_key, name_de, name_en, unit, category, origin
|
|
FROM food_attributes
|
|
WHERE is_active = true AND data_type = 'num_per_100g'
|
|
AND (
|
|
attr_key ILIKE %s OR name_de ILIKE %s
|
|
OR COALESCE(name_en, '') ILIKE %s
|
|
)
|
|
ORDER BY
|
|
CASE WHEN attr_key ILIKE %s THEN 0
|
|
WHEN name_de ILIKE %s THEN 1
|
|
ELSE 2 END,
|
|
sort_order, attr_key
|
|
LIMIT %s
|
|
""",
|
|
(like, like, like, q, f"{q}%", lim),
|
|
)
|
|
else:
|
|
cur.execute(
|
|
"""
|
|
SELECT id, attr_key, name_de, name_en, unit, category, origin
|
|
FROM food_attributes
|
|
WHERE is_active = true AND data_type = 'num_per_100g'
|
|
ORDER BY sort_order, attr_key
|
|
LIMIT %s
|
|
""",
|
|
(lim,),
|
|
)
|
|
return [dict(r) for r in cur.fetchall()]
|
|
|
|
|
|
def write_numeric_attributes(cur, food_id: str, values: dict[str, Any] | None) -> int:
|
|
if not values:
|
|
return 0
|
|
written = 0
|
|
for raw_key, raw_val in values.items():
|
|
key = str(raw_key or "").strip()
|
|
if not key or raw_val is None or raw_val == "":
|
|
continue
|
|
try:
|
|
num = float(raw_val)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
cur.execute(
|
|
"""
|
|
SELECT id FROM food_attributes
|
|
WHERE attr_key = %s AND is_active = true AND data_type = 'num_per_100g'
|
|
""",
|
|
(key,),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
continue
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO food_attribute_values (food_id, attribute_id, value_num, is_trace)
|
|
VALUES (%s, %s, %s, false)
|
|
ON CONFLICT (food_id, attribute_id)
|
|
DO UPDATE SET value_num = EXCLUDED.value_num, updated_at = NOW()
|
|
""",
|
|
(food_id, row["id"], num),
|
|
)
|
|
written += 1
|
|
return written
|
|
|
|
|
|
def macros_and_attributes_to_values(
|
|
macros: dict[str, Any] | None,
|
|
attributes: dict[str, Any] | None,
|
|
serving_g: float | None = None,
|
|
) -> dict[str, float]:
|
|
out: dict[str, float] = {}
|
|
for field, key in MACRO_TO_KEY.items():
|
|
if not macros or field not in macros or macros[field] is None or macros[field] == "":
|
|
continue
|
|
try:
|
|
out[key] = scale_to_per_100g(float(macros[field]), serving_g)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
for key, raw in (attributes or {}).items():
|
|
if raw is None or raw == "":
|
|
continue
|
|
try:
|
|
out[str(key)] = scale_to_per_100g(float(raw), serving_g)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
return out
|
|
|
|
|
|
def extra_attributes_for_food(cur, food_id: str) -> dict[str, float]:
|
|
cur.execute(
|
|
"""
|
|
SELECT a.attr_key, v.value_num
|
|
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
|
|
AND NOT (a.attr_key = ANY(%s))
|
|
ORDER BY a.sort_order, a.attr_key
|
|
""",
|
|
(food_id, list(MACRO_TO_KEY.values())),
|
|
)
|
|
return {r["attr_key"]: float(r["value_num"]) for r in cur.fetchall()}
|