mitai-jinkendo/backend/data_layer/food_suggest.py
Lars a7e5511039
All checks were successful
Deploy Development / deploy (push) Successful in 1m1s
Build Test / pytest-backend (push) Successful in 8s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 29s
feat: Rezepte beim Zuordnen anlegen und offene Zutaten listen
Lebensmittel oder Rezept wählbar; gemappte Zutaten werden übernommen, offene erscheinen in der Liste. Dazu Fettgehalt-Suche, EPA-Stoffe, Rezept-CRUD und Wechsel bestehender Zuordnungen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 16:59:24 +02:00

312 lines
11 KiB
Python

"""In-memory catalog suggestions: Haferflocken → Hafer Flocken, without opening a dialog."""
from __future__ import annotations
import re
import time
from typing import Any
from data_layer.food_mapping import normalize_food_name, primary_search_query
_INDEX_TTL_SEC = 300
_index_cache: dict[str, tuple[float, dict[str, Any]]] = {}
MAX_CANDIDATES = 60
MAX_BUCKET = 40
SPLIT_RE = re.compile(r"[^a-z0-9äöüß]+")
COLLAPSE_RE = re.compile(r"[^a-z0-9äöüß]")
NUM_RE = re.compile(r"\d+(?:[.,]\d+)?")
NUM_TOKEN_RE = re.compile(r"^\d+(?:\.\d+)?$")
MIN_SCORE = 45
FAT_CLOSE = 0.6
def collapse_key(raw: str | None) -> str:
return COLLAPSE_RE.sub("", normalize_food_name(raw))
def number_tokens(raw: str | None) -> list[str]:
return [m.replace(",", ".") for m in NUM_RE.findall(normalize_food_name(raw))]
def is_number_token(tok: str) -> bool:
return bool(NUM_TOKEN_RE.fullmatch(tok or ""))
def number_search_aliases(tok: str) -> list[str]:
"""9.5 and 10 are the same fat class in dairy; keep both searchable."""
if not is_number_token(tok):
return [tok]
aliases = {tok}
try:
value = float(tok)
except ValueError:
return [tok]
if abs(value - 10) <= FAT_CLOSE or abs(value - 9.5) <= FAT_CLOSE:
aliases.update({"9.5", "10"})
if value == int(value):
aliases.add(str(int(value)))
return list(aliases)
def numbers_compatible(query_nums: list[str], name_nums: list[str]) -> bool | None:
if not query_nums:
return None
if not name_nums:
return False
for qn in query_nums:
try:
qv = float(qn)
except ValueError:
continue
for nn in name_nums:
try:
if abs(qv - float(nn)) <= FAT_CLOSE:
return True
except ValueError:
if qn == nn:
return True
return False
def name_tokens(raw: str | None) -> list[str]:
text = normalize_food_name(raw)
nums = number_tokens(text)
words = [t for t in SPLIT_RE.split(NUM_RE.sub(" ", text)) if len(t) >= 2]
return words + nums
def score_name_match(query: str, name_de: str, name_en: str | None = None) -> int:
qn = normalize_food_name(primary_search_query(query))
nn = normalize_food_name(name_de)
if not qn or not nn:
return 0
qc, nc = collapse_key(qn), collapse_key(nn)
q_nums, n_nums = number_tokens(qn), number_tokens(nn)
fat_ok = numbers_compatible(q_nums, n_nums)
if qn == nn:
return 100
if qc and qc == nc:
return 95
qt, nt = set(name_tokens(qn)), set(name_tokens(nn))
q_words = {t for t in qt if not is_number_token(t)}
n_words = {t for t in nt if not is_number_token(t)}
words_overlap = bool(q_words and n_words and (q_words & n_words or q_words <= n_words))
if fat_ok and words_overlap:
return 96
if fat_ok is False:
base = 0
if qc and nc.startswith(qc) and len(qc) >= 4:
base = 82
elif nc and qc.startswith(nc) and len(nc) >= 4:
base = 78
elif qt and qt <= nt:
base = 72
elif nt and nt <= qt:
base = 68
elif qt and nt:
overlap = len(qt & nt) / len(qt | nt)
if overlap >= 0.5:
base = 50 + int(overlap * 20)
elif qc and nc and len(qc) >= 4 and (qc in nc or nc in qc):
base = 55 if abs(len(qc) - len(nc)) <= 8 else 46
return min(base, 52) if base else 0
if qc and nc.startswith(qc) and len(qc) >= 4:
return 82
if nc and qc.startswith(nc) and len(nc) >= 4:
return 78
if qt and qt <= nt:
return 72
if nt and nt <= qt:
return 68
if qt and nt:
overlap = len(qt & nt) / len(qt | nt)
if overlap >= 0.5:
return 50 + int(overlap * 20)
if qc and nc and len(qc) >= 4 and (qc in nc or nc in qc):
return 55 if abs(len(qc) - len(nc)) <= 8 else 46
en = normalize_food_name(name_en or "")
if en and (qn == en or collapse_key(en) == qc):
return 88
return 0
def _public(food: dict[str, Any], score: int) -> dict[str, Any]:
return {
"id": str(food["id"]),
"bls_code": food.get("bls_code"),
"name_de": food.get("name_de"),
"name_en": food.get("name_en"),
"catalog_kind": food.get("catalog_kind"),
"food_group": food.get("food_group"),
"score": score,
}
def invalidate_suggest_index(profile_id: str | None = None) -> None:
if profile_id is None:
_index_cache.clear()
return
_index_cache.pop(str(profile_id), None)
_index_cache.pop("global", None)
def get_suggest_index(cur, profile_id: str | None) -> dict[str, Any]:
key = str(profile_id or "global")
hit = _index_cache.get(key)
if hit and (time.monotonic() - hit[0]) < _INDEX_TTL_SEC:
return hit[1]
index = load_suggest_index(cur, profile_id)
_index_cache[key] = (time.monotonic(), index)
return index
def load_suggest_index(cur, profile_id: str | None) -> dict[str, Any]:
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)
""",
(profile_id,),
)
foods = [dict(r) for r in cur.fetchall()]
by_collapse: dict[str, list] = {}
by_token: dict[str, list] = {}
by_prefix: dict[str, list] = {}
by_suffix: dict[str, list] = {}
for food in foods:
food["_c"] = collapse_key(food.get("name_de"))
food["_t"] = name_tokens(food.get("name_de"))
if food["_c"]:
by_collapse.setdefault(food["_c"], []).append(food)
by_prefix.setdefault(food["_c"][:4], []).append(food)
if len(food["_c"]) >= 4:
by_suffix.setdefault(food["_c"][-4:], []).append(food)
for tok in food["_t"]:
by_token.setdefault(tok, []).append(food)
return {
"foods": foods,
"by_collapse": by_collapse,
"by_token": by_token,
"by_prefix": by_prefix,
"by_suffix": by_suffix,
}
def _candidate_foods(index: dict[str, Any], query: str) -> list[dict[str, Any]]:
qc = collapse_key(query)
seen: set[str] = set()
out: list[dict[str, Any]] = []
def add(food: dict[str, Any]) -> None:
fid = str(food["id"])
if fid in seen:
return
seen.add(fid)
out.append(food)
if qc:
for food in index["by_collapse"].get(qc, []):
add(food)
if len(qc) >= 4:
prefix_hits = index["by_prefix"].get(qc[:4], [])
if len(prefix_hits) > MAX_BUCKET:
prefix_hits = [
food for food in prefix_hits
if (food.get("_c") or "").startswith(qc) or qc.startswith(food.get("_c") or "")
]
for food in prefix_hits[:MAX_CANDIDATES]:
add(food)
suffix_hits = index["by_suffix"].get(qc[-4:], [])
if len(suffix_hits) <= MAX_BUCKET:
for food in suffix_hits:
add(food)
q_words = [t for t in name_tokens(query) if not is_number_token(t)]
seen_tok: set[str] = set()
for tok in name_tokens(query):
probes = number_search_aliases(tok) if is_number_token(tok) else [tok]
for probe in probes:
if probe in seen_tok:
continue
seen_tok.add(probe)
token_hits = index["by_token"].get(probe, [])
if is_number_token(probe) and q_words:
token_hits = [
food for food in token_hits
if set(q_words) & set(food.get("_t") or [])
]
elif not is_number_token(probe) and len(token_hits) > MAX_BUCKET:
token_hits = sorted(token_hits, key=lambda f: len(f.get("name_de") or ""))[:MAX_BUCKET]
for food in token_hits:
add(food)
if len(out) >= MAX_CANDIDATES and not any(is_number_token(t) for t in name_tokens(query)):
break
return out[:MAX_CANDIDATES] if not number_tokens(query) else out
def suggest_for_name(index: dict[str, Any], query: str, limit: int = 3) -> dict[str, Any]:
q = (query or "").strip()
scored: list[tuple[int, int, dict]] = []
for food in _candidate_foods(index, q):
score = score_name_match(q, food.get("name_de") or "", food.get("name_en"))
if score < MIN_SCORE:
continue
scored.append((score, len(food.get("name_de") or ""), food))
scored.sort(key=lambda x: (-x[0], x[1], x[2].get("name_de") or ""))
top = [_public(food, score) for score, _nlen, food in scored[: max(limit, 3)]]
ambiguous = False
if len(top) >= 2 and top[0]["score"] - top[1]["score"] <= 8 and top[1]["score"] >= 60:
ambiguous = True
elif len(top) >= 2 and top[0]["score"] < 90:
ambiguous = True
return {
"suggestions": top[:limit],
"suggestion_count": len(top),
"ambiguous": ambiguous,
}
def attach_suggestions(index: dict[str, Any], rows: list[dict[str, Any]], limit: int = 3) -> list[dict[str, Any]]:
for row in rows:
q = row.get("source_name_raw") or row.get("source_name_normalized") or ""
packed = suggest_for_name(index, q, limit=limit)
row["suggestions"] = packed["suggestions"]
row["suggestion_count"] = packed["suggestion_count"]
row["ambiguous"] = packed["ambiguous"]
return rows
def suggest_catalog_foods_ranked(cur, query: str, profile_id: str | None, limit: int = 8) -> list[dict]:
q = (query or "").strip()
if len(q) < 2:
return []
index = get_suggest_index(cur, profile_id)
packed = suggest_for_name(index, q, limit=max(limit, 8))
hits = list(packed["suggestions"])
from data_layer.food_mapping import suggest_catalog_foods
if number_tokens(q) or not hits:
seen = {str(h["id"]) for h in hits}
for row in suggest_catalog_foods(cur, q, profile_id, limit=max(limit, 20)):
fid = str(row["id"])
if fid in seen:
continue
seen.add(fid)
score = score_name_match(q, row.get("name_de") or "", row.get("name_en"))
if score < MIN_SCORE and not number_tokens(q):
continue
hits.append(_public(row, score or 40))
hits.sort(key=lambda h: (-int(h.get("score") or 0), h.get("name_de") or ""))
return hits[:limit]
def suggest_batch(cur, profile_id: str | None, names: list[str], limit: int = 3) -> dict[str, dict[str, Any]]:
index = get_suggest_index(cur, profile_id)
out = {}
for raw in names[:100]:
key = (raw or "").strip()
if not key or key in out:
continue
out[key] = suggest_for_name(index, key, limit=limit)
return out