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" # --------------------------------------------------------------------------