docker.dateiverwaltung/backend/app/modules/extraktoren.py
data 21e1ffe9e2 Version 1.1: Dateimanager mit 3-Panel Layout
Neue Features:
- 3-Panel Dateimanager (Ordnerbaum, Dateiliste, Vorschau)
- Separates Vorschau-Fenster für zweiten Monitor
- Resize-Handles für flexible Panel-Größen (horizontal & vertikal)
- Vorschau-Panel ausblendbar wenn externes Fenster aktiv
- Natürliche Sortierung (Sonderzeichen → Zahlen → Buchstaben)
- PDF-Vorschau mit Fit-to-Page
- Email-Attachment Abruf erweitert

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 12:51:40 +01:00

439 lines
16 KiB
Python

"""
Globale Feld-Extraktoren mit Kaskaden-Regex
Werden automatisch als Fallback verwendet wenn regel-spezifische Muster nicht greifen
"""
import re
from datetime import datetime
from typing import Optional, List, Dict, Any
import logging
logger = logging.getLogger(__name__)
# ============ DATUM ============
DATUM_MUSTER = [
# Mit Kontext (zuverlässiger)
{"regex": r"Rechnungsdatum[:\s]*(\d{2})[./](\d{2})[./](\d{4})", "order": "dmy"},
{"regex": r"Belegdatum[:\s]*(\d{2})[./](\d{2})[./](\d{4})", "order": "dmy"},
{"regex": r"Datum[:\s]*(\d{2})[./](\d{2})[./](\d{4})", "order": "dmy"},
{"regex": r"Date[:\s]*(\d{2})[./](\d{2})[./](\d{4})", "order": "dmy"},
{"regex": r"vom[:\s]*(\d{2})[./](\d{2})[./](\d{4})", "order": "dmy"},
# ISO Format
{"regex": r"(\d{4})-(\d{2})-(\d{2})", "order": "ymd"},
# Deutsches Format ohne Kontext
{"regex": r"(\d{2})\.(\d{2})\.(\d{4})", "order": "dmy"},
{"regex": r"(\d{2})/(\d{2})/(\d{4})", "order": "dmy"},
# Amerikanisches Format
{"regex": r"(\d{2})/(\d{2})/(\d{4})", "order": "mdy"},
# Ausgeschriebene Monate
{"regex": r"(\d{1,2})\.\s*(Januar|Februar|März|April|Mai|Juni|Juli|August|September|Oktober|November|Dezember)\s*(\d{4})", "order": "dMy"},
{"regex": r"(\d{1,2})\s+(Jan|Feb|Mär|Apr|Mai|Jun|Jul|Aug|Sep|Okt|Nov|Dez)[a-z]*\.?\s+(\d{4})", "order": "dMy"},
]
MONATE_DE = {
"januar": 1, "februar": 2, "märz": 3, "april": 4, "mai": 5, "juni": 6,
"juli": 7, "august": 8, "september": 9, "oktober": 10, "november": 11, "dezember": 12,
"jan": 1, "feb": 2, "mär": 3, "apr": 4, "jun": 6, "jul": 7, "aug": 8, "sep": 9, "okt": 10, "nov": 11, "dez": 12
}
def extrahiere_datum(text: str, spezifische_muster: List[Dict] = None) -> Optional[str]:
"""
Extrahiert Datum aus Text mit Kaskaden-Ansatz
Returns: ISO Format YYYY-MM-DD oder None
"""
muster_liste = (spezifische_muster or []) + DATUM_MUSTER
for muster in muster_liste:
try:
match = re.search(muster["regex"], text, re.IGNORECASE)
if match:
groups = match.groups()
order = muster.get("order", "dmy")
if order == "dmy":
tag, monat, jahr = int(groups[0]), int(groups[1]), int(groups[2])
elif order == "ymd":
jahr, monat, tag = int(groups[0]), int(groups[1]), int(groups[2])
elif order == "mdy":
monat, tag, jahr = int(groups[0]), int(groups[1]), int(groups[2])
elif order == "dMy":
tag = int(groups[0])
monat = MONATE_DE.get(groups[1].lower(), 1)
jahr = int(groups[2])
else:
continue
# Validierung
if 1 <= tag <= 31 and 1 <= monat <= 12 and 1900 <= jahr <= 2100:
return f"{jahr:04d}-{monat:02d}-{tag:02d}"
except Exception as e:
logger.debug(f"Datum-Extraktion fehlgeschlagen: {e}")
continue
return None
# ============ BETRAG ============
BETRAG_MUSTER = [
# Mit Kontext (zuverlässiger)
{"regex": r"Gesamtbetrag[:\s]*([\d.,]+)\s*(?:EUR|€)?", "context": True},
{"regex": r"Rechnungsbetrag[:\s]*([\d.,]+)\s*(?:EUR|€)?", "context": True},
{"regex": r"Endbetrag[:\s]*([\d.,]+)\s*(?:EUR|€)?", "context": True},
{"regex": r"Summe[:\s]*([\d.,]+)\s*(?:EUR|€)?", "context": True},
{"regex": r"Total[:\s]*([\d.,]+)\s*(?:EUR|€)?", "context": True},
{"regex": r"Brutto[:\s]*([\d.,]+)\s*(?:EUR|€)?", "context": True},
{"regex": r"zu zahlen[:\s]*([\d.,]+)\s*(?:EUR|€)?", "context": True},
{"regex": r"Zahlbetrag[:\s]*([\d.,]+)\s*(?:EUR|€)?", "context": True},
# Mit Währung (weniger zuverlässig)
{"regex": r"([\d.,]+)\s*(?:EUR|€)", "context": False},
{"regex": r"\s*([\d.,]+)", "context": False},
]
def extrahiere_betrag(text: str, spezifische_muster: List[Dict] = None) -> Optional[str]:
"""
Extrahiert Betrag aus Text mit Kaskaden-Ansatz
Returns: Formatierter Betrag (z.B. "1234,56") oder None
"""
muster_liste = (spezifische_muster or []) + BETRAG_MUSTER
for muster in muster_liste:
try:
match = re.search(muster["regex"], text, re.IGNORECASE)
if match:
betrag_str = match.group(1)
betrag = _parse_betrag(betrag_str)
if betrag is not None and betrag > 0:
# Formatierung: Ganzzahl wenn möglich, sonst 2 Dezimalstellen
if betrag == int(betrag):
return str(int(betrag))
return f"{betrag:.2f}".replace(".", ",")
except Exception as e:
logger.debug(f"Betrag-Extraktion fehlgeschlagen: {e}")
continue
return None
def _parse_betrag(betrag_str: str) -> Optional[float]:
"""Parst Betrag-String zu Float"""
betrag_str = betrag_str.strip()
# Leerzeichen entfernen
betrag_str = betrag_str.replace(" ", "")
# Deutsches Format: 1.234,56 -> 1234.56
if "," in betrag_str and "." in betrag_str:
if betrag_str.rfind(",") > betrag_str.rfind("."):
# Deutsches Format
betrag_str = betrag_str.replace(".", "").replace(",", ".")
else:
# Englisches Format
betrag_str = betrag_str.replace(",", "")
elif "," in betrag_str:
# Nur Komma: deutsches Dezimaltrennzeichen
betrag_str = betrag_str.replace(",", ".")
try:
return float(betrag_str)
except:
return None
# ============ RECHNUNGSNUMMER ============
NUMMER_MUSTER = [
# Mit Kontext
{"regex": r"Rechnungsnummer[:\s#]*([A-Z0-9][\w\-/]+)", "context": True},
{"regex": r"Rechnung\s*Nr\.?[:\s#]*([A-Z0-9][\w\-/]+)", "context": True},
{"regex": r"Rechnungs-Nr\.?[:\s#]*([A-Z0-9][\w\-/]+)", "context": True},
{"regex": r"Invoice\s*(?:No\.?|Number)?[:\s#]*([A-Z0-9][\w\-/]+)", "context": True},
{"regex": r"Beleg-?Nr\.?[:\s#]*([A-Z0-9][\w\-/]+)", "context": True},
{"regex": r"Dokumentnummer[:\s#]*([A-Z0-9][\w\-/]+)", "context": True},
{"regex": r"Bestell-?Nr\.?[:\s#]*([A-Z0-9][\w\-/]+)", "context": True},
{"regex": r"Auftrags-?Nr\.?[:\s#]*([A-Z0-9][\w\-/]+)", "context": True},
# Typische Formate ohne Kontext
{"regex": r"RE-?(\d{4,})", "context": False},
{"regex": r"INV-?(\d{4,})", "context": False},
]
def extrahiere_nummer(text: str, spezifische_muster: List[Dict] = None) -> Optional[str]:
"""
Extrahiert Rechnungs-/Belegnummer aus Text
"""
muster_liste = (spezifische_muster or []) + NUMMER_MUSTER
for muster in muster_liste:
try:
match = re.search(muster["regex"], text, re.IGNORECASE)
if match:
nummer = match.group(1).strip()
if len(nummer) >= 3: # Mindestens 3 Zeichen
return nummer
except Exception as e:
logger.debug(f"Nummer-Extraktion fehlgeschlagen: {e}")
continue
return None
# ============ FIRMA/ABSENDER ============
FIRMA_MUSTER = [
# Rechtsformen direkt (GmbH, AG, etc.) - sehr zuverlässig
{"regex": r"([A-ZÄÖÜ][A-Za-zäöüÄÖÜß\s&\-\.]+)\s+(?:GmbH|AG|KG|e\.K\.|Inc|Ltd|SE|UG|mbH|OHG|GbR)", "context": True},
# Kopfzeile/Absender typisch erste Zeilen
{"regex": r"^([A-ZÄÖÜ][A-Za-zäöüÄÖÜß0-9\s&\-\.]{2,50})$", "context": True, "multiline": True},
# Nach "von" / Absender
{"regex": r"(?:Absender|Von|From)[:\s]+([A-Za-zäöüÄÖÜß\s&\-\.]+)", "context": True},
# Firmenname vor Adresse (PLZ Stadt)
{"regex": r"([A-ZÄÖÜ][A-Za-zäöüÄÖÜß\s&\-\.]+)\n+[A-Za-zäöüÄÖÜß\s\d\-\.]+\n+\d{5}\s+[A-Za-zäöüÄÖÜß]+", "context": True},
# E-Mail Domain als Firmennamen
{"regex": r"(?:info|kontakt|rechnung|buchhaltung|office)@([a-zA-Z0-9\-]+)\.", "context": True},
# Website als Firmennamen
{"regex": r"(?:www\.|http[s]?://(?:www\.)?)([a-zA-Z0-9\-]+)\.", "context": True},
]
# Bekannte Firmen (werden im Text gesucht)
BEKANNTE_FIRMEN = [
# Elektronik/IT
"Sonepar", "Amazon", "Ebay", "MediaMarkt", "Saturn", "Conrad", "Reichelt",
"Alternate", "Mindfactory", "Caseking", "Notebooksbilliger", "Cyberport",
"Apple", "Microsoft", "Dell", "HP", "Lenovo", "ASUS", "Acer",
# Baumärkte
"Hornbach", "Bauhaus", "OBI", "Hagebau", "Toom", "Hellweg", "Globus",
# Telekommunikation
"Telekom", "Vodafone", "O2", "1&1", "Congstar", "Drillisch",
# Versicherungen
"Allianz", "HUK", "Provinzial", "DEVK", "Gothaer", "AXA", "ERGO", "Zurich",
"Generali", "HDI", "VHV", "R+V", "Debeka", "Signal Iduna",
# Möbel
"IKEA", "Poco", "XXXLutz", "Roller", "Höffner", "Segmüller",
# Versand/Logistik
"DHL", "DPD", "Hermes", "UPS", "GLS", "FedEx",
# Lebensmittel/Drogerie
"REWE", "Edeka", "Aldi", "Lidl", "Rossmann", "dm", "Müller",
# Energie
"E.ON", "RWE", "EnBW", "Vattenfall", "Stadtwerke", "EWE", "ENTEGA",
# Banken
"Deutsche Bank", "Commerzbank", "Sparkasse", "Volksbank", "ING", "DKB", "Postbank",
# Sonstige
"ADAC", "TÜV", "Dekra", "Würth", "Grainger", "Festo", "Bosch",
]
def extrahiere_firma(text: str, absender_email: str = "", spezifische_muster: List[Dict] = None) -> Optional[str]:
"""
Extrahiert Firmennamen aus Text oder E-Mail-Absender
"""
text_lower = text.lower()
# 1. Bekannte Firmen im Text suchen
for firma in BEKANNTE_FIRMEN:
if firma.lower() in text_lower:
return firma
# 2. Aus E-Mail-Domain extrahieren
if absender_email:
match = re.search(r"@([\w\-]+)\.", absender_email)
if match:
domain = match.group(1)
# Bekannte Domain-Namen kapitalisieren
for firma in BEKANNTE_FIRMEN:
if firma.lower() == domain.lower():
return firma
# Domain als Firmenname verwenden (kapitalisiert)
if len(domain) > 2:
return domain.capitalize()
# 3. Firmen mit Rechtsform suchen (GmbH, AG, etc.)
rechtsform_match = re.search(
r"([A-ZÄÖÜ][A-Za-zäöüÄÖÜß0-9\s&\-\.]{1,50})\s*(?:GmbH|AG|KG|e\.K\.|Inc|Ltd|SE|UG|mbH|OHG|GbR|Co\.\s*KG)",
text
)
if rechtsform_match:
firma = rechtsform_match.group(1).strip()
if len(firma) >= 2:
return firma
# 4. E-Mail im Text suchen und Domain extrahieren
email_match = re.search(r"[\w\.\-]+@([\w\-]+)\.", text)
if email_match:
domain = email_match.group(1)
if len(domain) > 2 and domain.lower() not in ["gmail", "yahoo", "hotmail", "outlook", "web", "gmx", "mail"]:
return domain.capitalize()
# 5. Website im Text suchen
web_match = re.search(r"(?:www\.|https?://(?:www\.)?)([a-zA-Z0-9\-]+)\.", text, re.IGNORECASE)
if web_match:
domain = web_match.group(1)
if len(domain) > 2:
return domain.capitalize()
# 6. Regex-Muster als Fallback
muster_liste = (spezifische_muster or []) + FIRMA_MUSTER
for muster in muster_liste:
try:
flags = re.MULTILINE if muster.get("multiline") else 0
match = re.search(muster["regex"], text, flags)
if match:
firma = match.group(1).strip()
# Filtern: zu kurz, nur Zahlen, etc.
if len(firma) >= 2 and not firma.isdigit():
return firma
except:
continue
return None
# ============ DOKUMENTTYP ============
DOKUMENTTYP_KEYWORDS = {
"Rechnung": ["rechnung", "invoice", "faktura", "bill"],
"Angebot": ["angebot", "quotation", "quote", "offerte"],
"Gutschrift": ["gutschrift", "credit note", "erstattung"],
"Mahnung": ["mahnung", "zahlungserinnerung", "payment reminder"],
"Lieferschein": ["lieferschein", "delivery note", "packing slip"],
"Auftragsbestätigung": ["auftragsbestätigung", "order confirmation", "bestellbestätigung"],
"Vertrag": ["vertrag", "contract", "vereinbarung"],
"Versicherungsschein": ["versicherungsschein", "police", "versicherungspolice"],
"Zeugnis": ["zeugnis", "certificate", "zertifikat"],
"Bescheinigung": ["bescheinigung", "nachweis", "bestätigung"],
"Kontoauszug": ["kontoauszug", "account statement", "bankbeleg"],
"Beitragsrechnung": ["beitragsrechnung", "beitragsberechnung", "mitgliedsbeitrag"],
}
def extrahiere_dokumenttyp(text: str, dateiname: str = "") -> Optional[str]:
"""
Erkennt den Dokumenttyp anhand von Keywords
"""
text_lower = text.lower() + " " + dateiname.lower()
for typ, keywords in DOKUMENTTYP_KEYWORDS.items():
for keyword in keywords:
if keyword in text_lower:
return typ
return None
# ============ HAUPTFUNKTION ============
def extrahiere_alle_felder(text: str, dokument_info: Dict = None,
regel_extraktion: Dict = None) -> Dict[str, Any]:
"""
Extrahiert alle verfügbaren Felder aus einem Dokument
Args:
text: Der extrahierte Text aus dem PDF
dokument_info: Zusätzliche Infos (absender, original_name, etc.)
regel_extraktion: Spezifische Extraktionsregeln aus der Regel
Returns:
Dict mit allen extrahierten Feldern
"""
dokument_info = dokument_info or {}
regel_extraktion = regel_extraktion or {}
felder = {}
# Datum
datum_muster = regel_extraktion.get("datum", {}).get("muster", [])
datum = extrahiere_datum(text, datum_muster if isinstance(datum_muster, list) else None)
if datum:
felder["datum"] = datum
# Betrag
betrag_muster = regel_extraktion.get("betrag", {}).get("muster", [])
betrag = extrahiere_betrag(text, betrag_muster if isinstance(betrag_muster, list) else None)
if betrag:
felder["betrag"] = betrag
# Nummer
nummer_muster = regel_extraktion.get("nummer", {}).get("muster", [])
nummer = extrahiere_nummer(text, nummer_muster if isinstance(nummer_muster, list) else None)
if nummer:
felder["nummer"] = nummer
# Firma
absender = dokument_info.get("absender", "")
firma = extrahiere_firma(text, absender)
if firma:
felder["firma"] = firma
# Dokumenttyp
dateiname = dokument_info.get("original_name", "")
typ = extrahiere_dokumenttyp(text, dateiname)
if typ:
felder["typ"] = typ
# Statische Werte aus Regel übernehmen
for feld_name, feld_config in regel_extraktion.items():
if isinstance(feld_config, dict) and "wert" in feld_config:
felder[feld_name] = feld_config["wert"]
return felder
# ============ SCHEMA-BUILDER ============
def baue_dateiname(schema: str, felder: Dict[str, Any], endung: str = ".pdf") -> str:
"""
Baut Dateinamen aus Schema und Feldern.
Entfernt automatisch Platzhalter und deren Trennzeichen wenn Feld fehlt.
Schema-Beispiel: "{datum} - {typ} - {firma} - {nummer} - {betrag} EUR"
Mit felder = {datum: "2026-10-01", typ: "Rechnung", firma: "Sonepar"}
Ergebnis: "2026-10-01 - Rechnung - Sonepar.pdf"
"""
# Schema ohne Endung verarbeiten
if schema.lower().endswith(".pdf"):
schema = schema[:-4]
# Platzhalter ersetzen
result = schema
for key, value in felder.items():
placeholder = "{" + key + "}"
if placeholder in result and value:
result = result.replace(placeholder, str(value))
# Nicht ersetzte Platzhalter und ihre Trennzeichen entfernen
# Muster: " - {feld}" oder "{feld} - " oder "{feld}"
result = re.sub(r'\s*-\s*\{[^}]+\}', '', result)
result = re.sub(r'\{[^}]+\}\s*-\s*', '', result)
result = re.sub(r'\{[^}]+\}', '', result)
# Aufräumen: Doppelte Trennzeichen, Leerzeichen
result = re.sub(r'\s*-\s*-\s*', ' - ', result)
result = re.sub(r'\s+', ' ', result)
result = result.strip(' -')
# Ungültige Zeichen entfernen
invalid_chars = '<>:"/\\|?*'
for char in invalid_chars:
result = result.replace(char, "_")
# Endung anhängen
if not result:
result = "Dokument"
return result + endung