Files
Vorrania/backend/app/services/rezepte.py
T
ScarriffleandClaude Opus 5 4e34accee3 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>
2026-10-02 13:18:27 +02:00

368 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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