Der Wortanker nahm jedes geteilte Wort ab vier Zeichen - also auch
"vegan". Damit passte jedes vegane Produkt auf jede vegane Zutat: "vegane
Butter" landete auf "veganes Muehlen Knusper Bleu", "veganer griechischer
Joghurt" auf "Vegane Muehlen Nuggets" und "veganer Lime Curd" auf "Vegane
Muehlen-Schnitzel auf Basis von Soja". Geteilt wurde dort nur die
Eigenschaft, nicht die Sache.
Beschreibende Woerter begruenden deshalb keinen Treffer mehr - vegan,
vegetarisch, bio, frisch, geschmolzen, ungesalzen, geschaelt, geschnitten,
Zimmertemperatur, oder, optional und so weiter, dazu reine Zahlen. Als
Wortanfaenge aufgefuehrt, weil Deutsch beugt ("vegan", "vegane",
"veganer", "veganes"). Sie sperren aber nicht: "vegane Butter" findet
weiterhin eine "Vegane Butter", nur eben ueber das Wort "Butter".
Wichtig war, die Liste auf BEIDEN Seiten anzuwenden. Sonst steckt "vegan"
weiter im Produktnamen und der Teilstring-Vergleich greift von dort aus.
Gegenprobe mit dem echten Katalog: Von zehn Zutaten bleiben die drei
richtigen Treffer (Mehl, Vollmilch, Aepfel), die sieben erfundenen sind
weg.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
401 lines
15 KiB
Python
401 lines
15 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")
|
||
|
||
|
||
# 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 _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
|