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:
co-authored by
Claude Opus 5
parent
2247c8cf69
commit
4e34accee3
+112
-13
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user