mitai-jinkendo/backend/data_layer/food_mapping.py
Lars fa542e0fb9
All checks were successful
Deploy Development / deploy (push) Successful in 1m7s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s
feat: Listen-Dialog mit Mengen/Einheiten, Mitai ohne Kochrezepte
Roh-Kombinationen statt Rezept-Sprache; Zutaten suchen, EL/TL/Prise auf Gramm. Gekochte Gerichte und Ausbeute bleiben Tandoor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 18:23:01 +02:00

526 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()
_LIST_COMMA_RE = re.compile(r",(?!\s*\d)")
def primary_search_query(query: str | None) -> str:
"""Use the name before a list-comma, but keep decimal commas (9,5 %)."""
q = (query or "").strip()
if not q:
return ""
return _LIST_COMMA_RE.split(q, maxsplit=1)[0].strip()
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"})
UNIT_LABELS = {
"g": "g", "kg": "kg", "ml": "ml", "l": "l",
"el": "EL", "tl": "TL", "prise": "Prise", "msp": "Msp.",
"stück": "Stück", "scheibe": "Scheibe", "portion": "Portion",
"becher": "Becher", "tasse": "Tasse",
}
UNIT_ORDER = (
"g", "ml", "el", "tl", "prise", "msp",
"stück", "scheibe", "portion", "becher", "tasse", "kg", "l",
)
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 format_quantity_raw(value: float | None, unit: str | None) -> str | None:
if value is None:
return None
label = UNIT_LABELS.get(unit or "g", unit or "g")
num = int(value) if float(value) == int(value) else value
return f"{num} {label}".replace(".", ",")
def list_quantity_units() -> list[dict[str, Any]]:
out = []
for uid in UNIT_ORDER:
default_g = MASS_VOLUME_TO_G.get(uid)
if default_g is None:
default_g = DEFAULT_UNIT_G.get(uid)
out.append({
"id": uid,
"label": UNIT_LABELS[uid],
"default_g": default_g,
"needs_unit_map": uid in COUNT_UNITS,
})
return out
def resolve_quantity(
*,
quantity_raw: str | None = None,
quantity_amount: float | None = None,
source_unit: str | None = None,
quantity_g: float | None = None,
grams_per_unit: float | None = None,
) -> dict[str, Any]:
"""Amount + unit → quantity_g. Standard unit is grams; conversion uses mapping or defaults."""
amount = None
if quantity_amount not in (None, ""):
try:
amount = float(quantity_amount)
except (TypeError, ValueError):
amount = None
explicit_g = None
if quantity_g not in (None, ""):
try:
explicit_g = float(quantity_g)
except (TypeError, ValueError):
explicit_g = None
if amount is not None:
unit = _canon_unit(source_unit) or "g"
raw = format_quantity_raw(amount, unit)
parsed = parse_quantity(raw.replace(",", "."), grams_per_unit)
elif quantity_raw:
parsed = parse_quantity(quantity_raw, grams_per_unit)
elif explicit_g is not None:
parsed = {"value": explicit_g, "unit": "g", "quantity_g": explicit_g, "needs_unit_map": False}
else:
parsed = {"value": None, "unit": _canon_unit(source_unit), "quantity_g": None, "needs_unit_map": False}
qty_g = parsed.get("quantity_g")
if qty_g is None and explicit_g is not None:
qty_g = explicit_g
unit = parsed.get("unit")
value = parsed.get("value")
return {
"quantity_amount": value,
"source_unit": unit,
"quantity_raw": format_quantity_raw(value, unit) or (str(quantity_raw).strip() if quantity_raw else None),
"quantity_g": qty_g,
"needs_unit_map": bool(parsed.get("needs_unit_map")),
}
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"
_SEARCH_NUM_RE = re.compile(r"\d+(?:[.,]\d+)?")
def _catalog_must_tokens(query: str) -> list[list[str]]:
"""AND-groups for catalog search: first word plus each number (with 9,5/10 aliases)."""
text = normalize_food_name(primary_search_query(query))
nums = [m.replace(",", ".") for m in _SEARCH_NUM_RE.findall(text)]
words = [t for t in re.split(r"[^a-z0-9äöüß]+", _SEARCH_NUM_RE.sub(" ", text)) if len(t) >= 2]
groups: list[list[str]] = []
if words:
groups.append([words[0]])
for num in nums[:3]:
variants = {num, num.replace(".", ",")}
try:
value = float(num)
except ValueError:
value = None
if value is not None and (abs(value - 10) <= 0.6 or abs(value - 9.5) <= 0.6):
variants.update({"10", "9.5", "9,5"})
groups.append(list(variants))
return groups
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 = primary_search_query(q) or q
like_full = f"%{q}%"
like_primary = f"%{primary}%"
prefix = f"{primary}%"
norm = normalize_food_name(primary)
extra_sql = ""
extra_params: list[str] = []
must = _catalog_must_tokens(q)
if len(must) >= 2:
parts = []
for group in must:
ors = " OR ".join(["name_de ILIKE %s"] * len(group))
parts.append(f"({ors})")
extra_params.extend(f"%{v}%" for v in group)
extra_sql = " OR (" + " AND ".join(parts) + ")"
cur.execute(
f"""
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
{extra_sql}
)
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,
*extra_params,
norm, prefix, q, limit,
),
)
return [dict(r) for r in cur.fetchall()]