Backend: Preiserkennung präziser (nicht mehr jede Zahl)

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-27 07:06:12 +02:00
co-authored by Claude Opus 4.8
parent d0d854be5a
commit 67bc2dd87d
2 changed files with 63 additions and 29 deletions
+49 -25
View File
@@ -134,8 +134,13 @@ _TOTAL_KW = re.compile(
r"zu zahlen|amount due|grand total|total due", r"zu zahlen|amount due|grand total|total due",
re.IGNORECASE, re.IGNORECASE,
) )
# Beträge mit 2 Nachkommastellen, mit/ohne Tausendertrenner: 1'299.00 / 1.299,00 / 49,90 _CURRENCY_RE = re.compile(r"chf|eur|sfr|fr\.|rp\.|€|\$", re.IGNORECASE)
_AMOUNT_RE = re.compile(r"\d{1,3}(?:[.\s']\d{3})+[.,]\d{2}|\d+[.,]\d{2}") # 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"(?<![\d.,])(?:\d{1,3}(?:[.\s']\d{3})+[.,]\d{2}|\d+[.,]\d{2})(?![.,]?\d)"
)
def _amount_to_cents(raw: str) -> int | None: def _amount_to_cents(raw: str) -> int | None:
@@ -151,51 +156,70 @@ def _amount_to_cents(raw: str) -> int | None:
return int(ganz) * 100 + int(dezimal) 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: def guess_price_cents(text: str) -> int | None:
"""Kaufpreis in Rappen/Cent aus dem Beleg oder ``None``. """Kaufpreis in Rappen/Cent aus dem Beleg oder ``None``.
Bevorzugt einen Betrag nahe einem Summen-Stichwort; sonst den größten Betrag Bewusst zurückhaltend, damit nicht irgendeine Zahl gewinnt:
(die Gesamtsumme ist auf Rechnungen meist der höchste Wert). 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: if not text:
return None return None
betraege = [ hits = _price_hits(text)
(m.start(), _amount_to_cents(m.group())) for m in _AMOUNT_RE.finditer(text) if not hits:
]
betraege = [(pos, c) for pos, c in betraege if c]
if not betraege:
return None 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: int | None = None
best_dist = 10**9 best_dist = 10**9
for kw in _TOTAL_KW.finditer(text): for kw in _TOTAL_KW.finditer(text):
for pos, c in betraege: for pos, c, _cur in hits:
if pos >= kw.start(): if pos >= kw.start():
dist = pos - kw.start() dist = pos - kw.start()
if dist <= 40 and dist < best_dist: if dist <= 40 and dist < best_dist:
best, best_dist = c, 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]: 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 Nur Beträge, die nach Preis aussehen – d.h. mit Währung in der Nähe oder kurz
Beträge absteigend. So kann die Oberfläche eine Auswahl anbieten, falls der hinter einem Summen-Stichwort. Beliebige Zahlen (Mengen, Artikelnummern,
automatische Tipp danebenliegt. Datumsteile) fliegen raus. So bleibt die Auswahl kurz und brauchbar.
""" """
if not text: if not text:
return [] return []
einzigartig = set() plausibel = {
for m in _AMOUNT_RE.finditer(text): c for pos, c, cur in _price_hits(text) if cur or _near_total(text, pos)
c = _amount_to_cents(m.group()) }
if c: if not plausibel:
einzigartig.add(c)
if not einzigartig:
return [] return []
kandidaten = sorted(einzigartig, reverse=True) kandidaten = sorted(plausibel, reverse=True)
best = guess_price_cents(text) best = guess_price_cents(text)
if best is not None and best in kandidaten: if best is not None and best in kandidaten:
kandidaten.remove(best) kandidaten.remove(best)
+14 -4
View File
@@ -60,19 +60,29 @@ def test_preis_deutsches_format():
assert guess_price_cents(text) == 129900 assert guess_price_cents(text) == 129900
def test_preis_ohne_stichwort_nimmt_groessten_betrag(): def test_preis_ohne_waehrung_und_ohne_stichwort_ist_none():
text = "Position A 12,90\nPosition B 199,00\nDanke." # 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 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(): def test_preis_ohne_betrag_ist_none():
assert guess_price_cents("Kein Preis hier.") is None assert guess_price_cents("Kein Preis hier.") is None
assert guess_price_cents("") is None assert guess_price_cents("") is None
def test_preis_kandidaten_bester_zuerst_ohne_dubletten(): 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) 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 set(kandidaten) == {5490, 4900, 590} # ohne Dubletten
assert guess_price_candidates("") == [] assert guess_price_candidates("") == []