From 67bc2dd87d6afd4f8066acf2b582571705a4e774 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 27 Jul 2026 07:06:12 +0200 Subject: [PATCH] =?UTF-8?q?Backend:=20Preiserkennung=20pr=C3=A4ziser=20(ni?= =?UTF-8?q?cht=20mehr=20jede=20Zahl)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nur noch Beträge, die nach Preis aussehen: direkt hinter einem Summen-Stichwort oder mit Währung (CHF/EUR/€/…) in der Nähe. Ohne solchen Hinweis kein Vorschlag. Datumsangaben (31.12.2027) werden per Lookaround nicht mehr als Betrag (31.12) missgelesen. Kandidatenliste enthaelt entsprechend nur plausible Preise. Co-Authored-By: Claude Opus 4.8 --- backend/app/services/warranty.py | 74 +++++++++++++++++++++----------- backend/tests/test_warranty.py | 18 ++++++-- 2 files changed, 63 insertions(+), 29 deletions(-) diff --git a/backend/app/services/warranty.py b/backend/app/services/warranty.py index 4f6b3fc..a40c4a6 100644 --- a/backend/app/services/warranty.py +++ b/backend/app/services/warranty.py @@ -134,8 +134,13 @@ _TOTAL_KW = re.compile( r"zu zahlen|amount due|grand total|total due", re.IGNORECASE, ) -# Beträge mit 2 Nachkommastellen, mit/ohne Tausendertrenner: 1'299.00 / 1.299,00 / 49,90 -_AMOUNT_RE = re.compile(r"\d{1,3}(?:[.\s']\d{3})+[.,]\d{2}|\d+[.,]\d{2}") +_CURRENCY_RE = re.compile(r"chf|eur|sfr|fr\.|rp\.|€|\$", re.IGNORECASE) +# Beträge mit genau 2 Nachkommastellen, mit/ohne Tausendertrenner +# (1'299.00 / 1.299,00 / 49,90). Die Lookarounds verhindern Treffer mitten in +# längeren Zahlen und in Datumsangaben (z.B. "31.12.2027" liefert kein "31.12"). +_AMOUNT_RE = re.compile( + r"(? int | None: @@ -151,51 +156,70 @@ def _amount_to_cents(raw: str) -> int | None: return int(ganz) * 100 + int(dezimal) +def _has_currency_near(text: str, start: int, end: int, window: int = 6) -> bool: + return bool(_CURRENCY_RE.search(text[max(0, start - window): end + window])) + + +def _near_total(text: str, pos: int) -> bool: + """Steht der Betrag kurz hinter einem Summen-Stichwort?""" + for kw in _TOTAL_KW.finditer(text): + if kw.start() <= pos <= kw.end() + 40: + return True + return False + + +def _price_hits(text: str) -> list[tuple[int, int, bool]]: + """(Position, Betrag in Cent, Währung in der Nähe?) für alle Beträge.""" + hits: list[tuple[int, int, bool]] = [] + for m in _AMOUNT_RE.finditer(text): + c = _amount_to_cents(m.group()) + if c: + hits.append((m.start(), c, _has_currency_near(text, m.start(), m.end()))) + return hits + + def guess_price_cents(text: str) -> int | None: """Kaufpreis in Rappen/Cent aus dem Beleg oder ``None``. - Bevorzugt einen Betrag nahe einem Summen-Stichwort; sonst den größten Betrag - (die Gesamtsumme ist auf Rechnungen meist der höchste Wert). + Bewusst zurückhaltend, damit nicht irgendeine Zahl gewinnt: + 1. Betrag direkt nach einem Summen-Stichwort (Gesamtbetrag/Total/…). + 2. Sonst der größte Betrag MIT Währungszeichen (CHF/EUR/€/…) in der Nähe. + 3. Ohne Hinweis (weder Summe noch Währung) lieber kein Vorschlag. """ if not text: return None - betraege = [ - (m.start(), _amount_to_cents(m.group())) for m in _AMOUNT_RE.finditer(text) - ] - betraege = [(pos, c) for pos, c in betraege if c] - if not betraege: + hits = _price_hits(text) + if not hits: return None - # Die Summe steht hinter ihrem Label ("Gesamtbetrag: 54.90") – deshalb den - # nächsten Betrag *nach* dem Stichwort nehmen, nicht den absolut nächsten - # (sonst gewönne eine davor stehende Zwischenzeile). best: int | None = None best_dist = 10**9 for kw in _TOTAL_KW.finditer(text): - for pos, c in betraege: + for pos, c, _cur in hits: if pos >= kw.start(): dist = pos - kw.start() if dist <= 40 and dist < best_dist: best, best_dist = c, dist - return best if best is not None else max(c for _, c in betraege) + if best is not None: + return best + mit_waehrung = [c for _pos, c, cur in hits if cur] + return max(mit_waehrung) if mit_waehrung else None def guess_price_candidates(text: str) -> list[int]: - """Alle plausiblen Beträge aus dem Beleg (in Rappen/Cent), ohne Dubletten. + """Plausible Beträge (Rappen/Cent), bester zuerst, ohne Dubletten. - Der beste Tipp (:func:`guess_price_cents`) steht vorne; danach die übrigen - Beträge absteigend. So kann die Oberfläche eine Auswahl anbieten, falls der - automatische Tipp danebenliegt. + Nur Beträge, die nach Preis aussehen – d.h. mit Währung in der Nähe oder kurz + hinter einem Summen-Stichwort. Beliebige Zahlen (Mengen, Artikelnummern, + Datumsteile) fliegen raus. So bleibt die Auswahl kurz und brauchbar. """ if not text: return [] - einzigartig = set() - for m in _AMOUNT_RE.finditer(text): - c = _amount_to_cents(m.group()) - if c: - einzigartig.add(c) - if not einzigartig: + plausibel = { + c for pos, c, cur in _price_hits(text) if cur or _near_total(text, pos) + } + if not plausibel: return [] - kandidaten = sorted(einzigartig, reverse=True) + kandidaten = sorted(plausibel, reverse=True) best = guess_price_cents(text) if best is not None and best in kandidaten: kandidaten.remove(best) diff --git a/backend/tests/test_warranty.py b/backend/tests/test_warranty.py index dc98796..ea7d6ac 100644 --- a/backend/tests/test_warranty.py +++ b/backend/tests/test_warranty.py @@ -60,19 +60,29 @@ def test_preis_deutsches_format(): assert guess_price_cents(text) == 129900 -def test_preis_ohne_stichwort_nimmt_groessten_betrag(): - text = "Position A 12,90\nPosition B 199,00\nDanke." +def test_preis_ohne_waehrung_und_ohne_stichwort_ist_none(): + # Reine Zahlen ohne Waehrung/Summen-Stichwort werden NICHT als Preis geraten. + assert guess_price_cents("Position A 12,90\nPosition B 199,00\nDanke.") is None + + +def test_preis_mit_waehrung_ohne_stichwort_nimmt_groessten(): + text = "Kabel 12.90 CHF\nGeraet 199.00 CHF" assert guess_price_cents(text) == 19900 +def test_datum_wird_nicht_als_preis_gelesen(): + assert guess_price_cents("Garantie gültig bis 31.12.2027.") is None + assert guess_price_candidates("Kaufdatum 05.06.2024, Garantie bis 31.12.2027") == [] + + def test_preis_ohne_betrag_ist_none(): assert guess_price_cents("Kein Preis hier.") is None assert guess_price_cents("") is None def test_preis_kandidaten_bester_zuerst_ohne_dubletten(): - text = "Artikel 49.00\nVersand 5.90\nGesamtbetrag: 54.90\nnochmal 49.00" + text = "Artikel 49.00 CHF\nVersand 5.90 CHF\nGesamtbetrag: 54.90 CHF\nnochmal 49.00 CHF" kandidaten = guess_price_candidates(text) - assert kandidaten[0] == 5490 # bester Tipp vorne + assert kandidaten[0] == 5490 # bester Tipp (Summe) vorne assert set(kandidaten) == {5490, 4900, 590} # ohne Dubletten assert guess_price_candidates("") == []