"""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