From 41d262cb2dbb1a330f237d9fb181195e4ac105d7 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 2 Oct 2026 13:22:38 +0200 Subject: [PATCH] Rezept-Zuordnung: "vegan" ist eine Eigenschaft, kein Lebensmittel Der Wortanker nahm jedes geteilte Wort ab vier Zeichen - also auch "vegan". Damit passte jedes vegane Produkt auf jede vegane Zutat: "vegane Butter" landete auf "veganes Muehlen Knusper Bleu", "veganer griechischer Joghurt" auf "Vegane Muehlen Nuggets" und "veganer Lime Curd" auf "Vegane Muehlen-Schnitzel auf Basis von Soja". Geteilt wurde dort nur die Eigenschaft, nicht die Sache. Beschreibende Woerter begruenden deshalb keinen Treffer mehr - vegan, vegetarisch, bio, frisch, geschmolzen, ungesalzen, geschaelt, geschnitten, Zimmertemperatur, oder, optional und so weiter, dazu reine Zahlen. Als Wortanfaenge aufgefuehrt, weil Deutsch beugt ("vegan", "vegane", "veganer", "veganes"). Sie sperren aber nicht: "vegane Butter" findet weiterhin eine "Vegane Butter", nur eben ueber das Wort "Butter". Wichtig war, die Liste auf BEIDEN Seiten anzuwenden. Sonst steckt "vegan" weiter im Produktnamen und der Teilstring-Vergleich greift von dort aus. Gegenprobe mit dem echten Katalog: Von zehn Zutaten bleiben die drei richtigen Treffer (Mehl, Vollmilch, Aepfel), die sieben erfundenen sind weg. Co-Authored-By: Claude Opus 5 --- backend/app/services/rezepte.py | 47 +++++++++++++++++++---- backend/tests/test_rezept_abgleich.py | 54 +++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 7 deletions(-) diff --git a/backend/app/services/rezepte.py b/backend/app/services/rezepte.py index 39051be..b69dfed 100644 --- a/backend/app/services/rezepte.py +++ b/backend/app/services/rezepte.py @@ -122,13 +122,41 @@ def _stamm(text: str) -> str: return roh.replace("ae", "a").replace("oe", "o").replace("ue", "u") -def _woerter(text: str) -> list[str]: - """Bedeutungstragende Wörter einer Zutat – sehr kurze fallen weg. +# Wörter, die eine Zutat BESCHREIBEN, aber nicht benennen. Sie dürfen allein +# keinen Treffer begründen, sonst passt jedes vegane Produkt auf jede vegane +# Zutat: „vegane Butter" landete so auf „veganes Mühlen Knusper Bleu", und +# „veganer Lime Curd" auf „Vegane Mühlen-Schnitzel". Geteilt wird dort nur die +# Eigenschaft, nicht die Sache. +# +# Als Wortanfänge aufgeführt, weil Deutsch beugt: „vegan", „vegane", „veganer", +# „veganes" sollen alle gemeint sein. Verglichen wird gegen die zusammengezogene +# Form aus ``_stamm`` (ae/oe/ue schon aufgelöst). +_NUR_EIGENSCHAFT = ( + "vegan", "vegetar", "bio", "biologisch", "frisch", "roh", + "gekocht", "gebacken", "geschmolz", "gesalz", "ungesalz", "gezuckert", + "getrock", "gefror", "gerieb", "gewurf", "geschnitt", "geschal", "gehackt", + "gemahl", "gepresst", "zimmertemperatur", "lauwarm", + "klein", "gross", "mittel", "fein", "grob", "ganz", + "oder", "sowie", "etwa", "circa", "nach", "ohne", "optional", "evtl", + "block", "stueck", "stuck", "gehauft", "gestrich", +) - „1 TL" und „oder" sollen keine Treffer begründen; „Mehl", „Apfel", - „Vollmilch" sehr wohl. + +def _nur_eigenschaft(wort: str) -> bool: + return wort.isdigit() or wort.startswith(_NUR_EIGENSCHAFT) + + +def _woerter(text: str) -> list[str]: + """Bedeutungstragende Wörter einer Zutat. + + Weg fallen sehr kurze („1 TL"), reine Zahlen und alles, was die Zutat nur + beschreibt (siehe ``_NUR_EIGENSCHAFT``). Übrig bleibt, was sie IST: „Mehl", + „Apfel", „Butter", „Joghurt". """ - return [w for w in _stamm(text).split() if len(w) >= 4] + return [ + w for w in _stamm(text).split() + if len(w) >= 4 and not _nur_eigenschaft(w) + ] def _hat_anker(zutat_woerter: list[str], kandidat: str) -> bool: @@ -143,9 +171,14 @@ def _hat_anker(zutat_woerter: list[str], kandidat: str) -> bool: ziel = _stamm(kandidat) if not ziel: return False - ziel_woerter = [w for w in ziel.split() if len(w) >= 4] + ziel_woerter = [ + w for w in ziel.split() if len(w) >= 4 and not _nur_eigenschaft(w) + ] + # Auch der Teilstring-Vergleich darf nicht über eine Eigenschaft laufen: + # „vegan" steckt sonst weiter in „veganes Mühlen Knusper Bleu". + ziel_ohne = " ".join(ziel_woerter) for wort in zutat_woerter: - if wort in ziel: + if wort in ziel_ohne: return True # Umgekehrt auch: Artikel „Milch" passt zur Zutat „Buttermilch". for zw in ziel_woerter: diff --git a/backend/tests/test_rezept_abgleich.py b/backend/tests/test_rezept_abgleich.py index 41619f5..9939f4c 100644 --- a/backend/tests/test_rezept_abgleich.py +++ b/backend/tests/test_rezept_abgleich.py @@ -256,6 +256,60 @@ def test_kategorie_wird_zuerst_durchsucht(db): assert zutat.status == "vorhanden" +def test_vegan_allein_begruendet_keinen_treffer(db): + """„vegan" ist eine Eigenschaft, kein Lebensmittel. + + Sonst passt jedes vegane Produkt auf jede vegane Zutat: „vegane Butter" + landete auf „veganes Mühlen Knusper Bleu", „veganer Lime Curd" auf + „Vegane Mühlen-Schnitzel". + """ + fleisch = _kategorie(db, "Fleischersatz") + _artikel(db, "veganes Mühlen Knusper Bleu", fleisch, bestand=400) + _artikel(db, "Vegane Mühlen Nuggets Klassisch", fleisch, bestand=400) + _artikel(db, "Vegane Mühlen-Schnitzel auf Basis von Soja", fleisch, bestand=400) + + zutaten = [ + _zutat("vegane Butter, Block, ungesalzen", 45, "g"), + _zutat("veganer griechischer Joghurt oder Skyr", 80, "g"), + _zutat("veganer Lime Curd", 120, "g"), + ] + for zutat in pruefe_zutaten(db, zutaten): + assert zutat.status == "kein_treffer", zutat.text + assert zutat.ziel_name == "" + + +def test_vegane_zutat_findet_das_passende_vegane_produkt(db): + """Die Eigenschaft darf nicht sperren, nur nicht allein tragen.""" + molk = _kategorie(db, "Molkereiprodukte") + butter = _artikel(db, "Vegane Butter", molk, bestand=500) + _artikel(db, "Vegane Mühlen-Schnitzel", molk, bestand=400) + + [zutat] = pruefe_zutaten(db, [_zutat("vegane Butter, Block, ungesalzen", 45, "g")]) + + assert zutat.ziel_id == butter.id + assert zutat.status == "vorhanden" + + +def test_zutat_aus_lauter_eigenschaften_trifft_nichts(db): + cat = _kategorie(db, "Trockenwaren") + _artikel(db, "Weissmehl", cat, bestand=1000) + + [zutat] = pruefe_zutaten(db, [_zutat("frisch gemahlen, optional", 1, "g")]) + + assert zutat.status == "kein_treffer" + + +def test_beiwoerter_stoeren_den_echten_treffer_nicht(db): + cat = _kategorie(db, "Obst & Gemüse") + aepfel = _artikel(db, "Äpfel", cat, bestand=3000) + + [zutat] = pruefe_zutaten( + db, [_zutat("Apfel geschält und in 3 mm dünne Scheiben geschnitten", 900, "g")] + ) + + assert zutat.ziel_id == aepfel.id + + def test_umlaut_und_mehrzahl_finden_sich(db): """Rezept schreibt „Apfel", im Katalog stehen „Äpfel".""" cat = _kategorie(db, "Obst & Gemüse")