fix: DKB-Empfaenger/Zweck-Split per CSV-Ground-Truth, Vergleichswerkzeug
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user