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>
368 lines
14 KiB
Python
368 lines
14 KiB
Python
"""Rezeptzutaten gegen den Bestand prüfen: Kann ich das kochen?
|
||
|
||
Bewusst **ohne HTTP** – die Zutaten kommen als Wörterbücher herein, so wie
|
||
Mealie sie liefert. Dadurch lässt sich der ganze Abgleich mit handgeschriebenen
|
||
Zutaten prüfen, ohne eine Mealie-Instanz zu brauchen; das Netz steckt allein in
|
||
``services/mealie.py``.
|
||
|
||
Je Zutat beantwortet das hier drei Fragen nacheinander:
|
||
1. **Welcher Artikel ist gemeint?** Erst eine gemerkte Zuordnung, sonst unscharf
|
||
über Namen – gegen Gruppen und Artikel, denn „Milch" ist markenübergreifend
|
||
gemeint und das ist genau, wofür es Gruppen gibt.
|
||
2. **Wie viel ist das in Gramm/Milliliter/Stück?** Über die Einheiten und die
|
||
Hausmaße der Kategorie („2 EL Mehl").
|
||
3. **Reicht der Bestand?**
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
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
|
||
|
||
# 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
|
||
class Kandidat:
|
||
art: str # "product" | "group"
|
||
id: int
|
||
name: str
|
||
score: int
|
||
|
||
|
||
@dataclass
|
||
class ZutatPruefung:
|
||
"""Eine Zutat des Rezepts, auf den Bestand bezogen."""
|
||
|
||
text: str # wie das Rezept es schreibt
|
||
menge: float | None = None # skalierte Menge in der Rezept-Einheit
|
||
einheit: str = ""
|
||
# Zuordnung
|
||
art: str | None = None # "product" | "group"
|
||
ziel_id: int | None = None
|
||
ziel_name: str = ""
|
||
kandidaten: list[Kandidat] = field(default_factory=list)
|
||
# Mengen in Basiseinheiten
|
||
benoetigt_base: float | None = None
|
||
bestand_base: float | None = None
|
||
fehlt_base: float | None = None
|
||
basis_einheit: str = ""
|
||
# vorhanden | zu_wenig | fehlt | menge_unbekannt | kein_treffer
|
||
status: str = "kein_treffer"
|
||
hinweis: str = ""
|
||
# Für „Maß jetzt anlegen": welches Maß fehlt und für welche Kategorie.
|
||
fehlendes_mass: str = ""
|
||
kategorie_id: int | None = None
|
||
|
||
|
||
def _zutat_text(zutat: dict) -> str:
|
||
"""Wie das Rezept die Zutat schreibt – für die Anzeige."""
|
||
for schluessel in ("display", "originalText", "original_text", "note"):
|
||
wert = zutat.get(schluessel)
|
||
if wert:
|
||
return str(wert)
|
||
essen = zutat.get("food") or {}
|
||
return str(essen.get("name") or "Zutat")
|
||
|
||
|
||
def _lebensmittel(zutat: dict) -> str:
|
||
essen = zutat.get("food") or {}
|
||
name = essen.get("name") or ""
|
||
if name:
|
||
return str(name)
|
||
# Ohne verknüpftes Lebensmittel bleibt nur der freie Text („2 Eier").
|
||
return str(zutat.get("note") or zutat.get("display") or "")
|
||
|
||
|
||
def _einheit(zutat: dict) -> str:
|
||
einheit = zutat.get("unit") or {}
|
||
if not isinstance(einheit, dict):
|
||
return ""
|
||
return str(einheit.get("abbreviation") or einheit.get("name") or "")
|
||
|
||
|
||
def faktor_fuer_portionen(rezept: dict, portionen: float | None) -> tuple[float, str]:
|
||
"""Skalierungsfaktor und, falls nötig, ein Hinweis warum nicht skaliert wird."""
|
||
if not portionen or portionen <= 0:
|
||
return 1.0, ""
|
||
basis = rezept.get("recipeServings") or rezept.get("recipe_servings") or 0
|
||
if not basis:
|
||
basis = rezept.get("recipeYieldQuantity") or rezept.get("recipe_yield_quantity") or 0
|
||
try:
|
||
basis = float(basis)
|
||
except (TypeError, ValueError):
|
||
basis = 0.0
|
||
if basis <= 0:
|
||
return 1.0, (
|
||
"Das Rezept nennt keine Portionszahl – die Mengen gelten deshalb "
|
||
"unverändert."
|
||
)
|
||
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, 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():
|
||
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)))
|
||
|
||
# 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]:
|
||
"""Bestand in Basiseinheiten plus die Bezeichnung der Basiseinheit."""
|
||
if art == "product":
|
||
produkt = db.get(Product, ziel_id)
|
||
if produkt is None:
|
||
return 0.0, ""
|
||
return current_stock(db, produkt.id), produkt.base_unit.value
|
||
gruppe = db.get(Group, ziel_id)
|
||
if gruppe is None:
|
||
return 0.0, ""
|
||
ctx = group_min_context(gruppe)
|
||
menge = summe_bestand_gewichtet(db, ctx.matching, ctx.faktoren)
|
||
return menge, (ctx.base_unit.value if ctx.base_unit else "")
|
||
|
||
|
||
def _referenzartikel(db: Session, art: str, ziel_id: int) -> Product | None:
|
||
"""Der Artikel, dessen Kategorie die Hausmaße liefert.
|
||
|
||
Bei einer Gruppe der erste zugehörige Artikel – „1 Tasse Milch" hängt an der
|
||
Kategorie der Milch, nicht an der Gruppe selbst.
|
||
"""
|
||
if art == "product":
|
||
return db.get(Product, ziel_id)
|
||
gruppe = db.get(Group, ziel_id)
|
||
if gruppe is None:
|
||
return None
|
||
ctx = group_min_context(gruppe)
|
||
return ctx.matching[0] if ctx.matching else None
|
||
|
||
|
||
def pruefe_zutaten(
|
||
db: Session,
|
||
zutaten: list[dict],
|
||
*,
|
||
faktor: float = 1.0,
|
||
zuordnung: dict[str, tuple[str, int]] | None = None,
|
||
) -> list[ZutatPruefung]:
|
||
"""Alle Zutaten eines Rezepts prüfen.
|
||
|
||
``zuordnung`` sind gemerkte Entscheidungen („Mealie-Lebensmittel X ist bei
|
||
uns Gruppe Y"), Schlüssel ist der normalisierte Name.
|
||
"""
|
||
zuordnung = zuordnung or {}
|
||
ergebnis: list[ZutatPruefung] = []
|
||
|
||
for zutat in zutaten:
|
||
pruefung = ZutatPruefung(text=_zutat_text(zutat))
|
||
name = _lebensmittel(zutat)
|
||
pruefung.einheit = _einheit(zutat)
|
||
|
||
rohmenge = zutat.get("quantity")
|
||
if zutat.get("disableAmount") or zutat.get("disable_amount") or not rohmenge:
|
||
pruefung.menge = None
|
||
else:
|
||
try:
|
||
pruefung.menge = float(rohmenge) * faktor
|
||
except (TypeError, ValueError):
|
||
pruefung.menge = None
|
||
|
||
# 1. Zuordnen
|
||
gemerkt = zuordnung.get(normalisieren(name))
|
||
if gemerkt is not None:
|
||
pruefung.art, pruefung.ziel_id = gemerkt
|
||
else:
|
||
pruefung.kandidaten = _kandidaten(db, name)
|
||
bester = pruefung.kandidaten[0] if pruefung.kandidaten else None
|
||
if bester is not None:
|
||
pruefung.art, pruefung.ziel_id = bester.art, bester.id
|
||
|
||
if pruefung.ziel_id is None:
|
||
pruefung.status = "kein_treffer"
|
||
pruefung.hinweis = f"'{name}' ist hier keinem Artikel zugeordnet."
|
||
ergebnis.append(pruefung)
|
||
continue
|
||
|
||
referenz = _referenzartikel(db, pruefung.art or "product", pruefung.ziel_id)
|
||
pruefung.ziel_name = (
|
||
referenz.name if pruefung.art == "product" and referenz is not None
|
||
else (db.get(Group, pruefung.ziel_id).name if pruefung.art == "group" else "")
|
||
)
|
||
if referenz is not None:
|
||
pruefung.kategorie_id = referenz.category_id
|
||
|
||
bestand, basis = _bestand(db, pruefung.art or "product", pruefung.ziel_id)
|
||
pruefung.bestand_base = bestand
|
||
pruefung.basis_einheit = basis
|
||
|
||
# 2. Menge umrechnen
|
||
if pruefung.menge is None:
|
||
pruefung.status = "menge_unbekannt"
|
||
pruefung.hinweis = "Das Rezept nennt keine Menge."
|
||
elif referenz is None:
|
||
pruefung.status = "menge_unbekannt"
|
||
pruefung.hinweis = "Zu dieser Gruppe gibt es noch keinen Artikel."
|
||
else:
|
||
try:
|
||
pruefung.benoetigt_base = to_base(
|
||
db, referenz, pruefung.menge,
|
||
pruefung.einheit or referenz.base_unit.value,
|
||
hausmasse=True,
|
||
)
|
||
except ConversionError as fehler:
|
||
pruefung.status = "menge_unbekannt"
|
||
pruefung.hinweis = str(fehler)
|
||
if "kein Maß" in str(fehler):
|
||
pruefung.fehlendes_mass = pruefung.einheit
|
||
ergebnis.append(pruefung)
|
||
continue
|
||
|
||
# 3. Vergleichen
|
||
if pruefung.benoetigt_base is not None:
|
||
fehlt = pruefung.benoetigt_base - (pruefung.bestand_base or 0.0)
|
||
pruefung.fehlt_base = max(0.0, fehlt)
|
||
if fehlt <= 1e-9:
|
||
pruefung.status = "vorhanden"
|
||
elif (pruefung.bestand_base or 0.0) > 1e-9:
|
||
pruefung.status = "zu_wenig"
|
||
else:
|
||
pruefung.status = "fehlt"
|
||
elif pruefung.status == "menge_unbekannt" and (pruefung.bestand_base or 0.0) <= 1e-9:
|
||
# Ohne Menge lässt sich nur sagen, ob überhaupt etwas da ist.
|
||
pruefung.status = "fehlt"
|
||
pruefung.hinweis = (pruefung.hinweis + " Vorrätig ist davon nichts.").strip()
|
||
|
||
ergebnis.append(pruefung)
|
||
|
||
return ergebnis
|
||
|
||
|
||
def kochbar(pruefungen: list[ZutatPruefung]) -> bool:
|
||
"""Reicht alles? Unklare Mengen zählen bewusst als „nicht sicher"."""
|
||
return all(p.status == "vorhanden" for p in pruefungen)
|
||
|
||
|
||
def artikeleinheiten(db: Session, pruefung: ZutatPruefung) -> tuple[float, str]:
|
||
"""Fehlmenge als zählbares Gebinde („2 Packungen") statt in Gramm."""
|
||
if pruefung.fehlt_base is None or pruefung.art != "product" or pruefung.ziel_id is None:
|
||
return 0.0, ""
|
||
produkt = db.get(Product, pruefung.ziel_id)
|
||
if produkt is None:
|
||
return 0.0, ""
|
||
faktor, label = article_unit(produkt)
|
||
return (pruefung.fehlt_base / (faktor or 1.0)), label
|