mitai-jinkendo/backend/data_layer/food_suggest.py
Lars 132a364a3a
All checks were successful
Deploy Development / deploy (push) Successful in 1m6s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 29s
feat: Zuordnen ohne Freeze und zuerst die letzten Wochen
Katalogsuche und Bestätigen blockieren die UI nicht mehr; offene Namen starten bei den aktuellen Tagebucheinträgen, alter Ballast bleibt unter Alle.

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

215 lines
7.2 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
_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äöüß]")
MIN_SCORE = 45
def collapse_key(raw: str | None) -> str:
return COLLAPSE_RE.sub("", normalize_food_name(raw))
def name_tokens(raw: str | None) -> list[str]:
return [t for t in SPLIT_RE.split(normalize_food_name(raw)) if len(t) >= 2]
def score_name_match(query: str, name_de: str, name_en: str | None = None) -> int:
qn = normalize_food_name((query or "").split(",")[0])
nn = normalize_food_name(name_de)
if not qn or not nn:
return 0
qc, nc = collapse_key(qn), collapse_key(nn)
if qn == nn:
return 100
if qc and qc == nc:
return 95
if qc and nc.startswith(qc) and len(qc) >= 4:
return 82
if nc and qc.startswith(nc) and len(nc) >= 4:
return 78
qt, nt = set(name_tokens(qn)), set(name_tokens(nn))
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)
for tok in name_tokens(query):
token_hits = index["by_token"].get(tok, [])
if 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:
break
return out[:MAX_CANDIDATES]
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=limit)
if packed["suggestions"]:
return packed["suggestions"]
from data_layer.food_mapping import suggest_catalog_foods
return suggest_catalog_foods(cur, q, profile_id, limit=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