Files
boehmitools-core/core/foodmatch.py
T
2026-07-24 21:37:03 +02:00

298 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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: (0100, 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