diff --git a/.claude/docs/functional/BLS_FOOD_REFERENCE.md b/.claude/docs/functional/BLS_FOOD_REFERENCE.md index 0546193..9f71272 100644 --- a/.claude/docs/functional/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/functional/BLS_FOOD_REFERENCE.md @@ -37,6 +37,7 @@ Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer a - BLS-Code (`bls_code`, Stoff-`attr_key`) bleibt die stabile Identität bei Reimports. - BLS 4.0 (~7140 Lebensmittel) ist frei nutzbar (MRI / blsdb.de); Dateien nicht im Git. - Mapping ohne KI: Normalisierung + exakter Lookup (User vor Global) + Bestätigung neuer Namen. +- Öl/Supplement mit Etikett (EPA/DHA, Vitamine) ist **ein Katalogeintrag**, keine Zutatenliste. Listen sind nur Rohmischungen aus Lebensmitteln — eine Liste kann keine andere Liste enthalten. Quarkspeise = Liste, Omega-3-Öl = Lebensmittel darin. - Gelernte Zuordnungen bleiben dauerhaft, sind aber änder- und löschbar. - Ungemappte / Fertiggerichte: FDDB-Makros, keine erfundenen Mikros. - Fasten und „unvollständig“ sind explizite Marken, kein Auto-Schluss aus fehlendem Import. diff --git a/.claude/docs/technical/BLS_FOOD_REFERENCE.md b/.claude/docs/technical/BLS_FOOD_REFERENCE.md index 1481d8a..e235080 100644 --- a/.claude/docs/technical/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/technical/BLS_FOOD_REFERENCE.md @@ -28,7 +28,7 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy / ## Router -- `/api/bls/*` — Suche, Attribute-Suche, eigene Foods (`attributes` + `serving_g`), eigene Mappings +- `/api/bls/*` — Suche, Attribute-Suche, eigene Foods (`attributes` + `serving_g`), eigene Mappings; `POST /bls/attributes` legt Extension-Stoff an (Name + Einheit), `GET /bls/attributes?keys=EPA,DHA` - Migration **065** — Extension-Keys EPA/DHA/DPA/ALA/OMEGA3/OMEGA6 falls BLS sie nicht unter diesem Key hat - `/api/admin/bls/*` — Import, Katalog, Attribute - `/api/admin/food-mappings` — Admin-CRUD diff --git a/CLAUDE.md b/CLAUDE.md index cc76692..9c51420 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,7 +125,7 @@ frontend/src/ - **Zuordnen-Performance:** Katalog-Index im Prozess (5 Min.), Vorschläge nur für sichtbare Zeilen (`POST /bls/foods/suggest-batch`), Suche mit Abort; nach Bestätigen kein Reload der ganzen Ernährungseite. Unmapped ohne Kreuzjoin Liste×Tagebuch; Listenzutaten-Datum aus `recipe.updated_at` oder letzter `recipe_id`-Nutzung. Listen-API `?brief=true`; FDDB-Listenimport rechnet Tageswerte im Hintergrund. Migration **068**. - **Zuordnen-Zeitraum:** Standard letzte 4 Wochen (`since_days`); ältere ungemappte Namen (z. B. Getreide nach Glutenverzicht) bleiben unter „Alle“. - **Katalogsuche:** Fettgehalt mitsuchen (`Joghurt 10%` / `9,5`); Dezimal-Komma bleibt erhalten. -- **Manuelle Foods:** Stoffe über EAV (`GET /bls/attributes`, `attributes` + `serving_g` beim Anlegen). Supplemente wie Norsan: EPA/DHA aus Etikett, Portionsgramm → Speicherung /100 g. +- **Manuelle Foods:** Stoffe über EAV (`GET /bls/attributes`, `attributes` + `serving_g` beim Anlegen). Supplemente wie Norsan: EPA/DHA aus Etikett, Portionsgramm → Speicherung /100 g. Nutzer kann fehlende Stoffe anlegen (`POST /bls/attributes`). Öl/Supplement = ein Lebensmittel, keine Liste (Listen nicht verschachtelbar). - **Listen / Kombinationen (Mitai):** Tab **Listen** (anlegen, bearbeiten, FDDB-Import). Im Listendialog Zutaten direkt BLS-zuordnen (Status offen/zugeordnet); Suche zuerst Listen-Namen und eigene Foods, dann BLS. **Tandoor:** User-Einstellungen URL + Token, Verbindungstest (`/api/tandoor/*`). Gerichte bleiben in Tandoor; später Mapping + `cooked_yield_g`. Admin-Mappings: `PUT /admin/food-mappings/{id}`. - **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106 - **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75. diff --git a/backend/data_layer/food_attributes.py b/backend/data_layer/food_attributes.py index 980ce00..ad3969c 100644 --- a/backend/data_layer/food_attributes.py +++ b/backend/data_layer/food_attributes.py @@ -1,8 +1,12 @@ """Catalog attribute definitions and numeric values (BLS EAV + extensions).""" from __future__ import annotations +import re from typing import Any +_ATTR_KEY_RE = re.compile(r"[^A-Z0-9]+") +_ALLOWED_UNITS = {"g": "g", "mg": "mg", "µg": "µg", "ug": "µg", "mcg": "µg", "kcal": "kcal", "kj": "kJ"} + MACRO_TO_KEY = { "kcal": "ENERCC", "protein_g": "PROT625", @@ -17,9 +21,24 @@ def scale_to_per_100g(value: float, serving_g: float | None) -> float: 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() +def list_numeric_attributes( + cur, query: str = "", limit: int = 40, keys: list[str] | None = None +) -> list[dict[str, Any]]: lim = min(max(int(limit or 40), 1), 80) + wanted = [str(k).strip() for k in (keys or []) if str(k).strip()] + if wanted: + 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 = ANY(%s) + ORDER BY sort_order, attr_key + """, + (wanted,), + ) + return [dict(r) for r in cur.fetchall()] + q = (query or "").strip() if q: like = f"%{q}%" cur.execute( @@ -126,3 +145,43 @@ def extra_attributes_for_food(cur, food_id: str) -> dict[str, float]: (food_id, list(MACRO_TO_KEY.values())), ) return {r["attr_key"]: float(r["value_num"]) for r in cur.fetchall()} + + +def normalize_attr_key(raw: str | None) -> str: + key = _ATTR_KEY_RE.sub("_", (raw or "").strip().upper()).strip("_") + return key[:64] + + +def normalize_attr_unit(raw: str | None) -> str: + return _ALLOWED_UNITS.get((raw or "g").strip().lower(), "g") + + +def create_extension_attribute(cur, name_de: str, unit: str = "g", attr_key: str | None = None) -> dict[str, Any]: + name = (name_de or "").strip() + if not name: + raise ValueError("Name fehlt") + key = normalize_attr_key(attr_key or name) + if not key: + raise ValueError("attr_key fehlt") + unit_n = normalize_attr_unit(unit) + cur.execute( + """ + SELECT id, attr_key, name_de, name_en, unit, category, origin + FROM food_attributes + WHERE attr_key = %s AND is_active = true + """, + (key,), + ) + existing = cur.fetchone() + if existing: + return dict(existing) + cur.execute( + """ + INSERT INTO food_attributes + (attr_key, name_de, name_en, unit, category, data_type, origin, sort_order) + VALUES (%s, %s, %s, %s, 'extension', 'num_per_100g', 'extension', 9100) + RETURNING id, attr_key, name_de, name_en, unit, category, origin + """, + (key, name, None, unit_n), + ) + return dict(cur.fetchone()) diff --git a/backend/routers/bls.py b/backend/routers/bls.py index 8ea6384..fceb5f5 100644 --- a/backend/routers/bls.py +++ b/backend/routers/bls.py @@ -52,6 +52,12 @@ class SuggestBatchBody(BaseModel): limit: int = 3 +class UserAttributeCreate(BaseModel): + name_de: str + unit: str = "g" + attr_key: Optional[str] = None + + def _pid(session: dict, x_profile_id: Optional[str] = None) -> str: return x_profile_id or session["profile_id"] @@ -100,12 +106,26 @@ def search_foods( @router.get("/attributes") def list_food_attributes( q: str = "", + keys: str = "", limit: int = 40, session: dict = Depends(require_auth), ): from data_layer.food_attributes import list_numeric_attributes + key_list = [p.strip() for p in (keys or "").split(",") if p.strip()] with get_db() as conn: - return list_numeric_attributes(get_cursor(conn), q, limit=limit) + return list_numeric_attributes(get_cursor(conn), q, limit=limit, keys=key_list or None) + + +@router.post("/attributes") +def create_food_attribute(body: UserAttributeCreate, session: dict = Depends(require_auth)): + from data_layer.food_attributes import create_extension_attribute + try: + with get_db() as conn: + return create_extension_attribute( + get_cursor(conn), body.name_de, body.unit, body.attr_key + ) + except ValueError as e: + raise HTTPException(400, str(e)) from e @router.post("/foods/suggest-batch") diff --git a/backend/tests/test_food_attributes.py b/backend/tests/test_food_attributes.py index 7bb2883..53cdcfa 100644 --- a/backend/tests/test_food_attributes.py +++ b/backend/tests/test_food_attributes.py @@ -1,4 +1,10 @@ -from data_layer.food_attributes import macros_and_attributes_to_values, scale_to_per_100g +from data_layer.food_attributes import ( + create_extension_attribute, + macros_and_attributes_to_values, + normalize_attr_key, + normalize_attr_unit, + scale_to_per_100g, +) def test_scale_serving_to_per_100g(): @@ -7,6 +13,28 @@ def test_scale_serving_to_per_100g(): assert scale_to_per_100g(10, None) == 10 +def test_attr_key_and_unit_normalize(): + assert normalize_attr_key("Vitamin D3") == "VITAMIN_D3" + assert normalize_attr_unit("ug") == "µg" + assert normalize_attr_unit("mcg") == "µg" + + +def test_create_extension_returns_existing(): + class _Cur: + def __init__(self): + self.calls = 0 + + def execute(self, sql, params=None): + self.calls += 1 + self._row = {"id": 1, "attr_key": "VITAMIN_D3", "name_de": "Vitamin D3", "name_en": None, "unit": "µg", "category": "extension", "origin": "extension"} if self.calls == 1 else None + + def fetchone(self): + return self._row + + row = create_extension_attribute(_Cur(), "Vitamin D3", "µg") + assert row["attr_key"] == "VITAMIN_D3" + + def test_norsan_label_epa_converts(): """Etikett: 1100 mg EPA = 1.1 g in 8 g Portion → g/100 g.""" vals = macros_and_attributes_to_values( diff --git a/backend/version.py b/backend/version.py index 84f4f38..4bc3867 100644 --- a/backend/version.py +++ b/backend/version.py @@ -22,7 +22,7 @@ MODULE_VERSIONS = { "activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement "nutrition": "1.3.5", # Listen-Dialog: Mapping an der Zutat; Suche Listen/eigene vor BLS "tandoor": "1.0.1", # Router-Import ohne future-annotations (Backend startet) - "bls": "1.0.5", # GET /bls/units + "bls": "1.0.6", # Nutzer darf Extension-Stoffe anlegen; EPA-Chips; mg/µg am Etikett "photos": "1.0.0", "insights": "1.3.0", "prompts": "1.1.0", diff --git a/frontend/src/components/FoodNutrientFields.jsx b/frontend/src/components/FoodNutrientFields.jsx index e92a670..68ccffa 100644 --- a/frontend/src/components/FoodNutrientFields.jsx +++ b/frontend/src/components/FoodNutrientFields.jsx @@ -1,16 +1,35 @@ import { useEffect, useRef, useState } from 'react' import { api } from '../utils/api' +const MASS = { g: 1, mg: 0.001, 'µg': 0.000001, ug: 0.000001, mcg: 0.000001 } +const QUICK_KEYS = ['EPA', 'DHA', 'DPA', 'OMEGA3'] + function parseNum(raw) { const n = parseFloat(String(raw ?? '').replace(',', '.')) return Number.isFinite(n) ? n : 0 } +function attrUnit(attr) { + const u = (attr?.unit || 'g').trim() + if (u === 'ug' || u === 'mcg') return 'µg' + return u +} + +export function convertMass(value, fromUnit, toUnit) { + const from = MASS[(fromUnit || 'g').toLowerCase()] + const to = MASS[(toUnit || 'g').toLowerCase()] + if (!from || !to) return value + return value * (from / to) +} + export function foodCreatePayload(name, macros, extras, servingG) { const attributes = {} for (const row of extras || []) { if (!row?.attr?.attr_key || row.value === '' || row.value == null) continue - attributes[row.attr.attr_key] = parseNum(row.value) + const n = parseNum(row.value) + const target = attrUnit(row.attr) + const entered = row.inputUnit || target + attributes[row.attr.attr_key] = convertMass(n, entered, target) } const serving = parseNum(servingG) return { @@ -29,10 +48,20 @@ export function foodCreatePayload(name, macros, extras, servingG) { export default function FoodNutrientFields({ macros, setMacros, extras, setExtras, servingG, setServingG }) { const [q, setQ] = useState('') const [hits, setHits] = useState([]) + const [quick, setQuick] = useState([]) const [loading, setLoading] = useState(false) + const [newName, setNewName] = useState('') + const [newUnit, setNewUnit] = useState('g') + const [creating, setCreating] = useState(false) + const [createErr, setCreateErr] = useState(null) const timer = useRef(null) useEffect(() => () => clearTimeout(timer.current), []) + useEffect(() => { + api.listFoodAttributes('', 20, QUICK_KEYS.join(',')).then((rows) => { + setQuick(Array.isArray(rows) ? rows : []) + }).catch(() => {}) + }, []) const search = (term) => { clearTimeout(timer.current) @@ -55,7 +84,9 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra const add = (attr) => { setExtras((list) => ( - list.some((r) => r.attr.attr_key === attr.attr_key) ? list : [...list, { attr, value: '' }] + list.some((r) => r.attr.attr_key === attr.attr_key) + ? list + : [...list, { attr, value: '', inputUnit: attrUnit(attr) }] )) setQ('') setHits([]) @@ -65,10 +96,36 @@ export default function FoodNutrientFields({ macros, setMacros, extras, setExtra setExtras((list) => list.map((r) => (r.attr.attr_key === key ? { ...r, value } : r))) } + const setInputUnit = (key, unit) => { + setExtras((list) => list.map((r) => (r.attr.attr_key === key ? { ...r, inputUnit: unit } : r))) + } + + const createAttr = async () => { + const name = (newName || q).trim() + if (!name) { + setCreateErr('Name des Stoffs fehlt') + return + } + setCreating(true) + setCreateErr(null) + try { + const attr = await api.createFoodAttribute({ name_de: name, unit: newUnit }) + add(attr) + setNewName('') + } catch (e) { + setCreateErr(e.message) + } finally { + setCreating(false) + } + } + + const unusedQuick = quick.filter((a) => !extras.some((r) => r.attr.attr_key === a.attr_key)) + return (
- Zahlen in der Einheit des Stoffs. Bei Etikett „pro 8 ml / 8 g“ die Portionsgröße setzen — gespeichert wird immer pro 100 g. + Etikettwerte so eintragen, wie sie dastehen. Portionsgröße setzen (5 ml Öl ≈ 5 g). Gespeichert wird immer pro 100 g. + Ein Öl/Supplement ist ein Lebensmittel, keine Liste — Listen können keine Listen enthalten.
Weitere Stoffe (EPA, DHA, Ballaststoffe, …)
+Weitere Stoffe
+ {unusedQuick.length > 0 && ( +Stoff nicht gefunden? Neu anlegen (wird für alle Lebensmittel nutzbar).
+ setNewName(e.target.value)} + /> +{createErr}
} +