298 lines
11 KiB
Python
298 lines
11 KiB
Python
# -*- 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
|