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:
parent
3e0975102f
commit
3832d5cdf2
|
|
@ -147,7 +147,7 @@ def build_payment_terms_note(data):
|
||||||
|
|
||||||
|
|
||||||
ANGEBOT_PATTERN = re.compile(
|
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,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -162,6 +162,43 @@ def extract_angebot_reference(text):
|
||||||
return f"Angebot {m.group(1)} vom {m.group(2)}"
|
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):
|
def sanitize_filename_part(value):
|
||||||
"""Macht einen String dateisystem-sicher: entfernt in Dateinamen kritische Zeichen
|
"""Macht einen String dateisystem-sicher: entfernt in Dateinamen kritische Zeichen
|
||||||
(Pfadtrenner/Sonderzeichen) und fasst Whitespace zu einem Bindestrich zusammen."""
|
(Pfadtrenner/Sonderzeichen) und fasst Whitespace zu einem Bindestrich zusammen."""
|
||||||
|
|
|
||||||
9
main.py
9
main.py
|
|
@ -31,6 +31,7 @@ from invoice_logic import (
|
||||||
detect_contract_type,
|
detect_contract_type,
|
||||||
extract_hardware_serial_numbers,
|
extract_hardware_serial_numbers,
|
||||||
extract_angebot_reference,
|
extract_angebot_reference,
|
||||||
|
extract_pre_position_freetext,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
@ -234,6 +235,14 @@ with col2:
|
||||||
final_data["contract_type"] = detect_contract_type(pdf_text)
|
final_data["contract_type"] = detect_contract_type(pdf_text)
|
||||||
final_data["hardware_serials"] = extract_hardware_serial_numbers(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
|
# 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
|
# 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
|
# Angebotsnummer faelschlich in Vertrag (BT-12) oder Vergabeangebot (BT-17) gepackt
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue