fix(parser): capture positions with split serial numbers; keep Angebot out of BT-12/BT-17

- Positionsscan bricht nur noch bei der ECHTEN Positionsnummer (len(items)+1) ab, nicht
  bei alleinstehenden Ziffern-Fragmenten aufgesplitteter Seriennummern (Rechnung R26-224:
  Position wurde sonst ganz verworfen). Seriennummern landen jetzt in der Beschreibung (BT-154).
- Angebotsnummer wird aus Vertrag (BT-12)/Vergabeangebot (BT-17) entfernt, falls das LLM sie
  dort eingeordnet hat - Angebot gehoert nur in den Freitext (BT-22). Schema-Beschreibungen
  entsprechend praezisiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dfuhrmann3 2026-07-13 11:30:27 +02:00
parent cd2bd56377
commit 29034d8501
2 changed files with 19 additions and 9 deletions

View File

@ -29,8 +29,8 @@ class InvoiceMetadata(BaseModel):
delivery_note_id: str = Field(default="", description="Gefundene Lieferscheinnummer (z.B. LS26-035) VOR Position 1. Sonst leer lassen.")
order_id: str = Field(default="", description="Gefundene EIGENE Auftragsbestätigungs-/Verkaufsauftragsnummer (BT-14, z.B. AB26-080) VOR Position 1. Das ist unsere eigene Nummer, NICHT die Bestellnummer des Käufers. Sonst leer lassen.")
contract_id: str = Field(default="", description="Vertragsnummer (BT-12) VOR Position 1 - NUR ein echter Vertrag. NICHT ein Vergabeangebot oder eine Ausschreibung (die gehören in tender_id). Sonst leer lassen.")
tender_id: str = Field(default="", description="Nummer eines Vergabeangebots oder einer Ausschreibung (BT-17, z.B. AN26-130) VOR Position 1. NICHT die Vertragsnummer (die gehört in contract_id). Sonst leer lassen.")
contract_id: str = Field(default="", description="Vertragsnummer (BT-12) VOR Position 1 - NUR ein echter Vertrag. NICHT ein Vergabeangebot/eine Ausschreibung (die gehören in tender_id) und NICHT ein 'Angebot' (z.B. 'ANGEBOT AN26-174 vom ...' - das wird separat als Freitext behandelt, hier KEIN Wert eintragen). Sonst leer lassen.")
tender_id: str = Field(default="", description="Nummer eines Vergabeangebots oder einer Ausschreibung (BT-17, z.B. AN26-130) VOR Position 1. NICHT die Vertragsnummer (die gehört in contract_id) und NICHT ein bloßes 'Angebot ... vom ...' (das ist KEIN Vergabeangebot). Sonst leer lassen.")
service_start_date: str = Field(default="", description="Startdatum eines explizit genannten Einsatz- oder Leistungszeitraums VOR Position 1 im Format YYYY-MM-DD. Sonst leer.")
service_end_date: str = Field(default="", description="Enddatum eines explizit genannten Einsatz- oder Leistungszeitraums VOR Position 1 im Format YYYY-MM-DD. Sonst leer.")
payment_term_days: str = Field(default="", description="Das Zahlungsziel in TAGEN als reine Zahl (z.B. '14' oder '30'). Suche im Text nach Formulierungen wie 'zahlbar innerhalb X Tagen', 'Zahlungsziel X Tage', 'X Tage netto'. Steht stattdessen ein konkretes Fälligkeitsdatum (z.B. 'zahlbar bis 31.07.2026'), berechne die Anzahl Tage zwischen Rechnungsdatum und diesem Datum und gib NUR diese Zahl zurück. Wenn im Text KEIN Zahlungsziel und KEIN Fälligkeitsdatum erkennbar ist: leer lassen (NICHT raten, NICHT 14 annehmen).")
@ -246,7 +246,11 @@ def extract_invoice_items_pure_python(text):
if any(prev_lower.startswith(w) for w in ["zwischensumme", "endbetrag", "gesamtbetrag", "umsatzsteuer"]):
break
if prev_line.isdigit() and len(prev_line) <= 2:
# Nur bei der ECHTEN Positionsnummer abbrechen (die naechste erwartete Position =
# len(items)+1). Sonst wuerden auch alleinstehende Ziffern-Fragmente von
# aufgesplitteten Seriennummern (z.B. "9" als letzte Zeile einer S/N) faelschlich
# als Positionsnummer gewertet und die Position verworfen.
if prev_line.isdigit() and len(prev_line) <= 2 and int(prev_line) == len(items) + 1:
break
desc_lines.insert(0, prev_line)

14
main.py
View File

@ -217,11 +217,17 @@ with col2:
final_data["contract_type"] = detect_contract_type(pdf_text)
final_data["hardware_serials"] = extract_hardware_serial_numbers(pdf_text)
# Angebotsbezug ("Angebot <Nr> vom <Datum>") hat kein eigenes EN16931-Feld -
# als Info fuer den Kunden in den Freitext (BT-22) uebernehmen, falls gefunden
# und noch nicht enthalten.
# 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
# hat, dort leeren - ein Angebot ist weder ein Vertrag noch ein Vergabeangebot.
_angebot = extract_angebot_reference(pdf_text)
if _angebot and _angebot.lower() not in str(final_data.get("invoice_note", "")).lower():
if _angebot:
_angebot_nr = _angebot.split()[1]
for _k in ("contract_id", "tender_id"):
if str(final_data.get(_k, "")).strip() == _angebot_nr:
final_data[_k] = ""
if _angebot.lower() not in str(final_data.get("invoice_note", "")).lower():
_existing_note = str(final_data.get("invoice_note", "")).strip()
final_data["invoice_note"] = f"{_existing_note}\n{_angebot}".strip() if _existing_note else _angebot