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>
312 lines
11 KiB
Python
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
|