Files
ScarriffleandClaude Opus 5 fe6327c913 Rezept-Zuordnung: ein Eigenschaftswort frisst keinen Artikelnamen mehr
Gefunden bei der Probe, ob ein Embedding-Modell die Zuordnung besser
hinbekommt: Die Sperrliste der Eigenschaftswoerter lief als eine einzige
Liste mit Praefix-Vergleich. "Frischkaese" fing mit "frisch" an, galt
damit als blosse Eigenschaft und fiel aus dem Vergleich - die Zutat
"Kaese" fand den Frischkaese deshalb NIE. Ebenso "Biomilch" (bio),
"Rohrzucker" (roh), "Kleingebaeck" (klein), "Ganzkornbrot" (ganz),
"Grobgriess" (grob).

Unsichtbar, weil kein falscher Treffer entstand, sondern ein richtiger
ausblieb - die Zutat stand einfach als "nicht zugeordnet" da.

Jetzt zwei Toepfe statt einem. Stammformen wie "getrock" oder "vegan"
vergleichen weiter ueber den Wortanfang, weil Deutsch beugt und diese
Stuecke keine Lebensmittelnamen anfangen. Kurze Alltagswoerter wie
"frisch", "bio", "roh", "klein" zaehlen nur noch als Eigenschaft, wenn
hoechstens eine Beugungsendung dranhaengt: "frische" ja, "frischkase"
nein.

Gegenprobe im Test: "frische Hefe" findet die "Frischen Kraeuter"
weiterhin nicht - die Sperre ist praeziser geworden, nicht schwaecher.

7 neue Tests, Suite bei 494.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 14:01:56 +02:00

491 lines
19 KiB
Python
Raw Permalink 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 = ""
# Der reine Lebensmittelname aus Mealie – Schluessel fuer die gemerkte
# Zuordnung von Hand (``services/zuordnung.py``).
food: str = ""
# True = die Zuordnung kommt aus einer gemerkten Entscheidung, nicht vom Raten.
manuell: bool = False
# 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.
#
# Verglichen wird gegen die zusammengezogene Form aus ``_stamm`` (ae/oe/ue schon
# aufgelöst) - die Listen unten stehen deshalb ebenfalls in dieser Form.
#
# Zwei Toepfe, weil es zwei Sorten Wort sind. Die Trennung ist nicht Kosmetik:
# Als EINE Liste mit Praefix-Vergleich hat sie ganze Artikelnamen verschluckt.
# 'Frischkaese' begann mit 'frisch', galt damit als blosse Eigenschaft und fiel
# aus dem Vergleich - die Zutat 'Kaese' fand den Frischkaese deshalb NIE. Ebenso
# 'Biomilch' (bio), 'Rohrzucker' (roh), 'Kleingebaeck' (klein), 'Ganzkornbrot'
# (ganz). Der Fehler war unsichtbar: kein falscher Treffer, nur ausbleibende
# richtige.
#
# 1. Stammformen, die in JEDER Beugung gemeint sind. Praefix-Vergleich, weil
# Deutsch beugt: 'getrock' deckt getrocknet/getrocknete/getrocknetes ab.
# Diese Stuecke fangen keine Komposita an, die ein Lebensmittel benennen.
_EIGENSCHAFT_STAMM = (
"vegan", "vegetar", "biologisch",
"gekocht", "gebacken", "geschmolz", "gesalz", "ungesalz", "gezuckert",
"getrock", "gefror", "gerieb", "gewurf", "geschnitt", "geschal", "gehackt",
"gemahl", "gepresst", "zimmertemperatur", "lauwarm", "gehauft", "gestrich",
"optional",
)
# 2. Kurze Alltagswoerter, die AUCH am Anfang echter Namen stehen. Hier zaehlt
# nur das Wort selbst, hoechstens mit einer Beugungsendung dran: 'frische'
# ja, 'frischkase' nein.
_EIGENSCHAFT_WORT = (
"bio", "frisch", "roh", "klein", "gross", "mittel", "fein", "grob", "ganz",
"oder", "sowie", "etwa", "circa", "nach", "ohne", "evtl",
"block", "stueck", "stuck",
)
# Deutsche Beugungsendungen. Was laenger ist, ist keine Endung mehr, sondern ein
# zweites Wort - und genau das soll den Namen retten.
_BEUGUNG = ("", "e", "er", "es", "en", "em", "ene", "enes", "ener", "enen")
def _nur_eigenschaft(wort: str) -> bool:
if wort.isdigit() or wort.startswith(_EIGENSCHAFT_STAMM):
return True
return any(
wort.startswith(w) and wort[len(w):] in _BEUGUNG
for w in _EIGENSCHAFT_WORT
)
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.food = name
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
pruefung.manuell = True
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