mitai-jinkendo/backend/data_layer/food_mapping.py

195 lines
5.9 KiB
Python
Raw Normal View History

"""FDDB → food_catalog mapping: normalize, lookup (user then global), learn, apply."""
from __future__ import annotations
import re
import unicodedata
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 normalize_food_name(raw: str | None) -> str:
if not raw:
return ""
s = unicodedata.normalize("NFKC", str(raw)).strip().strip('"').strip("'")
s = LEADING_QTY_RE.sub("", s)
s = DECIMAL_IN_NAME_RE.sub(r"\1.\2", s)
s = MULTISPACE_RE.sub(" ", s).strip().lower()
return s
def parse_quantity_g(raw: str | None) -> float | None:
if raw is None or str(raw).strip() == "":
return None
text = str(raw).strip().replace(",", ".")
m = re.match(r"^\s*(\d+(?:\.\d+)?)\s*(g|gramm)?\s*$", text, re.IGNORECASE)
if m:
return round(float(m.group(1)), 3)
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,
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,
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",
) -> 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()
if existing:
cur.execute(
"""
UPDATE food_name_mappings
SET food_id = %s, source_name_raw = %s, source = %s, updated_at = NOW()
WHERE id = %s
""",
(food_id, raw, source, 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, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, NOW())
RETURNING id
""",
(source_system, raw, norm, food_id, profile_id, source),
)
return int(cur.fetchone()["id"])
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
""",
(food_id, mapping_id, origin, profile_id, source_name_normalized),
)
return cur.rowcount or 0
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 []
like = f"%{q}%"
norm = normalize_food_name(q)
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 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 COALESCE(bls_code, '') ILIKE %s THEN 1
ELSE 2 END,
name_de
LIMIT %s
""",
(profile_id, like, like, like, norm, norm, q, limit),
)
return [dict(r) for r in cur.fetchall()]