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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-02 13:18:27 +02:00
co-authored by Claude Opus 5
parent 2247c8cf69
commit 4e34accee3
2 changed files with 196 additions and 16 deletions
+111 -12
View File
@@ -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()
if _hat_anker(woerter, voll):
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]
# 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:
+84 -3
View File
@@ -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"
# --------------------------------------------------------------------------