From 4e34accee3896eab0bf8e4f6c415c2c19583fe16 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 2 Oct 2026 13:18:27 +0200 Subject: [PATCH] Rezept-Zuordnung: Gegenstaende raus, Wortanker rein, Kategorie zuerst Die Zuordnung war unbrauchbar: "Mehl Typ 405" landete auf "Muehlen Geschnetzeltes", "Eier" auf "Reis", "Zucker" auf "Joystick fuer WII", "Apfel" auf "Snack Collection Platten 4 (Waffeln)" und "Salz" auf "Shirt Casual Fit". Drei Fehler steckten darin. 1. Gegenstaende standen ueberhaupt zur Auswahl. Der Kassenzettel-Abgleich filtert sie seit immer heraus, mein Rezeptabgleich nicht - also konnten ein Joystick, ein Shirt und eine Waffelplatte als Zutat gelten. Jetzt zaehlen nur Lebensmittel, und Einzelstuecke bleiben ebenfalls draussen. 2. Entschieden hat eine Buchstaben-Aehnlichkeit. Die haelt "Eier" und "Reis" fuer verwandt, weil sich Zeichen ueberlappen. Neu muss ein ganzes Wort geteilt sein: "Mehl" in "Weissmehl Typ 550" ja, "Mehl" in "Muehlen Geschnetzeltes" nein. Umlaute und Mehrzahl werden dabei zusammengezogen, damit "Apfel" auch "Aepfel" findet. 3. Gesucht wurde im ganzen Katalog. Jetzt zuerst dort, wo es hingehoert: Teilt der Zutatenname ein Wort mit einer Kategorie, gilt nur deren Teilbaum - "Mehl Typ 405" sucht in "Mehl & Zucker", nicht im Fleisch. Erst wenn darin nichts passt, zaehlt der Rest. Die Punktzahl ist damit keine Sperre mehr, sondern nur noch die Reihenfolge. Als Sperre war sie untauglich in beide Richtungen: "Vollmilch Zimmertemperatur" gegen "H Vollmilch" kommt auf etwa 40 Punkte, weil das lange Beiwort den Durchschnitt zieht - offensichtlich dasselbe Produkt. "Mehl" gegen "Muehlen Geschnetzeltes" kam dagegen ueber 60. Folge, die so gewollt ist: Wo nichts passt, steht jetzt "nicht zugeordnet" und gar kein Vorschlag mehr. Ein Vorschlag, den niemand nachvollziehen kann, ist schlimmer als keiner - er verleitet zum Durchklicken. Die dauerhafte Loesung fuer echte Luecken ist die gemerkte Zuordnung ("Mealie-Lebensmittel X ist bei uns Y"), die noch aussteht. Co-Authored-By: Claude Opus 5 --- backend/app/services/rezepte.py | 125 +++++++++++++++++++++++--- backend/tests/test_rezept_abgleich.py | 87 +++++++++++++++++- 2 files changed, 196 insertions(+), 16 deletions(-) diff --git a/backend/app/services/rezepte.py b/backend/app/services/rezepte.py index 8a46fc6..39051be 100644 --- a/backend/app/services/rezepte.py +++ b/backend/app/services/rezepte.py @@ -20,15 +20,18 @@ from dataclasses import dataclass, field from sqlalchemy.orm import Session -from ..models import Group, Product +from ..models import Category, CategoryTracking, Group, Product from .conversion import ConversionError, article_unit, group_min_context, to_base from .matching import match_score, normalisieren from .stock import current_stock, summe_bestand_gewichtet -# Ab hier gilt ein Name als Treffer. Bewusst strenger als beim Kassenzettel: -# Dort sieht ein Mensch die Vorschläge durch, hier soll nicht stillschweigend -# das Falsche als „vorhanden" gelten. -SCHWELLE = 60 +# Gesperrt wird über den Wortanker (siehe ``_hat_anker``), nicht über eine +# Punktzahl. Eine Ähnlichkeitszahl ist als Sperre untauglich: „Vollmilch +# Zimmertemperatur" gegen „H Vollmilch" landet nur bei etwa 40 Punkten, weil das +# lange Beiwort den Durchschnitt zieht – obwohl es offensichtlich dasselbe ist. +# Umgekehrt kam „Mehl" auf über 60 gegen „Mühlen Geschnetzeltes". Die Punkte +# entscheiden deshalb nur noch die Reihenfolge unter den Kandidaten, die den +# Anker schon bestanden haben. @dataclass @@ -109,18 +112,114 @@ def faktor_fuer_portionen(rezept: dict, portionen: float | None) -> tuple[float, return portionen / basis, "" +# Umlaut-Reste zusammenziehen, damit „Apfel" auch „Äpfel" (= „aepfel") findet. +# Nur fuer den Wortvergleich, nie fuer die Anzeige. +_UMLAUT_REST = str.maketrans({"a": "a"}) + + +def _stamm(text: str) -> str: + roh = normalisieren(text) + 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. + + „1 TL" und „oder" sollen keine Treffer begründen; „Mehl", „Apfel", + „Vollmilch" sehr wohl. + """ + return [w for w in _stamm(text).split() if len(w) >= 4] + + +def _hat_anker(zutat_woerter: list[str], kandidat: str) -> bool: + """Teilen Zutat und Kandidat ein echtes Wort? + + Das ist die eigentliche Sperre. Reine Ähnlichkeitswerte halten „Eier" und + „Reis" oder „Mehl" und „Mühlen Geschnetzeltes" für verwandt, weil sich + Buchstaben überlappen – für einen Rezeptabgleich ist das unbrauchbar. + Verlangt wird deshalb, dass ein ganzes Wort im anderen Namen steckt: + „Mehl" in „Weissmehl Typ 550" ja, „Mehl" in „Mühlen Geschnetzeltes" nein. + """ + ziel = _stamm(kandidat) + if not ziel: + return False + ziel_woerter = [w for w in ziel.split() if len(w) >= 4] + for wort in zutat_woerter: + if wort in ziel: + return True + # Umgekehrt auch: Artikel „Milch" passt zur Zutat „Buttermilch". + for zw in ziel_woerter: + if zw in wort: + return True + return False + + +def _kategorien_zur_zutat(db: Session, zutat_woerter: list[str]) -> set[int]: + """Kategorien, deren Name ein Wort mit der Zutat teilt – samt Unterkategorien. + + Damit wird zuerst dort gesucht, wo es hingehört: „Mehl Typ 405" in „Mehl & + Zucker", nicht im ganzen Katalog. Erst wenn dort nichts passt, zählt der + Rest. + """ + from ..routers.categories import descendant_ids + + passende: set[int] = set() + for kategorie in db.query(Category).all(): + if _hat_anker(zutat_woerter, kategorie.name): + passende |= descendant_ids(db, kategorie.id) + return passende + + def _kandidaten(db: Session, name: str) -> list[Kandidat]: - """Artikel und Gruppen nach Namensähnlichkeit, bester zuerst.""" - if not normalisieren(name): + """Artikel und Gruppen, die zur Zutat passen können – bester zuerst. + + Drei Stufen, in dieser Reihenfolge: + + 1. **Nur Lebensmittel.** Gegenstände bleiben außen vor, wie beim + Kassenzettel-Abgleich. Ein Joystick ist kein Zucker, und eine + Waffelplatte kein Apfel. + 2. **Wortanker.** Ohne ein geteiltes ganzes Wort gibt es keinen Treffer, + egal wie hoch die Buchstabenähnlichkeit wäre. + 3. **Kategorie zuerst.** Passt der Zutatenname zu einer Kategorie, gilt nur + was darin liegt – sonst der ganze (gefilterte) Katalog. + """ + woerter = _woerter(name) + if not woerter: return [] + + # 1. Nur Lebensmittel, und keine Einzelstücke. + from ..crud import product_tracking + + lebensmittel = [ + p for p in db.query(Product).all() + if not p.individual and product_tracking(db, p) != CategoryTracking.object.value + ] + + # 3. Kategorie zuerst – aber nur, wenn darin auch etwas mit Anker liegt. + kategorien = _kategorien_zur_zutat(db, woerter) + if kategorien: + in_kategorie = [ + p for p in lebensmittel + if p.category_id in kategorien and _hat_anker(woerter, f"{p.name} {p.brand or ''}") + ] + if in_kategorie: + lebensmittel = in_kategorie + treffer: list[Kandidat] = [] for gruppe in db.query(Group).all(): - treffer.append(Kandidat("group", gruppe.id, gruppe.name, match_score(name, gruppe.name))) - for produkt in db.query(Product).all(): + if _hat_anker(woerter, gruppe.name): + treffer.append( + Kandidat("group", gruppe.id, gruppe.name, match_score(name, gruppe.name)) + ) + for produkt in lebensmittel: voll = f"{produkt.name} {produkt.brand or ''}".strip() - treffer.append(Kandidat("product", produkt.id, produkt.name, match_score(name, voll))) - treffer.sort(key=lambda k: (-k.score, k.name)) - return [k for k in treffer if k.score > 0][:5] + if _hat_anker(woerter, voll): + treffer.append(Kandidat("product", produkt.id, produkt.name, match_score(name, voll))) + + # Gruppen bei Gleichstand vor Artikeln: „Milch" ist markenuebergreifend + # gemeint, und genau das ist eine Gruppe. + treffer.sort(key=lambda k: (-k.score, 0 if k.art == "group" else 1, k.name)) + return treffer[:5] def _bestand(db: Session, art: str, ziel_id: int) -> tuple[float, str]: @@ -189,7 +288,7 @@ def pruefe_zutaten( else: pruefung.kandidaten = _kandidaten(db, name) bester = pruefung.kandidaten[0] if pruefung.kandidaten else None - if bester is not None and bester.score >= SCHWELLE: + if bester is not None: pruefung.art, pruefung.ziel_id = bester.art, bester.id if pruefung.ziel_id is None: diff --git a/backend/tests/test_rezept_abgleich.py b/backend/tests/test_rezept_abgleich.py index 626e70c..41619f5 100644 --- a/backend/tests/test_rezept_abgleich.py +++ b/backend/tests/test_rezept_abgleich.py @@ -184,7 +184,13 @@ def test_gemerkte_zuordnung_schlaegt_die_suche(db): assert zutat.ziel_name == "Dinkelmehl" -def test_schwacher_treffer_liefert_kandidaten_statt_einer_annahme(db): +def test_voellig_fremde_zutat_liefert_gar_keinen_vorschlag(db): + """Lieber nichts als Unsinn. + + Fruehere Fassung schlug hier „Weissmehl" vor, weil sich Buchstaben + ueberlappen. Ein Vorschlag, den niemand nachvollziehen kann, ist schlimmer + als keiner – er verleitet zum Durchklicken. + """ cat = _kategorie(db, "Trockenwaren") _artikel(db, "Weissmehl", cat, bestand=1000) @@ -192,8 +198,83 @@ def test_schwacher_treffer_liefert_kandidaten_statt_einer_annahme(db): assert zutat.status == "kein_treffer" assert zutat.ziel_id is None - # Vorschlaege gibt es trotzdem – daraus baut die Oberflaeche die Korrektur. - assert zutat.kandidaten + assert zutat.kandidaten == [] + + +# -------------------------------------------------------------------------- +# Was NICHT passen darf – die Faelle aus dem echten Katalog +# -------------------------------------------------------------------------- +def test_gegenstaende_kommen_nicht_in_frage(db): + """Ein Joystick ist kein Zucker und eine Waffelplatte kein Apfel.""" + essen = _kategorie(db, "Trockenwaren") + _artikel(db, "Weissmehl", essen, bestand=1000) + kram = Category(name="Elektronik", tracking=CategoryTracking.object.value) + db.add(kram) + db.commit() + db.refresh(kram) + for name in ("Joystick für WII", "Snack Collection Platten 4 (Waffeln)", + "Shirt Casual Fit"): + db.add(Product(name=name, base_unit=BaseUnit.piece, category_id=kram.id, + source="manual")) + db.commit() + + for zutat_name in ("Zucker", "Apfel", "Salz"): + [zutat] = pruefe_zutaten(db, [_zutat(zutat_name, 1, "g")]) + assert zutat.status == "kein_treffer", zutat_name + assert all("Joystick" not in k.name for k in zutat.kandidaten) + + +def test_mehl_trifft_nicht_das_geschnetzelte(db): + """Buchstaben-Aehnlichkeit allein reicht nicht – es braucht ein ganzes Wort.""" + fleisch = _kategorie(db, "Fleisch & Fisch") + _artikel(db, "Mühlen Geschnetzeltes", fleisch, bestand=500) + + [zutat] = pruefe_zutaten(db, [_zutat("Mehl Typ 405", 125, "g")]) + + assert zutat.status == "kein_treffer" + + +def test_eier_treffen_nicht_den_reis(db): + cat = _kategorie(db, "Trockenwaren") + _artikel(db, "Reis", cat, bestand=2000) + + [zutat] = pruefe_zutaten(db, [_zutat("Eier", 3, None)]) + + assert zutat.status == "kein_treffer" + + +def test_kategorie_wird_zuerst_durchsucht(db): + """Steht die Zutat in einer passenden Kategorie, gilt nur die.""" + fleisch = _kategorie(db, "Fleisch & Fisch") + _artikel(db, "Mühlen Geschnetzeltes", fleisch, bestand=500) + backen = _kategorie(db, "Mehl & Zucker") + richtig = _artikel(db, "Weissmehl Typ 405", backen, bestand=2000) + + [zutat] = pruefe_zutaten(db, [_zutat("Mehl Typ 405", 125, "g")]) + + assert zutat.ziel_id == richtig.id + assert zutat.status == "vorhanden" + + +def test_umlaut_und_mehrzahl_finden_sich(db): + """Rezept schreibt „Apfel", im Katalog stehen „Äpfel".""" + cat = _kategorie(db, "Obst & Gemüse") + aepfel = _artikel(db, "Äpfel", cat, base=BaseUnit.gram, bestand=2000) + + [zutat] = pruefe_zutaten(db, [_zutat("Apfel", 900, "g")]) + + assert zutat.ziel_id == aepfel.id + assert zutat.status == "vorhanden" + + +def test_vollmilch_findet_die_h_vollmilch(db): + cat = _kategorie(db, "Molkerei") + milch = _artikel(db, "H Vollmilch", cat, base=BaseUnit.milliliter, bestand=1000) + + [zutat] = pruefe_zutaten(db, [_zutat("Vollmilch Zimmertemperatur", 120, "ml")]) + + assert zutat.ziel_id == milch.id + assert zutat.status == "vorhanden" # --------------------------------------------------------------------------