Tagebuchzeilen eigener Rezepte werden über den Listen-Import in Zutaten zerlegt. Zuordnen erfolgt im Namens-Popup statt per BLS-Code. Co-authored-by: Cursor <cursoragent@cursor.com>
207 lines
6.3 KiB
Python
207 lines
6.3 KiB
Python
"""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 = s.lstrip("!")
|
||
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
|
||
AND recipe_id IS NULL
|
||
""",
|
||
(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 []
|
||
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()]
|