"200 ml Schlagsahne" landete auf "Kraeuter Sahne Haehnchen" - beide teilen
das Wort "Sahne", der Rest ist Zufall. Die Einheit haette es verraten: Das
Haehnchen wird in Gramm gefuehrt, das Rezept sagt Milliliter. Wo sich die
Rezept-Einheit gar nicht umrechnen laesst, wird deshalb nicht mehr
zugeordnet, sondern offen gelassen - lieber "nichts Passendes gefunden"
samt Begruendung als eine falsche Behauptung.
Entscheidend ist, WARUM die Umrechnung scheitert, und nur einer der Gruende
sagt etwas ueber den Artikel:
- Bekannte Einheit falscher Art ohne Zweiteinheit -> spricht gegen den
Artikel. Milliliter an einem Gramm-Produkt ist ein anderes Produkt.
- Unbekanntes Wort oder nicht hinterlegtes Hausmass ("1 Tasse Linsen") ->
sagt gar nichts. Die Linsen koennen voellig richtig sein, es fehlt bloss
die Massangabe, und die laesst sich nachtragen.
- Gar keine Einheit ("3 Eier") -> passt immer.
Die erste Fassung hat das nicht unterschieden und damit "1 Tasse Linsen"
zu "nicht zugeordnet" gemacht, obwohl der Artikel stimmte - die beiden
bestehenden Tests haben das sofort gezeigt.
Bei mehreren Kandidaten gewinnt jetzt der mit passender Einheit, auch gegen
eine hoehere Namensaehnlichkeit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
457 lines
18 KiB
Python
457 lines
18 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 (
|
||
BASE_OF_KIND,
|
||
ConversionError,
|
||
article_unit,
|
||
find_unit,
|
||
group_min_context,
|
||
kind_of_product,
|
||
to_base,
|
||
zweit_faktor,
|
||
)
|
||
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
|
||
# Laesst sich die Rezept-Einheit fuer diesen Kandidaten umrechnen? Passt sie
|
||
# nicht, ist es meist das falsche Produkt (siehe ``_einheit_passt``).
|
||
einheit_passt: bool = True
|
||
|
||
|
||
@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")
|
||
|
||
|
||
# Wörter, die eine Zutat BESCHREIBEN, aber nicht benennen. Sie dürfen allein
|
||
# keinen Treffer begründen, sonst passt jedes vegane Produkt auf jede vegane
|
||
# Zutat: „vegane Butter" landete so auf „veganes Mühlen Knusper Bleu", und
|
||
# „veganer Lime Curd" auf „Vegane Mühlen-Schnitzel". Geteilt wird dort nur die
|
||
# Eigenschaft, nicht die Sache.
|
||
#
|
||
# Als Wortanfänge aufgeführt, weil Deutsch beugt: „vegan", „vegane", „veganer",
|
||
# „veganes" sollen alle gemeint sein. Verglichen wird gegen die zusammengezogene
|
||
# Form aus ``_stamm`` (ae/oe/ue schon aufgelöst).
|
||
_NUR_EIGENSCHAFT = (
|
||
"vegan", "vegetar", "bio", "biologisch", "frisch", "roh",
|
||
"gekocht", "gebacken", "geschmolz", "gesalz", "ungesalz", "gezuckert",
|
||
"getrock", "gefror", "gerieb", "gewurf", "geschnitt", "geschal", "gehackt",
|
||
"gemahl", "gepresst", "zimmertemperatur", "lauwarm",
|
||
"klein", "gross", "mittel", "fein", "grob", "ganz",
|
||
"oder", "sowie", "etwa", "circa", "nach", "ohne", "optional", "evtl",
|
||
"block", "stueck", "stuck", "gehauft", "gestrich",
|
||
)
|
||
|
||
|
||
def _nur_eigenschaft(wort: str) -> bool:
|
||
return wort.isdigit() or wort.startswith(_NUR_EIGENSCHAFT)
|
||
|
||
|
||
def _woerter(text: str) -> list[str]:
|
||
"""Bedeutungstragende Wörter einer Zutat.
|
||
|
||
Weg fallen sehr kurze („1 TL"), reine Zahlen und alles, was die Zutat nur
|
||
beschreibt (siehe ``_NUR_EIGENSCHAFT``). Übrig bleibt, was sie IST: „Mehl",
|
||
„Apfel", „Butter", „Joghurt".
|
||
"""
|
||
return [
|
||
w for w in _stamm(text).split()
|
||
if len(w) >= 4 and not _nur_eigenschaft(w)
|
||
]
|
||
|
||
|
||
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 and not _nur_eigenschaft(w)
|
||
]
|
||
# Auch der Teilstring-Vergleich darf nicht über eine Eigenschaft laufen:
|
||
# „vegan" steckt sonst weiter in „veganes Mühlen Knusper Bleu".
|
||
ziel_ohne = " ".join(ziel_woerter)
|
||
for wort in zutat_woerter:
|
||
if wort in ziel_ohne:
|
||
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 _einheit_passt(db: Session, produkt: Product, token: str) -> bool:
|
||
"""Lässt sich die Rezept-Einheit für diesen Artikel überhaupt umrechnen?
|
||
|
||
Das ist ein überraschend gutes Zeichen für „falsches Produkt": Sagt das
|
||
Rezept Milliliter und der Kandidat wird in Gramm geführt, ist es meist
|
||
etwas anderes – „200 ml Schlagsahne" ist kein „Kräuter Sahne Hähnchen".
|
||
Eine Zweiteinheit am Artikel („3 Stück ≙ 250 g") gilt weiterhin als
|
||
passend, dafür ist sie da.
|
||
|
||
Entscheidend ist, WARUM eine Umrechnung scheitert – nur einer der Gründe
|
||
sagt etwas über den Artikel:
|
||
|
||
* **Bekannte Einheit falscher Art, ohne Brücke** → spricht gegen den
|
||
Artikel. Milliliter an einem Gramm-Produkt ist ein anderes Produkt.
|
||
* **Unbekanntes Wort oder nicht hinterlegtes Hausmaß** („1 Tasse Linsen")
|
||
→ sagt gar nichts. Der Artikel kann völlig richtig sein, es fehlt nur die
|
||
Maßangabe – die lässt sich nachtragen.
|
||
* **Keine Einheit** (etwa „3 Eier") → passt immer.
|
||
"""
|
||
if not token:
|
||
return True
|
||
unit = find_unit(db, token)
|
||
if unit is None:
|
||
# Unbekannt oder Hausmass: kein Urteil ueber den Artikel.
|
||
return True
|
||
if unit.kind == kind_of_product(produkt):
|
||
return True
|
||
return zweit_faktor(produkt, BASE_OF_KIND[unit.kind]) is not None
|
||
|
||
|
||
def _kandidaten(db: Session, name: str, einheit: 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):
|
||
k = Kandidat("product", produkt.id, produkt.name, match_score(name, voll))
|
||
k.einheit_passt = _einheit_passt(db, produkt, einheit)
|
||
treffer.append(k)
|
||
|
||
# Reihenfolge: passende Einheit zuerst, dann Punkte; Gruppen bei Gleichstand
|
||
# vor Artikeln, weil „Milch" markenuebergreifend gemeint ist.
|
||
treffer.sort(key=lambda k: (
|
||
0 if k.einheit_passt else 1, -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, pruefung.einheit)
|
||
bester = pruefung.kandidaten[0] if pruefung.kandidaten else None
|
||
# Passt die Einheit beim besten Kandidaten nicht, wird NICHT
|
||
# zugeordnet: Sagt das Rezept Milliliter und der Artikel wird in
|
||
# Gramm gefuehrt, ist es meist etwas anderes. Lieber offen lassen
|
||
# und von Hand zuordnen lassen, als etwas Falsches behaupten.
|
||
if bester is not None and bester.einheit_passt:
|
||
pruefung.art, pruefung.ziel_id = bester.art, bester.id
|
||
elif bester is not None:
|
||
pruefung.hinweis = (
|
||
f"Nichts Passendes gefunden: '{bester.name}' würde zum Namen "
|
||
f"passen, wird aber nicht in '{pruefung.einheit}' geführt."
|
||
)
|
||
|
||
if pruefung.ziel_id is None:
|
||
pruefung.status = "kein_treffer"
|
||
if not pruefung.hinweis:
|
||
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
|