"""FDDB → food_catalog mapping: normalize, lookup (user then global), learn, apply.""" from __future__ import annotations import re import unicodedata from datetime import date, timedelta from typing import Any LEADING_QTY_RE = re.compile( r"^\s*\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l|stück|stk|st\.?|portion(?:en)?)\b[\s,.:\-–]*", re.IGNORECASE, ) MULTISPACE_RE = re.compile(r"\s+") DECIMAL_IN_NAME_RE = re.compile(r"(\d),(\d)") def strip_leading_quantity(raw: str | None) -> str: if not raw: return "" s = unicodedata.normalize("NFKC", str(raw)).strip().strip('"').strip("'") s = s.lstrip("!") s = LEADING_QTY_RE.sub("", s) return MULTISPACE_RE.sub(" ", s).strip() def normalize_food_name(raw: str | None) -> str: s = strip_leading_quantity(raw) if not s: return "" s = DECIMAL_IN_NAME_RE.sub(r"\1.\2", s) return s.lower() def merge_unmapped_rows(rows: list[dict]) -> list[dict]: merged: dict[str, dict] = {} for row in rows: raw = row.get("source_name_raw") or "" key = normalize_food_name(raw) or row.get("source_name_normalized") or raw.lower() if not key: continue display = strip_leading_quantity(raw) or raw count = int(row.get("count") or 0) if key not in merged: item = dict(row) item["source_name_normalized"] = key item["source_name_raw"] = display item["count"] = count item["variant_count"] = 1 merged[key] = item continue cur = merged[key] cur["count"] = int(cur.get("count") or 0) + count cur["variant_count"] = int(cur.get("variant_count") or 1) + 1 if display and (not cur.get("source_name_raw") or len(display) < len(cur["source_name_raw"])): cur["source_name_raw"] = display if row.get("matching_recipe_id") and not cur.get("matching_recipe_id"): cur["matching_recipe_id"] = row["matching_recipe_id"] if row.get("sample_quantity_raw") and not cur.get("sample_quantity_raw"): cur["sample_quantity_raw"] = row["sample_quantity_raw"] first, last = row.get("first_date"), row.get("last_date") if first and (not cur.get("first_date") or str(first) < str(cur["first_date"])): cur["first_date"] = first if last and (not cur.get("last_date") or str(last) > str(cur["last_date"])): cur["last_date"] = last return list(merged.values()) def as_iso_date(value: Any) -> str | None: if value is None or value == "": return None if hasattr(value, "isoformat"): return str(value.isoformat())[:10] text = str(value).strip() return text[:10] if len(text) >= 10 else None def filter_unmapped_since(rows: list[dict], since_days: int, today: date | None = None) -> list[dict]: """Keep names last eaten in the window. Rows without last_date drop out (old recipe leftovers).""" days = int(since_days or 0) if days <= 0: return list(rows) cutoff = ((today or date.today()) - timedelta(days=days)).isoformat() out = [] for row in rows: last = as_iso_date(row.get("last_date")) if last and last >= cutoff: out.append(row) return out def sort_unmapped_rows(rows: list[dict], since_days: int = 0) -> list[dict]: rows = list(rows) if int(since_days or 0) > 0: rows.sort( key=lambda x: ( as_iso_date(x.get("last_date")) or "", int(x.get("count") or 0), ), reverse=True, ) else: rows.sort(key=lambda x: (-int(x.get("count") or 0), x.get("source_name_normalized") or "")) return rows UNIT_ALIASES = { "g": "g", "gr": "g", "gramm": "g", "kg": "kg", "ml": "ml", "l": "l", "liter": "l", "lt": "l", "stück": "stück", "stk": "stück", "st": "stück", "st.": "stück", "pcs": "stück", "el": "el", "esslöffel": "el", "tl": "tl", "teelöffel": "tl", "prise": "prise", "scheibe": "scheibe", "portion": "portion", "portionen": "portion", "becher": "becher", "tasse": "tasse", "msp": "msp", "msp.": "msp", } MASS_VOLUME_TO_G = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "l": 1000.0} DEFAULT_UNIT_G = {"el": 15.0, "tl": 5.0, "prise": 0.3, "msp": 1.0} COUNT_UNITS = frozenset({"stück", "scheibe", "portion", "becher", "tasse"}) QTY_PARSE_RE = re.compile( r"^\s*(\d+(?:[.,]\d+)?)\s*([a-zA-ZäöüÄÖÜß.]+)?\s*$", re.IGNORECASE, ) def _canon_unit(raw: str | None) -> str | None: if not raw: return None return UNIT_ALIASES.get(raw.strip().lower().rstrip(".")) def parse_quantity(raw: str | None, grams_per_unit: float | None = None) -> dict[str, Any]: empty = {"value": None, "unit": None, "quantity_g": None, "needs_unit_map": False} if raw is None or str(raw).strip() == "": return empty text = str(raw).strip().replace(",", ".") m = QTY_PARSE_RE.match(text) if not m: return empty value = float(m.group(1)) unit = _canon_unit(m.group(2)) if unit is None: return {"value": value, "unit": "g", "quantity_g": round(value, 3), "needs_unit_map": False} if unit in MASS_VOLUME_TO_G: return { "value": value, "unit": unit, "quantity_g": round(value * MASS_VOLUME_TO_G[unit], 3), "needs_unit_map": False, } factor = grams_per_unit if grams_per_unit is not None else DEFAULT_UNIT_G.get(unit) if factor is not None: return { "value": value, "unit": unit, "quantity_g": round(value * float(factor), 3), "needs_unit_map": unit in COUNT_UNITS, } return {"value": value, "unit": unit, "quantity_g": None, "needs_unit_map": True} def parse_quantity_g(raw: str | None, grams_per_unit: float | None = None) -> float | None: return parse_quantity(raw, grams_per_unit)["quantity_g"] def detect_quantity_unit(*texts: str | None) -> str | None: for text in texts: unit = parse_quantity(text).get("unit") if unit and unit not in MASS_VOLUME_TO_G: return unit return None def get_food_mapping_with_cursor( cur, source_name: str, profile_id: str | None = None, source_system: str = "fddb", ) -> dict[str, Any] | None: norm = normalize_food_name(source_name) if not norm: return None if profile_id: cur.execute( """ SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source, m.grams_per_unit, m.source_unit, f.bls_code, f.name_de, f.catalog_kind FROM food_name_mappings m JOIN food_catalog f ON f.id = m.food_id WHERE m.source_system = %s AND m.source_name_normalized = %s AND m.profile_id = %s LIMIT 1 """, (source_system, norm, profile_id), ) row = cur.fetchone() if row: return dict(row) cur.execute( """ SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source, m.grams_per_unit, m.source_unit, f.bls_code, f.name_de, f.catalog_kind FROM food_name_mappings m JOIN food_catalog f ON f.id = m.food_id WHERE m.source_system = %s AND m.source_name_normalized = %s AND m.profile_id IS NULL LIMIT 1 """, (source_system, norm), ) row = cur.fetchone() return dict(row) if row else None def upsert_food_mapping( cur, *, source_name_raw: str, food_id: str, profile_id: str | None, source: str = "bulk", source_system: str = "fddb", grams_per_unit: float | None = None, source_unit: str | None = None, ) -> int: norm = normalize_food_name(source_name_raw) if not norm: raise ValueError("Leerer Lebensmittelname") if profile_id: cur.execute( """ SELECT id FROM food_name_mappings WHERE source_system = %s AND source_name_normalized = %s AND profile_id = %s """, (source_system, norm, profile_id), ) else: cur.execute( """ SELECT id FROM food_name_mappings WHERE source_system = %s AND source_name_normalized = %s AND profile_id IS NULL """, (source_system, norm), ) existing = cur.fetchone() raw = source_name_raw.strip() unit = _canon_unit(source_unit) if source_unit else None if existing: cur.execute( """ UPDATE food_name_mappings SET food_id = %s, source_name_raw = %s, source = %s, grams_per_unit = %s, source_unit = %s, updated_at = NOW() WHERE id = %s """, (food_id, raw, source, grams_per_unit, unit, existing["id"]), ) return int(existing["id"]) cur.execute( """ INSERT INTO food_name_mappings (source_system, source_name_raw, source_name_normalized, food_id, profile_id, source, grams_per_unit, source_unit, updated_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW()) RETURNING id """, (source_system, raw, norm, food_id, profile_id, source, grams_per_unit, unit), ) return int(cur.fetchone()["id"]) def apply_quantities_to_items(cur, profile_id: str, source_name_normalized: str, grams_per_unit: float | None) -> int: if not grams_per_unit: return 0 cur.execute( """ SELECT id, quantity_raw, source_name_raw FROM nutrition_items WHERE profile_id = %s AND source_name_normalized = %s """, (profile_id, source_name_normalized), ) n = 0 for row in cur.fetchall(): qty = parse_quantity_g(row.get("quantity_raw") or row.get("source_name_raw"), grams_per_unit) if qty is None: continue cur.execute( "UPDATE nutrition_items SET quantity_g = %s, updated_at = NOW() WHERE id = %s", (qty, row["id"]), ) n += 1 return n def apply_mapping_to_items(cur, profile_id: str, source_name_normalized: str, food_id: str, mapping_id: int) -> int: origin = _value_origin_for_food(cur, food_id) cur.execute( """ UPDATE nutrition_items SET food_id = %s, mapping_id = %s, value_origin = %s, updated_at = NOW() WHERE profile_id = %s AND source_name_normalized = %s AND recipe_id IS NULL """, (food_id, mapping_id, origin, profile_id, source_name_normalized), ) n = cur.rowcount or 0 cur.execute( """ SELECT id, source_name_raw FROM nutrition_items WHERE profile_id = %s AND recipe_id IS NULL AND food_id IS NULL """, (profile_id,), ) extra = [ row["id"] for row in cur.fetchall() if normalize_food_name(row.get("source_name_raw")) == source_name_normalized ] if extra: cur.execute( """ UPDATE nutrition_items SET food_id = %s, mapping_id = %s, value_origin = %s, updated_at = NOW() WHERE id = ANY(%s) """, (food_id, mapping_id, origin, extra), ) n += cur.rowcount or 0 return n def clear_mapping_from_items(cur, profile_id: str, source_name_normalized: str) -> int: cur.execute( """ UPDATE nutrition_items SET food_id = NULL, mapping_id = NULL, value_origin = 'fddb', updated_at = NOW() WHERE profile_id = %s AND source_name_normalized = %s """, (profile_id, source_name_normalized), ) return cur.rowcount or 0 def _value_origin_for_food(cur, food_id: str) -> str: cur.execute("SELECT catalog_kind FROM food_catalog WHERE id = %s", (food_id,)) row = cur.fetchone() if not row: return "fddb" kind = row["catalog_kind"] if kind == "official_bls": return "bls" return "manual_catalog" def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int = 8) -> list[dict]: q = (query or "").strip() if not q: return [] primary = q.split(",")[0].strip() or q like_full = f"%{q}%" like_primary = f"%{primary}%" prefix = f"{primary}%" norm = normalize_food_name(primary) cur.execute( """ SELECT id, bls_code, name_de, name_en, catalog_kind, food_group FROM food_catalog WHERE is_active = true AND ( owner_profile_id IS NULL OR owner_profile_id = %s ) AND ( name_de ILIKE %s OR name_de ILIKE %s OR COALESCE(name_en, '') ILIKE %s OR COALESCE(name_en, '') ILIKE %s OR COALESCE(bls_code, '') ILIKE %s OR lower(name_de) = %s ) ORDER BY CASE WHEN lower(name_de) = %s THEN 0 WHEN name_de ILIKE %s THEN 1 WHEN COALESCE(bls_code, '') ILIKE %s THEN 2 ELSE 3 END, name_de LIMIT %s """, ( profile_id, like_full, like_primary, like_full, like_primary, like_full, norm, norm, prefix, q, limit, ), ) return [dict(r) for r in cur.fetchall()]