Files
Vorrania/backend/app/services/rezepte.py
T
ScarriffleandClaude Opus 5 2a56cca3c8 Rezept-Zuordnung: die Einheit verraet das falsche Produkt
"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>
2026-10-02 13:25:41 +02:00

457 lines
18 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 (
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