From b51124fd0a695fa2495e2c2a59142c4c4bba838c Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Wed, 15 Apr 2026 19:59:01 +0200 Subject: [PATCH] Refactor: OCR/Tesseract-Fallback entfernt, KI-Doku aktualisiert --- docs/konfiguration.md | 17 +- src/Models/Setting.php | 3 +- src/Services/KontierungsvorschlagService.php | 307 +++++++++++++++---- src/Views/settings/form.php | 19 +- 4 files changed, 262 insertions(+), 84 deletions(-) diff --git a/docs/konfiguration.md b/docs/konfiguration.md index d73d895..77bc1cf 100644 --- a/docs/konfiguration.md +++ b/docs/konfiguration.md @@ -20,23 +20,30 @@ Das Format ist immer `{Präfix}-{Jahr}-{laufende Nummer}`. Die laufende Nummer w --- -## KI-Beleganalyse einrichten +## KI-Kontierungsvorschlag einrichten -Die KI-gestützte Belegerkennung ist standardmäßig deaktiviert. Sie kann unter **Einstellungen → KI-Kontierungsvorschlag** aktiviert werden, sobald ein LLM erreichbar ist. +Der KI-Kontierungsvorschlag analysiert Belege automatisch beim Nextcloud-Import und füllt das Buchungsformular mit einem Vorschlag vor. Die Funktion ist standardmäßig deaktiviert und kann unter **Einstellungen → KI-Kontierungsvorschlag** aktiviert werden. + +### Voraussetzungen + +- Nextcloud-Import muss konfiguriert und aktiv sein (siehe unten) +- Ein erreichbares LLM (lokal via Ollama oder Remote-API) +- Für Bildbelege (JPEG, PNG, TIFF): ein Vision-fähiges Modell (z.B. `qwen2.5vl`, `llava`); ohne Vision-Unterstützung werden Bildbelege ohne Textextraktion verarbeitet +- Für PDF-Belege: `pdftotext` und `pdftoppm` (Teil von `poppler-utils`, im Docker-Image enthalten) ### Option A – Lokales Ollama Kein API-Schlüssel nötig, aber hoher Ressourcenbedarf: ```bash -ollama pull mistral # oder llama3.2 etc. +ollama pull qwen2.5vl # Vision-fähig (empfohlen), alternativ mistral o.ä. ``` In `.env` ist keine weitere Konfiguration nötig – die App erkennt Ollama auf `localhost:11434` automatisch. Modell und URL können optional überschrieben werden: ```dotenv OLLAMA_URL=http://localhost:11434 -OLLAMA_MODEL=mistral +OLLAMA_MODEL=qwen2.5vl ``` ### Option B – Remote-API (OpenAI-kompatibel) @@ -49,7 +56,7 @@ LLM_API_KEY=sk-... LLM_MODEL=gpt-4o-mini ``` -> **Hinweis:** Die Ergebnisqualität hängt stark vom gewählten Modell ab. Tests mit `mistral` (lokal via Ollama) lieferten bisher mäßige Ergebnisse. +> **Hinweis:** Die Ergebnisqualität hängt stark vom gewählten Modell ab. Für zuverlässige Ergebnisse wird ein Vision-fähiges Modell empfohlen. --- diff --git a/src/Models/Setting.php b/src/Models/Setting.php index c112139..e7e3356 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -33,7 +33,7 @@ class Setting iban=?, bic=?, bank_name=?, ustva_steuernummer=?, ustva_bundesland=?, invoice_nr_prefix=?, beleg_nr_prefix=?, - payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?, ocr_aktiv=?, + payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?, nextcloud_url=?, nextcloud_user=?, nextcloud_pass=?, nextcloud_folder=?, nextcloud_done=?, logo_max_width=?, logo_max_height=?, @@ -61,7 +61,6 @@ class Setting (int)($data['payment_days_default'] ?? 14), isset($data['test_mode']) ? 1 : 0, isset($data['ki_vorschlag_aktiv']) ? 1 : 0, - isset($data['ocr_aktiv']) ? 1 : 0, $data['nextcloud_url'] ?: null, $data['nextcloud_user'] ?: null, $data['nextcloud_pass'] ?: null, diff --git a/src/Services/KontierungsvorschlagService.php b/src/Services/KontierungsvorschlagService.php index 944c132..27a6dc7 100644 --- a/src/Services/KontierungsvorschlagService.php +++ b/src/Services/KontierungsvorschlagService.php @@ -24,12 +24,6 @@ class KontierungsvorschlagService return (bool)($s['ki_vorschlag_aktiv'] ?? 0) && $this->llm->isAvailable(); } - public function isOcrEnabled(): bool - { - $s = Setting::get(); - return (bool)($s['ocr_aktiv'] ?? 1); - } - public function extractText(array $beleg): string { $absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path']; @@ -38,14 +32,33 @@ class KontierungsvorschlagService $mime = strtolower($beleg['mime_type'] ?? ''); if ($mime === 'application/pdf') { + // 1. eRechnung-XML (ZUGFeRD / Factur-X / XRechnung) — strukturiert, fehlerarm + $xml = $this->extractErechnungXml($absPath); + if ($xml !== '') return $xml; + + // 2. Vision-Modell (qwen2.5vl o.ä.) + if ($this->llm->isVisionAvailable()) { + $vision = $this->extractPdfVision($absPath); + if ($vision !== '') return $vision; + } + + // 3. Fallback: pdftotext return $this->extractPdfText($absPath); - } elseif (in_array($mime, ['image/jpeg', 'image/png', 'image/tiff'], true)) { - return $this->isOcrEnabled() ? $this->extractImageText($absPath) : ''; + } + + if (in_array($mime, ['image/jpeg', 'image/png', 'image/tiff'], true)) { + if (!$this->llm->isVisionAvailable()) return ''; + return $this->extractImageVision($absPath); } return ''; } + /** + * Erstellt einen Kontierungsvorschlag für den Beleg. + * Gibt ein Array von Buchungszeilen zurück (eine je MwSt-Satz), + * oder null bei Fehler / nicht unterstütztem MIME-Typ. + */ public function generateForBeleg(array $beleg): ?array { $text = $this->extractText($beleg); @@ -61,7 +74,7 @@ class KontierungsvorschlagService } // Konten laden (max. 50 relevante Konten für kurzen Prompt) - $allAccounts = Account::all(['aktiv' => 1]); + $allAccounts = Account::all(['aktiv' => 1]); $kategorieKonten = array_slice(array_values(array_filter( $allAccounts, fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true) @@ -82,20 +95,20 @@ class KontierungsvorschlagService )); $beleginhalt = trim($text) !== '' - ? "Belegtext:\n" . mb_substr(trim($text), 0, 2000) + ? "Belegtext:\n" . mb_substr(trim($text), 0, 3000) : '[Kein Text extrahierbar]'; - $settings = Setting::get(); - $eigenerName = trim(($settings['firma'] ?? '') ?: ($settings['inhaber'] ?? '')); + $settings = Setting::get(); + $eigenerName = trim(($settings['firma'] ?? '') ?: ($settings['inhaber'] ?? '')); $eigenerHinweis = $eigenerName !== '' - ? "\n- supplier_name: NICHT \"{$eigenerName}\" (das ist der Rechnungsempfaenger, nicht der Lieferant). Bei Ausgabe = Aussteller/Verkaeuer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar." - : "\n- supplier_name: Bei Ausgabe = Aussteller/Verkaeuer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar."; + ? "\n- supplier_name: NICHT \"{$eigenerName}\" (das ist der Rechnungsempfaenger, nicht der Lieferant). Bei Ausgabe = Aussteller/Verkaeufer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar." + : "\n- supplier_name: Bei Ausgabe = Aussteller/Verkaeufer der Rechnung; bei Einnahme = Kaeufer/Auftraggeber. Leer lassen wenn nicht erkennbar."; $prompt = <<parseAndValidate($response, $allAccounts); } + // ------------------------------------------------------------------------- + // Text-Extraktion + // ------------------------------------------------------------------------- + + /** + * Extrahiert eingebettete eRechnung-XML (ZUGFeRD / Factur-X / XRechnung). + * Gibt XML-Inhalt (max. 12.000 Zeichen) zurück, oder '' wenn keiner gefunden. + */ + private function extractErechnungXml(string $path): string + { + // pdfdetach ist Teil von poppler-utils (zusammen mit pdftotext installiert) + $safe = escapeshellarg($path); + exec("pdfdetach -list {$safe} 2>/dev/null", $listOutput, $rc); + if ($rc !== 0 || empty($listOutput)) return ''; + + $hasXml = false; + foreach ($listOutput as $line) { + if (stripos($line, '.xml') !== false) { + $hasXml = true; + break; + } + } + if (!$hasXml) return ''; + + $tmpDir = sys_get_temp_dir() . '/erechnung_' . uniqid(); + $safeTmp = escapeshellarg($tmpDir); + mkdir($tmpDir, 0700, true); + + exec("pdfdetach -saveall -o {$safeTmp} {$safe} 2>/dev/null"); + + $xmlContent = ''; + $candidates = [ + "$tmpDir/factur-x.xml", + "$tmpDir/ZUGFeRD-invoice.xml", + "$tmpDir/zugferd-invoice.xml", + "$tmpDir/xrechnung.xml", + ]; + // Bekannte Namen zuerst, dann alle .xml-Dateien im tmpDir + foreach (array_merge($candidates, glob("$tmpDir/*.xml") ?: [], glob("$tmpDir/*.XML") ?: []) as $f) { + if (file_exists($f)) { + $xmlContent = file_get_contents($f); + break; + } + } + + // Cleanup + foreach (glob("$tmpDir/*") ?: [] as $f) { + @unlink($f); + } + @rmdir($tmpDir); + + return mb_substr($xmlContent, 0, 12000); + } + + /** + * Konvertiert PDF-Seiten zu Bildern (pdftoppm) und schickt sie ans Vision-Modell. + * Fallback auf pdftotext wenn Vision nicht verfügbar oder fehlschlägt. + */ + private function extractPdfVision(string $path): string + { + $tmpDir = sys_get_temp_dir() . '/vision_' . uniqid(); + $safeTmp = escapeshellarg($tmpDir . '/page'); + $safe = escapeshellarg($path); + mkdir($tmpDir, 0700, true); + + // pdftoppm ist Teil von poppler-utils; auf 800px Breite begrenzen (Ollama-Limit) + exec("pdftoppm -jpeg -scale-to-x 800 -scale-to-y -1 {$safe} {$tmpDir}/page 2>/dev/null", $out, $rc); + + $images = glob("$tmpDir/page*.jpg") ?: glob("$tmpDir/page*.jpeg") ?: []; + sort($images); + + if ($rc !== 0 || empty($images)) { + @rmdir($tmpDir); + return ''; + } + + $base64 = []; + foreach ($images as $img) { + $base64[] = base64_encode(file_get_contents($img)); + @unlink($img); + } + @rmdir($tmpDir); + + $prompt = 'Extrahiere den vollstaendigen Text dieses Belegs. ' + . 'Behalte die Tabellenstruktur bei (Spalten mit Leerzeichen ausrichten). ' + . 'Antworte nur mit dem extrahierten Text, ohne Kommentar oder Erklaerung.'; + + return $this->llm->generateVision($prompt, $base64); + } + + /** + * Schickt ein Bild (JPEG/PNG/TIFF) ans Vision-Modell. + * Skaliert auf max. 800px Breite damit der Payload nicht zu groß wird. + */ + private function extractImageVision(string $path): string + { + $base64 = $this->resizeImageBase64($path, 800); + if ($base64 === '') return ''; + + $prompt = 'Extrahiere den vollstaendigen Text dieses Belegs. ' + . 'Behalte die Tabellenstruktur bei. ' + . 'Antworte nur mit dem extrahierten Text, ohne Kommentar oder Erklaerung.'; + + return $this->llm->generateVision($prompt, [$base64]); + } + + /** + * Liest ein Bild, skaliert es auf maxWidth Pixel Breite und gibt es als base64-JPEG zurück. + */ + private function resizeImageBase64(string $path, int $maxWidth): string + { + // pdftoppm erzeugt JPEG; ImageCropService verwendet GD — GD ist im Container verfügbar + $info = @getimagesize($path); + if (!$info) return ''; + + [$origW, $origH, $type] = $info; + + $src = match ($type) { + IMAGETYPE_JPEG => @imagecreatefromjpeg($path), + IMAGETYPE_PNG => @imagecreatefrompng($path), + IMAGETYPE_TIFF_II, IMAGETYPE_TIFF_MM => null, // GD unterstützt kein TIFF + default => null, + }; + + if (!$src) { + // Kein GD-Support (z. B. TIFF) → direkt senden, auch wenn groß + return base64_encode(file_get_contents($path)); + } + + if ($origW <= $maxWidth) { + // Bereits klein genug → direkt als JPEG-Base64 + ob_start(); + imagejpeg($src, null, 85); + $jpeg = ob_get_clean(); + imagedestroy($src); + return base64_encode($jpeg); + } + + $scale = $maxWidth / $origW; + $newW = $maxWidth; + $newH = (int)round($origH * $scale); + $dst = imagecreatetruecolor($newW, $newH); + imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $origW, $origH); + imagedestroy($src); + + ob_start(); + imagejpeg($dst, null, 85); + $jpeg = ob_get_clean(); + imagedestroy($dst); + + return base64_encode($jpeg); + } + private function extractPdfText(string $path): string { - $safe = escapeshellarg($path); + $safe = escapeshellarg($path); $output = []; - exec("pdftotext -l 1 {$safe} -", $output, $returnCode); + exec("pdftotext {$safe} -", $output, $returnCode); return $returnCode === 0 ? implode("\n", $output) : ''; } - private function extractImageText(string $path): string - { - $tmpBase = sys_get_temp_dir() . '/ocr_' . uniqid(); - $safe = escapeshellarg($path); - $safeTmp = escapeshellarg($tmpBase); - exec("tesseract {$safe} {$safeTmp} -l deu+eng quiet 2>/dev/null", $out, $rc); - $txtFile = $tmpBase . '.txt'; - if ($rc === 0 && file_exists($txtFile)) { - $text = file_get_contents($txtFile); - unlink($txtFile); - return $text ?: ''; - } - return ''; - } + // ------------------------------------------------------------------------- + // JSON-Parsing & Validierung + // ------------------------------------------------------------------------- + /** + * Parst die LLM-Antwort als JSON-Array von Buchungszeilen. + * Akzeptiert auch ein einzelnes JSON-Objekt (Backward-Compat) und wickelt es in ein Array. + * Gibt validiertes Array zurück, oder null bei Parse-/Validierungsfehler. + */ private function parseAndValidate(string $response, array $accounts): ?array { - // JSON aus Antwort extrahieren (Modell könnte Prosa voranstellen) - if (!preg_match('/\{[^{}]*\}/s', $response, $m)) return null; - - $data = json_decode($m[0], true); - if (!is_array($data)) return null; - - $required = ['buchungstyp', 'kategorie_konto_id', 'zahlungskonto_id', 'ust_satz', 'betrag_netto']; - foreach ($required as $field) { - if (!isset($data[$field])) return null; + // Array-Form: [{ ... }, { ... }] + if (preg_match('/\[.*\]/s', $response, $m)) { + $data = json_decode($m[0], true); + if (is_array($data) && isset($data[0]) && is_array($data[0])) { + return $this->validateLines($data, $accounts); + } } - // Werte normalisieren - $data['buchungstyp'] = in_array($data['buchungstyp'], ['Ausgabe', 'Einnahme'], true) ? $data['buchungstyp'] : 'Ausgabe'; - $data['kategorie_konto_id'] = (int)$data['kategorie_konto_id']; - $data['zahlungskonto_id'] = (int)$data['zahlungskonto_id']; - $data['ust_satz'] = in_array((int)$data['ust_satz'], [0, 7, 19], true) ? (int)$data['ust_satz'] : 0; - $data['betrag_netto'] = round((float)$data['betrag_netto'], 2); - $data['buchungstext'] = (string)($data['buchungstext'] ?? ''); - $data['supplier_name'] = trim((string)($data['supplier_name'] ?? '')); + // Fallback: einzelnes Objekt { ... } → in Array einwickeln + if (preg_match('/\{[^{}]*\}/s', $response, $m)) { + $data = json_decode($m[0], true); + if (is_array($data)) { + return $this->validateLines([$data], $accounts); + } + } - // IDs gegen Kontenliste validieren (mit Kontenartenprüfung) + return null; + } + + /** + * Validiert und normalisiert ein Array von Buchungszeilen. + * Gibt nur gültige Zeilen zurück; null wenn keine einzige valide ist. + */ + private function validateLines(array $lines, array $accounts): ?array + { $kategorieIds = array_column(array_filter($accounts, fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true) ), 'id'); $zahlungsIds = array_column(array_filter($accounts, fn($a) => in_array($a['kontenart'], ['Zahlungskonto', 'Verbindlichkeit'], true) ), 'id'); - if (!in_array($data['kategorie_konto_id'], $kategorieIds, false)) return null; - if (!in_array($data['zahlungskonto_id'], $zahlungsIds, false)) return null; - // Lieferant gegen vorhandene Suppliers prüfen - if ($data['supplier_name'] !== '') { - $existing = Supplier::findByName($data['supplier_name']); - $data['supplier_found'] = $existing !== false; - } else { - $data['supplier_found'] = null; + $valid = []; + foreach ($lines as $data) { + if (!is_array($data)) continue; + + $required = ['buchungstyp', 'kategorie_konto_id', 'zahlungskonto_id', 'ust_satz', 'betrag_netto']; + $ok = true; + foreach ($required as $field) { + if (!isset($data[$field])) { $ok = false; break; } + } + if (!$ok) continue; + + // Normalisieren + $data['buchungstyp'] = in_array($data['buchungstyp'], ['Ausgabe', 'Einnahme'], true) ? $data['buchungstyp'] : 'Ausgabe'; + $data['kategorie_konto_id'] = (int)$data['kategorie_konto_id']; + $data['zahlungskonto_id'] = (int)$data['zahlungskonto_id']; + $data['ust_satz'] = in_array((int)$data['ust_satz'], [0, 7, 19], true) ? (int)$data['ust_satz'] : 0; + $data['betrag_netto'] = round((float)$data['betrag_netto'], 2); + $data['buchungstext'] = (string)($data['buchungstext'] ?? ''); + $data['supplier_name'] = trim((string)($data['supplier_name'] ?? '')); + + // Konto-IDs validieren + if (!in_array($data['kategorie_konto_id'], $kategorieIds, false)) continue; + if (!in_array($data['zahlungskonto_id'], $zahlungsIds, false)) continue; + + // Lieferant gegen vorhandene Suppliers prüfen + if ($data['supplier_name'] !== '') { + $existing = Supplier::findByName($data['supplier_name']); + $data['supplier_found'] = $existing !== false; + } else { + $data['supplier_found'] = null; + } + + $valid[] = $data; } - return $data; + return empty($valid) ? null : $valid; } } diff --git a/src/Views/settings/form.php b/src/Views/settings/form.php index d61b970..9ebd281 100644 --- a/src/Views/settings/form.php +++ b/src/Views/settings/form.php @@ -136,7 +136,7 @@ Automatischen Kontierungsvorschlag erzeugen

- Beim ersten Öffnen eines Belegs wird ein LLM befragt und das Buchungsformular vorausgefüllt. + Beim automatischen Nextcloud-Import wird für jeden Beleg ein Kontierungsvorschlag per LLM erzeugt. Konfiguration via .env (lokales Ollama oder Remote-API).

- -
-
OCR-Texterkennung
-
- -

- Bei Belegen im Bildformat (JPEG, PNG, TIFF) wird Tesseract OCR eingesetzt, um den Belegtext - zu extrahieren. Dieser Text wird – sofern der KI-Schalter aktiv ist – an das LLM übergeben. - PDF-Belege werden unabhängig davon per pdftotext ausgelesen. -

-
-
-
Nextcloud-Belegimport