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>
This commit is contained in:
Scarriffle
2026-10-02 13:25:41 +02:00
co-authored by Claude Opus 5
parent 41d262cb2d
commit 2a56cca3c8
2 changed files with 116 additions and 9 deletions
+64 -8
View File
@@ -21,7 +21,16 @@ from dataclasses import dataclass, field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..models import Category, CategoryTracking, Group, Product from ..models import Category, CategoryTracking, Group, Product
from .conversion import ConversionError, article_unit, group_min_context, to_base 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 .matching import match_score, normalisieren
from .stock import current_stock, summe_bestand_gewichtet from .stock import current_stock, summe_bestand_gewichtet
@@ -40,6 +49,9 @@ class Kandidat:
id: int id: int
name: str name: str
score: int 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 @dataclass
@@ -203,7 +215,37 @@ def _kategorien_zur_zutat(db: Session, zutat_woerter: list[str]) -> set[int]:
return passende return passende
def _kandidaten(db: Session, name: str) -> list[Kandidat]: 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. """Artikel und Gruppen, die zur Zutat passen können – bester zuerst.
Drei Stufen, in dieser Reihenfolge: Drei Stufen, in dieser Reihenfolge:
@@ -247,11 +289,15 @@ def _kandidaten(db: Session, name: str) -> list[Kandidat]:
for produkt in lebensmittel: for produkt in lebensmittel:
voll = f"{produkt.name} {produkt.brand or ''}".strip() voll = f"{produkt.name} {produkt.brand or ''}".strip()
if _hat_anker(woerter, voll): if _hat_anker(woerter, voll):
treffer.append(Kandidat("product", produkt.id, produkt.name, match_score(name, voll))) k = Kandidat("product", produkt.id, produkt.name, match_score(name, voll))
k.einheit_passt = _einheit_passt(db, produkt, einheit)
treffer.append(k)
# Gruppen bei Gleichstand vor Artikeln: „Milch" ist markenuebergreifend # Reihenfolge: passende Einheit zuerst, dann Punkte; Gruppen bei Gleichstand
# gemeint, und genau das ist eine Gruppe. # vor Artikeln, weil „Milch" markenuebergreifend gemeint ist.
treffer.sort(key=lambda k: (-k.score, 0 if k.art == "group" else 1, k.name)) 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] return treffer[:5]
@@ -319,13 +365,23 @@ def pruefe_zutaten(
if gemerkt is not None: if gemerkt is not None:
pruefung.art, pruefung.ziel_id = gemerkt pruefung.art, pruefung.ziel_id = gemerkt
else: else:
pruefung.kandidaten = _kandidaten(db, name) pruefung.kandidaten = _kandidaten(db, name, pruefung.einheit)
bester = pruefung.kandidaten[0] if pruefung.kandidaten else None bester = pruefung.kandidaten[0] if pruefung.kandidaten else None
if bester is not 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 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: if pruefung.ziel_id is None:
pruefung.status = "kein_treffer" pruefung.status = "kein_treffer"
if not pruefung.hinweis:
pruefung.hinweis = f"'{name}' ist hier keinem Artikel zugeordnet." pruefung.hinweis = f"'{name}' ist hier keinem Artikel zugeordnet."
ergebnis.append(pruefung) ergebnis.append(pruefung)
continue continue
+51
View File
@@ -310,6 +310,57 @@ def test_beiwoerter_stoeren_den_echten_treffer_nicht(db):
assert zutat.ziel_id == aepfel.id assert zutat.ziel_id == aepfel.id
def test_falsche_einheit_spricht_gegen_den_artikel(db):
"""„200 ml Schlagsahne" ist kein „Kräuter Sahne Hähnchen"."""
fleisch = _kategorie(db, "Fleisch & Fisch")
_artikel(db, "Kräuter Sahne Hähnchen", fleisch, base=BaseUnit.gram, bestand=800)
[zutat] = pruefe_zutaten(db, [_zutat("Schlagsahne", 200, "ml")])
assert zutat.status == "kein_treffer"
assert "nicht in 'ml' geführt" in zutat.hinweis
def test_passende_einheit_gewinnt_gegen_hoehere_punktzahl(db):
molk = _kategorie(db, "Molkereiprodukte")
fleisch = _kategorie(db, "Fleisch & Fisch")
_artikel(db, "Sahne Hähnchen", fleisch, base=BaseUnit.gram, bestand=800)
richtig = _artikel(db, "Schlagsahne Rahm", molk, base=BaseUnit.milliliter, bestand=500)
[zutat] = pruefe_zutaten(db, [_zutat("Schlagsahne", 200, "ml")])
assert zutat.ziel_id == richtig.id
def test_fehlendes_hausmass_spricht_nicht_gegen_den_artikel(db):
"""Nur eine BEKANNTE Einheit falscher Art ist ein Hinweis.
„1 Tasse Linsen" heisst nicht, dass die Linsen falsch sind – es fehlt bloss
die Maßangabe, und die laesst sich nachtragen.
"""
cat = _kategorie(db, "Hülsenfrüchte")
linsen = _artikel(db, "Berglinsen", cat, bestand=1000)
[zutat] = pruefe_zutaten(db, [_zutat("Berglinsen", 1, "Tasse")])
assert zutat.ziel_id == linsen.id
assert zutat.status == "menge_unbekannt"
def test_zweiteinheit_laesst_die_fremde_einheit_zu(db):
cat = _kategorie(db, "Backwaren")
broetchen = _artikel(db, "Brötchen", cat, base=BaseUnit.piece, package_size=6,
bestand=12)
broetchen.secondary_base = BaseUnit.gram.value
broetchen.secondary_count = 1
broetchen.secondary_amount = 50
db.commit()
[zutat] = pruefe_zutaten(db, [_zutat("Brötchen", 100, "g")])
assert zutat.ziel_id == broetchen.id
def test_umlaut_und_mehrzahl_finden_sich(db): def test_umlaut_und_mehrzahl_finden_sich(db):
"""Rezept schreibt „Apfel", im Katalog stehen „Äpfel".""" """Rezept schreibt „Apfel", im Katalog stehen „Äpfel"."""
cat = _kategorie(db, "Obst & Gemüse") cat = _kategorie(db, "Obst & Gemüse")