chore: initial import
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user