feat(parser): deterministic pre-position free text into BT-22 (Task 3a)

- extract_pre_position_freetext(): liest den Vorlauftext zwischen Tabellenkopf ('Pos.') und
  erster Position direkt aus dem PDF-Text (ohne Referenzzeilen), statt sich auf das oft
  unvollstaendige LLM-Feld zu verlassen. Ersetzt invoice_note, wenn die Struktur erkannt wird.
  Verifiziert gegen 7 echte Rechnungen (vollstaendiger Freitext bei BIM/valantic/degewo/ZenIT,
  korrekt leer bei Gemeinde/Stadtwerke).
- ANGEBOT-Regex: Wortgrenze ergaenzt, damit 'VERGABEANGEBOT' (BT-17) nicht mehr faelschlich als
  'Angebot' erkannt und tender_id nicht mehr faelschlich geleert wird.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dfuhrmann3 2026-07-13 13:03:51 +02:00
parent 3e0975102f
commit 3832d5cdf2
2 changed files with 47 additions and 1 deletions

View File

@ -147,7 +147,7 @@ def build_payment_terms_note(data):
ANGEBOT_PATTERN = re.compile(
r"Angebot\s+([A-Za-z0-9][A-Za-z0-9\-/.]*)\s+vom\s+(\d{1,2}[.\-/]\d{1,2}[.\-/]\d{2,4})",
r"\bAngebot\s+([A-Za-z0-9][A-Za-z0-9\-/.]*)\s+vom\s+(\d{1,2}[.\-/]\d{1,2}[.\-/]\d{2,4})",
re.IGNORECASE,
)
@ -162,6 +162,43 @@ def extract_angebot_reference(text):
return f"Angebot {m.group(1)} vom {m.group(2)}"
# Referenzzeilen, die ein eigenes EN16931-Feld haben und daher NICHT in den Vorlauf-Freitext
# gehoeren (Auftragsbestaetigung BT-14, Angebot/Vergabeangebot, Lieferschein BT-16, Vertrag BT-12).
_REFERENCE_LINE_PATTERN = re.compile(
r"^(AUFTRAGSBESTÄTIGUNG|VERGABEANGEBOT|ANGEBOT|AUSSCHREIBUNG|LIEFERSCHEIN|VERTRAG|BESTELLUNG)\b",
re.IGNORECASE,
)
# Positionsnummer: alleinstehend "1" / "12" oder gegliedert "2.1". Markiert das Ende des Vorlaufs.
_POSITION_NUMBER_PATTERN = re.compile(r"^\d{1,3}(?:\.\d{1,3})?$")
def extract_pre_position_freetext(text):
"""Liest den beschreibenden Vorlauftext zwischen dem Tabellenkopf ('Pos.') und der ersten
Position deterministisch aus dem PDF-Text - unabhaengig vom (oft unvollstaendigen) LLM. Alles
zwischen der ersten 'Pos.'-Zeile und der ersten Positionsnummer wird uebernommen, ausser den
Referenzzeilen (Auftragsbestaetigung/Angebot/Vergabeangebot/Lieferschein/Vertrag), die eigene
Felder haben. Gibt den Freitext zurueck ("" wenn keiner vorhanden) oder None, wenn die
Tabellenstruktur nicht erkennbar ist (dann bleibt ein evtl. LLM-Freitext unveraendert)."""
lines = [ln.strip() for ln in text.split("\n")]
header_idx = next((i for i, ln in enumerate(lines) if ln == "Pos."), None)
if header_idx is None:
return None
first_pos_idx = next(
(i for i in range(header_idx + 1, len(lines)) if _POSITION_NUMBER_PATTERN.match(lines[i])),
None,
)
if first_pos_idx is None:
return None
freetext_lines = [
ln for ln in lines[header_idx + 1:first_pos_idx]
if ln and not _REFERENCE_LINE_PATTERN.match(ln)
]
return "\n".join(freetext_lines)
def sanitize_filename_part(value):
"""Macht einen String dateisystem-sicher: entfernt in Dateinamen kritische Zeichen
(Pfadtrenner/Sonderzeichen) und fasst Whitespace zu einem Bindestrich zusammen."""

View File

@ -31,6 +31,7 @@ from invoice_logic import (
detect_contract_type,
extract_hardware_serial_numbers,
extract_angebot_reference,
extract_pre_position_freetext,
)
# ==========================================
@ -234,6 +235,14 @@ with col2:
final_data["contract_type"] = detect_contract_type(pdf_text)
final_data["hardware_serials"] = extract_hardware_serial_numbers(pdf_text)
# Vorlauftext (BT-22) deterministisch aus dem PDF-Text lesen statt vom (oft
# unvollstaendigen) LLM: alles zwischen Tabellenkopf und erster Position, ohne die
# Referenzzeilen. Nur ersetzen, wenn die Tabellenstruktur erkannt wurde (sonst
# bleibt der LLM-Freitext als Fallback erhalten).
_freetext = extract_pre_position_freetext(pdf_text)
if _freetext is not None:
final_data["invoice_note"] = _freetext
# Angebotsbezug ("Angebot <Nr> vom <Datum>") hat kein eigenes EN16931-Feld - als
# Info fuer den Kunden in den Freitext (BT-22). Zusaetzlich: falls das LLM die
# Angebotsnummer faelschlich in Vertrag (BT-12) oder Vergabeangebot (BT-17) gepackt