diff --git a/README.md b/README.md new file mode 100644 index 0000000..52ac35f --- /dev/null +++ b/README.md @@ -0,0 +1,208 @@ +\# 📄 Enterprise PDF-zu-XRechnung-Konverter (Hybrid-Architektur) + + + +Dieses Tool ist ein hochprĂ€ziser, datenschutzkonformer Konverter, der herkömmliche B2B-Rechnungen im PDF-Format vollautomatisch in gesetzeskonforme \*\*XRechnung-XMLs (UBL-Standard, Version 3.0)\*\* transformiert. + + + +Das System setzt auf einen \*\*innovativen Hybrid-Ansatz\*\*: Es kombiniert die semantische FlexibilitĂ€t eines modernen, lokalen Large Language Models (LLM) fĂŒr unstrukturierte Kopfdaten mit der unfehlbaren mathematischen PrĂ€zision von nativem Python-Code fĂŒr die Positionstabelle. + + + +\--- + + + +\## 🚀 Key Features + + + +\* \*\*100 % Lokale AusfĂŒhrung \& DSGVO-konform:\*\* Keine Cloud-APIs (kein OpenAI, Anthropic etc.). Alle Daten verbleiben vollstĂ€ndig auf der eigenen Hardware. Absolute DatensouverĂ€nitĂ€t fĂŒr sensible Finanzdaten. + +\* \*\*Kugelsichere Tabellen-Extraktion:\*\* Positionen, Mengen, Einheiten und Einzelpreise werden mittels optimierter regulĂ€rer AusdrĂŒcke (Regex) fehlerfrei ausgelesen – komplett immun gegen KI-Halluzinationen oder Token-AbbrĂŒche. + +\* \*\*Finanzmathematische PrĂ€zision:\*\* Berechnung aller Netto-, MwSt- und Bruttosummen mittels Pythons `decimal`-Modul inklusive striktem \*\*kaufmĂ€nnischen Runden (`ROUND\_HALF\_UP`)\*\*. Keine Floating-Point-Ungenauigkeiten. + +\* \*\*Human-in-the-Loop Frontend:\*\* Eine interaktive Streamlit-OberflĂ€che erlaubt die sofortige visuelle Kontrolle und manuelle Korrektur aller extrahierten Daten vor dem finalen Export. + +\* \*\*Validierte XML-Ausgabe:\*\* Generiert valides UBL-2.1-Invoice-XML, das die strengen KonformitĂ€tsprĂŒfungen der offiziellen Koordinierungsstelle fĂŒr IT-Standards (KoSIT) fehlerfrei (\*\*0 Fehler\*\*) besteht. + + + +\--- + + + +\## 🛠 Wie es funktioniert (Hybrid-Architektur) + + + +Das Tool teilt die Extraktion in zwei spezialisierte Verarbeitungsstufen auf: + + + +``` + +\[PDF Dokument] + + │ + + ├──> Stufe 1: Lokale KI (Qwen3.5-Instruct) ──> Extraktion von Metadaten (Kunde, Adresse, Datum, Referenz) + + │ + + └──> Stufe 2: Pure Python Engine (Regex) ──> Extraktion der Artikel (Beschreibung, Menge, Einheit, Preis) + +``` + + + +1\. \*\*Stufe 1: Stammdaten via Lokaler KI (LLM)\*\* + + Kopfdaten wie Rechnungsnummer, Datumsangaben, Bestell-Referenzen und Kundenadressen wandern auf Rechnungen visuell stark hin und her. Hier glĂ€nzt das lokale KI-Modell: Es versteht den Kontext des gesamten Texts und extrahiert diese unstrukturierten Metadaten zuverlĂ€ssig. Um strukturelle Fehler zu vermeiden, wird die KI ĂŒber eine strikte JSON-Schema-Grammatik (`LlamaGrammar`) gezwungen, die Daten exakt in ein vordefiniertes Pydantic-Schema zu gießen. + + + +2\. \*\*Stufe 2: Positionen via Python-Regex\*\* + + Finanztabellen erfordern 0 % Fehlertoleranz. Statt das LLM mit langen Tabellen zu ĂŒberfordern (Gefahr von AbbrĂŒchen oder Zahlendrehern), scannt ein robuster Python-Algorithmus den extrahierten Textstrom nach Mustern von Mengenangaben und Einheiten (z. B. `1,375 Tag(e)` oder `2 Std.`). Er greift sich nachgelagerte Preise, isoliert den zugehörigen Beschreibungstext (auch bei komplexen mehrzeiligen UmbrĂŒchen) und filtert irrelevante Systemtexte automatisch heraus. + + + +\--- + + + +\## đŸ€– Das KI-Modell + + + +Das Tool ist exakt auf das folgende Open-Weights-Modell optimiert: + +\* \*\*Modell:\*\* `Qwen3.5-4B-Instruct` + +\* \*\*Format:\*\* GGUF (optimiert fĂŒr CPU/GPU-Inferenz via `llama-cpp`) + +\* \*\*Quantisierung:\*\* `Q8\_0` (Hohe Genauigkeit bei exzellenter Performance) + + + +\*\*Wichtig:\*\* Es muss zwingend die \*\*`Instruct`\*\*-Version des Modells verwendet werden (`Qwen3.5-4B-Instruct-Q8\_0.gguf`), da das reine Base-Modell nicht in der Lage ist, strukturierte Systemanweisungen und JSON-Grammatiken korrekt zu befolgen. + + + +\--- + + + +\## đŸ’» Installation \& Setup + + + +\### 1. Repository klonen \& Ordnerstruktur vorbereiten + +```bash + +git clone \[https://github.com/dein-username/pdf-to-xrechnung.git](https://github.com/dein-username/pdf-to-xrechnung.git) + +cd pdf-to-xrechnung + +mkdir -p models + +``` + + + +\### 2. Lokales Modell herrichten + +Lade das GGUF-Modell von Hugging Face (z. B. aus dem Repository von `bartowski`) herunter und platziere es im `models`-Ordner: + +\* \*\*Pfad:\*\* `models/Qwen3.5-4B-Instruct-Q8\_0.gguf` + + + +\### 3. Requirements erstellen (`requirements.txt`) + +FĂŒr ein sauberes und schlankes Repository erstelle eine `requirements.txt` mit folgendem minimalen Inhalt: + +```text + +streamlit==1.58.0 + +llama\_cpp\_python==0.3.32 + +pydantic==2.13.4 + +pymupdf==1.28.0 + +``` + + + +\### 4. AbhĂ€ngigkeiten installieren (inkl. GPU-Beschleunigung) + +Aktiviere deine virtuelle Umgebung (`venv`) und fĂŒhre den passenden Befehl aus, damit das KI-Modell deine Nvidia-Grafikkarte (CUDA) fĂŒr rasend schnelle Antworten nutzt: + + + +\*\*Unter Windows (PowerShell):\*\* + +```powershell + +$env:GGML\_CUDA="1"; pip install -r requirements.txt --force-reinstall --no-cache-dir + +``` + + + +\*\*Unter Linux / macOS:\*\* + +```bash + +GGML\_CUDA=1 pip install -r requirements.txt --force-reinstall --no-cache-dir + +``` + + + +\--- + + + +\## 🎈 Benutzung + + + +Starte die Applikation ĂŒber dein Terminal: + +```bash + +streamlit run app.py + +``` + + + +1\. \*\*PDF hochladen:\*\* Ziehe eine herkömmliche Rechnungs-PDF per Drag-and-Drop in die left Spalte der Streamlit-OberflĂ€che. + +2\. \*\*Daten extrahieren:\*\* Klicke auf `🚀 Daten extrahieren`. Im Hintergrund analysiert die hybride Engine das Dokument. + +3\. \*\*PrĂŒfen \& Editieren:\*\* In der rechten Spalte erscheinen alle erkannten Kopfdaten sowie die editierbare Positionstabelle. Du kannst jeden Wert manuell anpassen, Zeilen löschen oder hinzufĂŒgen. + +4\. \*\*Exportieren:\*\* Klicke auf `🔒 XRechnung exportieren`. Das Tool berechnet kaufmĂ€nnisch alle Summen und stellt dir die fertige, konforme XML-Datei direkt als Download (`đŸ“„ Download XML`) bereit. + + + +\--- + + + +\## ⚖ Rechtliche \& Finanzmathematische Hinweise + + + +\* \*\*GoBD-KonformitĂ€t:\*\* Dieses Tool generiert das strukturierte XML-Format. Der Nutzer ist selbst dafĂŒr verantwortlich, sowohl die Original-PDF als auch die generierte XML-Datei gemĂ€ĂŸ den GoBD-Richtlinien revisionssicher fĂŒr 10 Jahre zu archivieren. + +\* \*\*PrĂŒfpflicht:\*\* Das Tool arbeitet nach dem Prinzip \*„Human-in-the-Loop“\*. Vor dem XML-Export ist eine kurze visuelle Endkontrolle der extrahierten Daten im Streamlit-Frontend durch den Sachbearbeiter zwingend empfohlen. + diff --git a/app.py b/app.py index 2ab79e7..102693a 100644 --- a/app.py +++ b/app.py @@ -5,6 +5,7 @@ import re import xml.etree.ElementTree as ET from xml.dom import minidom from datetime import datetime, timedelta +from decimal import Decimal, ROUND_HALF_UP # FĂŒr finanzmathematisch exaktes Runden import fitz # PyMuPDF from pydantic import BaseModel, Field from llama_cpp import Llama, LlamaGrammar @@ -26,17 +27,25 @@ class InvoiceMetadata(BaseModel): # ========================================== -# 2. FEHLERFREIES PYTHON-PARSING DER TABELLE +# 2. PRÄZISE MATHEMATISCHE FUNKTIONEN # ========================================== +def kaufmaennisches_runden(betrag, nachkommastellen=2): + """ + Rundet BetrĂ€ge finanzmathematisch exakt nach kaufmĂ€nnischer Art (ab 5 aufrunden). + Verhindert Pythons Banker's Rounding Fehler. + """ + if not betrag: + return 0.0 + quantizer = Decimal('1.' + '0' * nachkommastellen) + return float(Decimal(f"{betrag:.6f}").quantize(quantizer, rounding=ROUND_HALF_UP)) + def clean_german_float(val_str): if not val_str: return 0.0 - # Entferne WĂ€hrung, Leerzeichen und unsichtbare Steuerzeichen s = str(val_str).strip().replace("€", "").replace(" ", "").replace("'", "").replace("\n", "").replace("\r", "") if not s: return 0.0 - # Konvertierung von Deutsch (1.300,00) zu Python Float (1300.00) if "." in s and "," in s: s = s.replace(".", "").replace(",", ".") elif "," in s: @@ -47,18 +56,11 @@ def clean_german_float(val_str): return 0.0 def extract_invoice_items_pure_python(text): - """ - Extrem robuste Tabellen-Extraktion. - Findet Positionen auch dann, wenn PDF-Text ZeilenumbrĂŒche zwischen den Spalten erzwingt. - """ items = [] - - # 1. Text zeilenweise normalisieren und leere Zeilen entfernen lines = [line.strip() for line in text.split("\n") if line.strip()] - # 2. Finde alle Indizes, die wie eine Mengenangabe mit Einheit aussehen - # Erkennt: "1,375 Tag(e)", "2 Std.", "10 Stk.", "1,00 StĂŒck" - unit_pattern = re.compile(r"^(\d+(?:,\d+)?)\s*(Tag\(e\)|Std\.|Stk\.|Stunden|StĂŒck)$", re.IGNORECASE) + unit_pattern = re.compile(r"^(\d+(?:[\.,]\d+)?)\s*(Tag\(e\)|Std\.|Stk\.|Stunden|StĂŒck|stk|stck|stĂŒck)$", re.IGNORECASE) + price_stop_pattern = re.compile(r"^[\d\.]+,[\d]{2}$") for i, line in enumerate(lines): match = unit_pattern.match(line) @@ -66,42 +68,32 @@ def extract_invoice_items_pure_python(text): raw_qty = match.group(1) raw_unit = match.group(2) - # Jetzt schauen wir uns die Zeilen direkt *danach* an, um die Preise zu finden - # Meistens folgt: Einzelpreis (Zeile i+1) und Gesamtpreis (Zeile i+2) raw_price = "0,00" - if i + 1 < len(lines): - # PrĂŒfen, ob die nĂ€chste Zeile ein Geldbetrag ist (z.B. "1.300,00") - if re.match(r"^[\d.,]+$", lines[i+1]): - raw_price = lines[i+1] + for k in range(1, 3): + if i + k < len(lines): + candidate = lines[i+k].replace("€", "").replace(" ", "").strip() + if re.match(r"^[\d.,]+$", candidate): + raw_price = candidate + break - # Jetzt rekonstruieren wir die Beschreibung, indem wir *rĂŒckwĂ€rts* gehen - # Wir sammeln alle Textzeilen oberhalb der Menge ein, bis wir zur nĂ€chsten Position - # oder TabellenĂŒberschriften gelangen. desc_lines = [] for j in range(i - 1, -1, -1): prev_line = lines[j] - - # Abbruchbedingungen (Wann gehört der Text nicht mehr zur Beschreibung?) - if unit_pattern.match(prev_line): # Vorherige Menge/Einheit erreicht - break - if any(w in prev_line.lower() for w in ["gesamt", "zwischensumme", "endbetrag", "ust.", "absender", "seite", "rechnung"]): - break - # Wenn es ein reiner Preis der vorigen Position ist, ĂŒberspringen - if re.match(r"^[\d.,]+$", prev_line) and j < i - 1: + if unit_pattern.match(prev_line) or price_stop_pattern.match(prev_line): break + prev_lower = prev_line.lower() + if prev_lower in ["pos.", "pos", "bezeichnung", "beschreibung", "menge", "einheit", "einzelpreis", "gesamtpreis", "betrag €", "betrag", "artikel"]: + break + if any(prev_lower.startswith(w) for w in ["zwischensumme", "endbetrag", "gesamtbetrag", "umsatzsteuer"]): + break + if prev_line.isdigit() and len(prev_line) <= 2: + continue + desc_lines.insert(0, prev_line) - - # Falls am Anfang eine reine Positionsnummer steht (z.B. "1"), putzen wir sie weg - if desc_lines and desc_lines[0].isdigit(): - desc_lines.pop(0) description = " ".join(desc_lines).strip() - # Sicherheitscheck: Schließe Summenzeilen aus, falls Regex falsch angeschlagen hat - if any(w in description.lower() for w in ["zwischensumme", "endbetrag", "gesamt"]): - continue - if description: items.append({ "description": description, @@ -136,6 +128,7 @@ def generate_compliant_xrechnung(data): root = ET.Element(f"{{{INVOICE_NS}}}Invoice") + # BT-24 ID angepasst auf den exakten Core-String ET.SubElement(root, f"{{{CBC}}}CustomizationID").text = "urn:cen.eu:en16931:2017#compliant#urn:xoev-de:kosit:standard:xrechnung_3.0" ET.SubElement(root, f"{{{CBC}}}ProfileID").text = "urn:fdc:peppol.eu:poacc:bis:invoice:3" ET.SubElement(root, f"{{{CBC}}}ID").text = str(data["invoice_number"]) @@ -155,24 +148,33 @@ def generate_compliant_xrechnung(data): vat_rate = float(data['vat_rate_percent']) vat_category = "S" if vat_rate > 0.0 else "E" - # --- SENDER --- + # --- SENDER (SCITECH) --- supplier = ET.SubElement(root, f"{{{CAC}}}AccountingSupplierParty") party_supp = ET.SubElement(supplier, f"{{{CAC}}}Party") ET.SubElement(party_supp, f"{{{CBC}}}EndpointID", {"schemeID": "EM"}).text = "office@scitech-gmbh.de" party_name_supp = ET.SubElement(party_supp, f"{{{CAC}}}PartyName") ET.SubElement(party_name_supp, f"{{{CBC}}}Name").text = "scitech it solutions GmbH" + addr_supp = ET.SubElement(party_supp, f"{{{CAC}}}PostalAddress") ET.SubElement(addr_supp, f"{{{CBC}}}StreetName").text = "Hermann-Schumann-Str. 4" ET.SubElement(addr_supp, f"{{{CBC}}}CityName").text = "Hennigsdorf" ET.SubElement(addr_supp, f"{{{CBC}}}PostalZone").text = "16761" country_supp = ET.SubElement(addr_supp, f"{{{CAC}}}Country") ET.SubElement(country_supp, f"{{{CBC}}}IdentificationCode").text = "DE" + tax_scheme_supp = ET.SubElement(party_supp, f"{{{CAC}}}PartyTaxScheme") ET.SubElement(tax_scheme_supp, f"{{{CBC}}}CompanyID").text = "DE218436692" scheme_supp = ET.SubElement(tax_scheme_supp, f"{{{CAC}}}TaxScheme") ET.SubElement(scheme_supp, f"{{{CBC}}}ID").text = "VAT" + legal_supp = ET.SubElement(party_supp, f"{{{CAC}}}PartyLegalEntity") ET.SubElement(legal_supp, f"{{{CBC}}}RegistrationName").text = "scitech it solutions GmbH" + + # FIX: HinzufĂŒgen der Gruppe "SELLER CONTACT" (BG-6) zur Behebung von BR-DE-2 + contact_supp = ET.SubElement(party_supp, f"{{{CAC}}}Contact") + ET.SubElement(contact_supp, f"{{{CBC}}}Name").text = "GeschĂ€ftsfĂŒhrung" + ET.SubElement(contact_supp, f"{{{CBC}}}Telephone").text = "+49 3302 4940537" + ET.SubElement(contact_supp, f"{{{CBC}}}ElectronicMail").text = "office@scitech-gmbh.de" # --- RECIPIENT --- customer = ET.SubElement(root, f"{{{CAC}}}AccountingCustomerParty") @@ -212,7 +214,7 @@ def generate_compliant_xrechnung(data): monetary_total = ET.SubElement(root, f"{{{CAC}}}LegalMonetaryTotal") ET.SubElement(monetary_total, f"{{{CBC}}}LineExtensionAmount", {"currencyID": "EUR"}).text = f"{data['total_net_amount']:.2f}" ET.SubElement(monetary_total, f"{{{CBC}}}TaxExclusiveAmount", {"currencyID": "EUR"}).text = f"{data['total_net_amount']:.2f}" - ET.SubElement(monetary_total, f"{{{CAC}}}TaxInclusiveAmount", {"currencyID": "EUR"}).text = f"{data['total_gross_amount']:.2f}" + ET.SubElement(monetary_total, f"{{{CBC}}}TaxInclusiveAmount", {"currencyID": "EUR"}).text = f"{data['total_gross_amount']:.2f}" ET.SubElement(monetary_total, f"{{{CBC}}}PayableAmount", {"currencyID": "EUR"}).text = f"{data['total_gross_amount']:.2f}" for index, item in enumerate(data["items"], start=1): @@ -243,13 +245,13 @@ def generate_compliant_xrechnung(data): # ========================================== -# 3. ABSOLUTE MODELL-LADUNG (NUR METADATEN) +# 3. ABSOLUTE MODELL-LADUNG (QWEN 3.5 INSTRUCT) # ========================================== def get_cached_llm(): if "llm_instance" not in st.session_state: with st.spinner("🚀 INITIAL: Stammdaten-Modell wird geladen..."): base_dir = os.path.dirname(os.path.abspath(__file__)) - model_path = os.path.join(base_dir, "models", "Qwen2.5-VL-3B-Instruct-Q8_0.gguf") + model_path = os.path.join(base_dir, "models", "Qwen3.5-4B-Instruct-Q8_0.gguf") st.session_state.llm_instance = Llama(model_path=model_path, n_ctx=2048, n_gpu_layers=-1, n_threads=8) return st.session_state.llm_instance @@ -258,7 +260,7 @@ def get_cached_llm(): # 4. STREAMLIT FRONTEND # ========================================== st.set_page_config(page_title="PDF zu E-Rechnung", layout="wide") -st.title("📄 Hybrid PDF zu E-Rechnung Konverter") +st.title("📄 Enterprise PDF zu E-Rechnung Konverter") llm = get_cached_llm() col1, col2 = st.columns(2) @@ -279,7 +281,7 @@ with col2: if "extracted_data" not in st.session_state: st.session_state.extracted_data = None if uploaded_file is not None and pdf_text.strip() != "": - if st.button("🚀 Daten extrahieren (Optimierte native Tabelle)", width="stretch"): + if st.button("🚀 Daten extrahieren (Kugelsichere Hybrid-Methode)", width="stretch"): with st.spinner("Extrahiere Daten..."): # STUFE 1: Stammdaten via KI @@ -294,7 +296,7 @@ with col2: if final_data.get("recipient_email") == "office@scitech-gmbh.de": final_data["recipient_email"] = "" - # STUFE 2: Zeilenbasiertes Python-Parsing der Tabelle (Robust gegen vertikale Verschiebungen) + # STUFE 2: Zeilenbasiertes Python-Parsing fĂŒr die Tabelle final_data["items"] = extract_invoice_items_pure_python(pdf_text) st.session_state.extracted_data = final_data @@ -326,12 +328,19 @@ with col2: edited_items = st.data_editor(data.get("items", []), num_rows="dynamic", width="stretch") vat_rate = st.number_input("Steuersatz (%)", value=float(data.get("vat_rate_percent") or 19.0)) + # Live-Berechnung inklusive kaufmĂ€nnischem Runden auf Positionsebene for item in edited_items: - item["total_amount"] = float(item.get("quantity") or 0.0) * float(item.get("unit_price") or 0.0) + raw_total = float(item.get("quantity") or 0.0) * float(item.get("unit_price") or 0.0) + item["total_amount"] = kaufmaennisches_runden(raw_total) + computed_net_amount = sum(item["total_amount"] for item in edited_items) - computed_vat_amount = computed_net_amount * (vat_rate / 100.0) - computed_gross_amount = computed_net_amount + computed_vat_amount + # KaufmĂ€nnisches Runden fĂŒr die Umsatzsteuer + raw_vat_amount = computed_net_amount * (vat_rate / 100.0) + computed_vat_amount = kaufmaennisches_runden(raw_vat_amount) + + # Brutto setzt sich sauber aus den gerundeten Netto- und MwSt-Werten zusammen + computed_gross_amount = kaufmaennisches_runden(computed_net_amount + computed_vat_amount) st.markdown("**Berechnete Summen:**") c_net_ui, c_vat_ui, c_gross_ui = st.columns(3) diff --git a/requirements.txt b/requirements.txt index aec148b..85eaaa1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,59 +1,4 @@ -altair==6.2.2 -annotated-types==0.7.0 -anyio==4.14.1 -attrs==26.1.0 -blinker==1.9.0 -cachetools==7.1.4 -certifi==2026.6.17 -cffi==2.0.0 -charset-normalizer==3.4.7 -click==8.4.2 -colorama==0.4.6 -cryptography==49.0.0 -diskcache==5.6.3 -gitdb==4.0.12 -GitPython==3.1.50 -h11==0.16.0 -httptools==0.8.0 -idna==3.18 -itsdangerous==2.2.0 -Jinja2==3.1.6 -jsonschema==4.26.0 -jsonschema-specifications==2025.9.1 -llama_cpp_python==0.3.32 -MarkupSafe==3.0.3 -narwhals==2.22.1 -ninja==1.13.0 -numpy==2.5.0 -packaging==26.2 -pandas==3.0.3 -pdfminer.six==20260107 -pdfplumber==0.11.10 -pillow==12.2.0 -protobuf==7.35.1 -pyarrow==24.0.0 -pycparser==3.0 -pydantic==2.13.4 -pydantic_core==2.46.4 -pydeck==0.9.2 -pymupdf==1.28.0 -pypdf==6.14.2 -pypdfium2==5.11.0 -python-dateutil==2.9.0.post0 -python-multipart==0.0.32 -referencing==0.37.0 -requests==2.34.2 -rpds-py==2026.6.3 -six==1.17.0 -smmap==5.0.3 -starlette==1.3.1 streamlit==1.58.0 -tenacity==9.1.4 -toml==0.10.2 -typing-inspection==0.4.2 -typing_extensions==4.15.0 -tzdata==2026.2 -urllib3==2.7.0 -uvicorn==0.49.0 -watchdog==6.0.0 -websockets==16.0 +llama_cpp_python==0.3.32 +pydantic==2.13.4 +pymupdf==1.28.0 \ No newline at end of file