fix: DKB-Empfaenger/Zweck-Split per CSV-Ground-Truth, Vergleichswerkzeug

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 22:08:37 +02:00
parent 7a0f71aa8c
commit 1b888307e9
3 changed files with 312 additions and 2 deletions

View File

@@ -49,6 +49,80 @@ def _is_noise(line: str) -> bool:
return bool(RE_NOISE.search(line))
# --- Empfänger/Zweck-Split-Heuristik (D-T5-1/D-T5-2, siehe
# .superpowers/sdd/parser-vs-csv-audit.md) --------------------------------
# extras[0] enthält "<Empfänger><Leerzeichen><Beginn Verwendungszweck>" ohne
# Trenner. Die folgenden Regeln bestimmen, wo der Empfänger endet.
_LEGAL_FORM_TOKENS = {
"ag", "gmbh", "ev", "eg", "kg", "bv", "sca", "sarl", "se",
"ltd", "inc", "ek", "ohg", "gbr", "stiftung", "bank",
}
_CONNECTOR_TOKENS = {"et", "cie"}
_REF_KEYWORDS = {"rechnung", "vertrag", "kundennummer", "kassenzeichen", "rg", "nr"}
_REF_KEYWORD_PREFIXES = ("kd.-nr", "kdnr", "beitragsnr")
_RE_TOKEN_DIGIT = re.compile(r"\d")
_MAX_LEGAL_FORM_SCAN = 7
def _normalize_token(tok: str) -> str:
return tok.strip(",;").lower().replace(".", "")
def _is_legal_form(tok: str) -> bool:
return _normalize_token(tok) in _LEGAL_FORM_TOKENS
def _is_connector(tok: str) -> bool:
return _normalize_token(tok) in _CONNECTOR_TOKENS
def _is_reference_keyword(tok: str) -> bool:
norm = _normalize_token(tok)
return norm in _REF_KEYWORDS or norm.startswith(_REF_KEYWORD_PREFIXES)
def _split_counterparty(line: str) -> tuple[str, str]:
"""Trennt Empfänger von Zweck-Beginn in der ersten Fortsetzungszeile
(extras[0]) einer DKB-Buchung. Gibt (counterparty, purpose_prefix)
zurück; purpose_prefix ist der abgeschnittene Rest (kann leer sein).
Reihenfolge (D-T5-1):
1. Rechtsform-Grenze (erste ~7 Tokens; "et Cie"-Ketten bis zum letzten
Rechtsform-Token in Folge).
2. Referenz-Grenze (erstes Token mit Ziffer oder Referenz-Schlüsselwort,
frühestens ab Token 1 -> counterparty nie leer).
3. Kein Schnitt (reine Wort-Folge, semantisch nicht trennbar).
D-T5-2: "siehe Anlage"-Zeilen -> counterparty leer, ganze Zeile in den
Zweck.
"""
line = line.strip()
if not line:
return "", ""
if line.lower().startswith("siehe anlage"):
return "", line
tokens = line.split()
n = len(tokens)
for i in range(min(n, _MAX_LEGAL_FORM_SCAN)):
if not _is_legal_form(tokens[i]):
continue
last = i
j = i + 1
while j < n and (_is_connector(tokens[j]) or _is_legal_form(tokens[j])):
if _is_legal_form(tokens[j]):
last = j
j += 1
cut = last + 1
return " ".join(tokens[:cut]), " ".join(tokens[cut:])
for idx in range(1, n):
if _RE_TOKEN_DIGIT.search(tokens[idx]) or _is_reference_keyword(tokens[idx]):
return " ".join(tokens[:idx]), " ".join(tokens[idx:])
return line, ""
def parse(path: Path) -> ParsedStatement:
with pdfplumber.open(path) as pdf:
text = "\n".join((page.extract_text() or "") for page in pdf.pages)
@@ -95,8 +169,16 @@ def parse(path: Path) -> ParsedStatement:
def _finish(p: dict) -> ParsedTransaction:
counterparty = p["extra"][0] if p["extra"] else ""
purpose = " ".join([p["head"], *p["extra"][1:]]).strip()
if p["extra"]:
counterparty, remainder = _split_counterparty(p["extra"][0])
purpose_parts = [p["head"]]
if remainder:
purpose_parts.append(remainder)
purpose_parts.extend(p["extra"][1:])
else:
counterparty = ""
purpose_parts = [p["head"]]
purpose = " ".join(purpose_parts).strip()
return ParsedTransaction(booking_date=p["booking"], value_date=p["value"],
amount=p["amount"], purpose=purpose,
counterparty=counterparty)