# -*- coding: utf-8 -*- """ Namensabgleich für deutsche Zutaten-, Einheiten- und Schlagwortnamen. Der AI-Import fragt: „Welcher vorhandene Eintrag könnte gemeint sein?“ — dort darf großzügig vorgeschlagen werden, ein Mensch entscheidet im Dropdown. Hier ist die Frage eine andere: „Welche Einträge sind dasselbe und dürfen zusammengeführt werden?“ Zusammenführen ist in Tandoor nicht umkehrbar, also gilt hier das Gegenteil: im Zweifel nicht. Deshalb bewusst zwei getrennte Stufen: duplicate_groups() nur was praktisch sicher dasselbe ist — gleicher Name oder Singular/Plural. Nur das wird zum Zusammenführen vorgeschlagen. similar_pairs() alles Grenzwertige — „Sahne / Schlagsahne“. Wird nur aufgelistet, nie vorausgewählt. Bekannte Lücke: unregelmäßige Plurale mit Umlaut (Ei/Eier, Apfel/Äpfel) erkennt die Heuristik nicht. Sie tauchen dann in keiner der beiden Listen auf. """ from __future__ import annotations import re import unicodedata from difflib import SequenceMatcher from typing import Any, Iterable _SPACES = re.compile(r"\s+") _NOISE = re.compile(r"[^\w\s]", re.UNICODE) # Wörter, die zwei Einträge inhaltlich verschieden machen. Tauchen sie nur auf # einer Seite auf, ist es keine Dublette – „Rote Zwiebel“ ist nicht „Zwiebel“. DISTINGUISHING = { "rot", "rote", "roter", "rotes", "gruen", "gruene", "gruener", "gelb", "gelbe", "weiss", "weisse", "weisser", "schwarz", "schwarze", "braun", "braune", "gross", "grosse", "klein", "kleine", "frisch", "frische", "getrocknet", "getrocknete", "gemahlen", "gemahlene", "geraeuchert", "geraeucherte", "tiefgekuehlt", "roh", "rohe", "gekocht", "gekochte", "suess", "suesse", "sauer", "saure", "fettarm", "fettarme", "vollfett", "halbfett", "mager", "magere", "light", "vegan", "vegetarisch", "bio", "glatt", "glatte", "kraus", "geschaelt", "geschaelte", "ganz", "ganze", } # Beschreibende Endungen, die einen Eintrag zu einer eigenen Sorte machen: # laktoseFREI, fettARM, eiweissREICH, fettREDUZIERT, zuckerHALTIG … _DISTINGUISHING_MARKERS = ("frei", "arm", "reich", "reduziert", "haltig") _MARKER_INFLECTIONS = ("", "e", "er", "es", "en", "em") def is_distinguishing_word(word: str) -> bool: """ Macht dieses Wort zwei Einträge inhaltlich verschieden? Entweder es steht auf der festen Liste, oder es endet auf einen beschreibenden Marker (mit Beugung). Der Wortstamm davor muss lang genug sein, damit z. B. „Marmelade“ nicht wegen „arm“ anschlägt. """ if word in DISTINGUISHING: return True for marker in _DISTINGUISHING_MARKERS: for inflection in _MARKER_INFLECTIONS: suffix = marker + inflection if word.endswith(suffix) and len(word) - len(suffix) >= 3: return True return False def folded(value: Any) -> str: """Kleinschreibung, Umlaute als ae/oe/ue/ss, Akzente weg.""" text = str(value or "").strip().casefold() for source, target in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")): text = text.replace(source, target) text = unicodedata.normalize("NFKD", text) return "".join(ch for ch in text if not unicodedata.combining(ch)) def comparable(value: Any) -> str: return _SPACES.sub(" ", _NOISE.sub(" ", folded(value))).strip() def word_forms(word: str) -> set[str]: """Häufige deutsche Singular-/Pluralformen eines einzelnen Wortes.""" word = word.strip() if len(word) <= 2: return {word} if word else set() forms = {word} if word.endswith("eln") or word.endswith("ern"): forms.add(word[:-1]) if word.endswith("en") and len(word) > 4: forms.add(word[:-2]) forms.add(word[:-1]) if word.endswith("n") and len(word) > 4: forms.add(word[:-1]) if word.endswith("e"): forms.add(word + "n") else: forms.update({word + "e", word + "en", word + "n", word + "s"}) return {form for form in forms if len(form) >= 3} def same_word(a: str, b: str) -> bool: """Zwei Wörter sind gleich oder Singular/Plural voneinander.""" return a == b or b in word_forms(a) or a in word_forms(b) def looks_like_plural(base: str, candidate: str) -> bool: """ Ist `candidate` eine plausible Pluralform von `base`? Nur bei gleicher Wortzahl und wenn jedes abweichende Wort eine erkennbare, nicht kürzere Pluralform ist. Bewusst konservativ: „Zwiebel“ → „Zwiebeln“ ja, „Sahne“ → „Schlagsahne“ nein. Umlaut-Plurale (Apfel → Äpfel) erkennt die Heuristik nicht. """ base_c, cand_c = comparable(base), comparable(candidate) if not base_c or not cand_c or base_c == cand_c: return False bw, cw = base_c.split(), cand_c.split() if len(bw) != len(cw): return False veraendert = False for x, y in zip(bw, cw): if x == y: continue if y in word_forms(x) and len(y) >= len(x): veraendert = True continue return False return veraendert def guess_plural(name: str) -> str: """ Grober Pluralvorschlag für ein einzelnes deutsches Wort — nur als editierbare Vorbelegung gedacht, nicht als Wahrheit. Bei Mehrwortnamen und unsicheren Endungen bewusst leer, damit nichts Falsches vorgeschlagen wird. """ n = (name or "").strip() if not n or " " in n or "-" in n: return "" low = n.casefold() # -e -> -en (Tomate -> Tomaten, Zwiebel bleibt aber Sache des Nutzers) if low.endswith("e"): return n + "n" # -el/-er/-en oft unverändert; kein sicherer Vorschlag if low.endswith(("el", "er", "en", "chen", "lein")): return "" if low.endswith(("a", "o", "i", "u", "y")): return n + "s" return n + "e" def is_variant(a: str, b: str) -> bool: """ Gleiche Wortzahl und Wort für Wort dieselbe Bedeutung. Die Wortzahl-Bedingung ist der Kern: Sie verhindert, dass „Rote Zwiebel“ auf „Zwiebel“ oder „Mehl Type 550“ auf „Mehl“ zusammenfällt. """ left, right = comparable(a).split(), comparable(b).split() if not left or len(left) != len(right): return False return all(same_word(x, y) for x, y in zip(left, right)) def similarity(left: str, right: str) -> tuple[float, str]: """Wie ähnlich sind zwei Namen? Rückgabe: (0–100, Begründung).""" a, b = comparable(left), comparable(right) if not a or not b: return 0.0, "leer" if a == b: return 100.0, "gleicher Name" if is_variant(a, b): return 96.0, "Singular/Plural" a_words, b_words = set(a.split()), set(b.split()) unterscheidend = {w for w in (a_words ^ b_words) if is_distinguishing_word(w)} if unterscheidend: return 40.0, f"unterscheidet sich durch „{sorted(unterscheidend)[0]}“" # Alles Weitere ist bestenfalls ein Hinweis. Die Obergrenze liegt bewusst # unter der Dublettenschwelle: Teilwörter sind in Zusammensetzungen die # Regel (Sahne/Schlagsahne, Kartoffel/Süßkartoffel) und keine Dubletten. # # Wichtig: „Teilwort“ meint eine echte WORT-Teilmenge — jedes Wort der # kürzeren Seite kommt als ganzes Wort in der längeren vor. Eine bloße # Zeichenketten-Enthaltung reicht NICHT, sonst gelten „Mango“/„Mangold“, # „Buchweizen“/„Buchweizenmehl“ oder „getrocknete Tomate“/„getrocknete # Tomaten in Öl“ fälschlich als ähnlich. if a_words < b_words or b_words < a_words: short = min(len(a_words), len(b_words)) long = max(len(a_words), len(b_words)) return min(70.0 + (short / long) * 15.0, 85.0), "Teilwort" overlap = len(a_words & b_words) / max(1, len(a_words | b_words)) ratio = SequenceMatcher(None, a, b).ratio() return min(max(ratio * 76.0, overlap * 80.0), 85.0), "ähnlich" def names_of(obj: dict[str, Any]) -> list[str]: return [value for value in (obj.get("name"), obj.get("plural_name")) if value] def best_match(left: dict[str, Any], right: dict[str, Any]) -> tuple[float, str]: best = (0.0, "") for a in names_of(left): for b in names_of(right): score, reason = similarity(a, b) if score > best[0]: best = (score, reason) return best def _rank_target(member: dict[str, Any]) -> tuple: """Ziel einer Zusammenführung: der Eintrag, der am meisten zu verlieren hat.""" return ( -(member.get("numrecipe") or 0), # in den meisten Rezepten -len(member.get("properties") or []), # hat Nährwerte -bool(member.get("plural_name")), # Plural gepflegt -bool(member.get("supermarket_category")), len(member.get("name") or ""), member.get("id") or 0, ) DUPLICATE_THRESHOLD = 90.0 SIMILAR_THRESHOLD = 74.0 def duplicate_groups(objects: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: """Gruppen von Einträgen, die praktisch sicher dasselbe sind.""" items = [obj for obj in objects if isinstance(obj.get("id"), int)] parent: dict[int, int] = {obj["id"]: obj["id"] for obj in items} reasons: dict[tuple[int, int], tuple[float, str]] = {} def root(x: int) -> int: while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x for index, left in enumerate(items): for right in items[index + 1:]: score, reason = best_match(left, right) if score >= DUPLICATE_THRESHOLD: reasons[tuple(sorted((left["id"], right["id"])))] = (score, reason) parent[root(left["id"])] = root(right["id"]) clusters: dict[int, list[dict[str, Any]]] = {} for obj in items: clusters.setdefault(root(obj["id"]), []).append(obj) groups: list[dict[str, Any]] = [] for members in clusters.values(): if len(members) < 2: continue target = sorted(members, key=_rank_target)[0] best_score, best_reason = 0.0, "ähnlich" for member in members: if member["id"] == target["id"]: continue key = tuple(sorted((member["id"], target["id"]))) score, reason = reasons.get(key, (0.0, "über einen dritten Eintrag")) if score > best_score: best_score, best_reason = score, reason groups.append({ "target_id": target["id"], "target": target, "members": sorted(members, key=_rank_target), "sources": [m for m in members if m["id"] != target["id"]], "score": round(best_score, 1), "reason": best_reason, "recipes_affected": sum((m.get("numrecipe") or 0) for m in members), }) groups.sort(key=lambda g: -g["recipes_affected"]) return groups def similar_pairs(objects: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: """Grenzwertige Paare — nur zum Anschauen, nie zum Zusammenführen.""" items = [obj for obj in objects if isinstance(obj.get("id"), int)] pairs: list[dict[str, Any]] = [] for index, left in enumerate(items): for right in items[index + 1:]: score, reason = best_match(left, right) if SIMILAR_THRESHOLD <= score < DUPLICATE_THRESHOLD: pairs.append({ "left": left, "right": right, "score": round(score, 1), "reason": reason, }) pairs.sort(key=lambda p: -p["score"]) return pairs