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 ..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 .stock import current_stock, summe_bestand_gewichtet
@@ -40,6 +49,9 @@ class Kandidat:
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
@@ -203,7 +215,37 @@ def _kategorien_zur_zutat(db: Session, zutat_woerter: list[str]) -> set[int]:
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.
Drei Stufen, in dieser Reihenfolge:
@@ -247,11 +289,15 @@ def _kandidaten(db: Session, name: str) -> list[Kandidat]:
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)))
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
# gemeint, und genau das ist eine Gruppe.
treffer.sort(key=lambda k: (-k.score, 0 if k.art == "group" else 1, k.name))
# 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]
@@ -319,13 +365,23 @@ def pruefe_zutaten(
if gemerkt is not None:
pruefung.art, pruefung.ziel_id = gemerkt
else:
pruefung.kandidaten = _kandidaten(db, name)
pruefung.kandidaten = _kandidaten(db, name, pruefung.einheit)
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
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
+51
View File
@@ -310,6 +310,57 @@ def test_beiwoerter_stoeren_den_echten_treffer_nicht(db):
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):
"""Rezept schreibt „Apfel", im Katalog stehen „Äpfel"."""
cat = _kategorie(db, "Obst & Gemüse")