diff --git a/.env.example b/.env.example index 7dcf14c..6b5bed4 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,9 @@ DB_PASS=steuerkram # OLLAMA_URL=http://localhost:11434 # OLLAMA_MODEL=llama3.2 # +# Vision-Modell für Beleglesung (multimodales Ollama-Modell, benötigt OLLAMA_URL) +# VISION_MODEL=qwen2.5vl:latest +# # Option B: Remote-LLM per OpenAI-kompatibler API (z.B. OpenAI, OpenRouter, LM Studio) # LLM_API_URL=https://api.openai.com/v1 # LLM_API_KEY=sk-... diff --git a/Changes.md b/Changes.md index 68048db..092b62a 100644 --- a/Changes.md +++ b/Changes.md @@ -2,6 +2,14 @@ --- +## 2026-04-15 – KI-Erkennung: automatischer Aufruf aus Belegansicht entfernt + +### Geändert +- `src/Views/belege/detail.php`: KI-Analyse wird nicht mehr automatisch beim Öffnen eines Belegs ausgelöst; stattdessen wird ein Hinweis "KI-Analyse wird im Hintergrund durchgeführt …" mit einem "Jetzt generieren"-Button angezeigt, wenn noch kein Vorschlag vorhanden ist +- Hintergrund: Die Analyse läuft bereits beim Nextcloud-Import (`ki-batch.php`); ein doppelter Aufruf aus der UI war überflüssig + +--- + ## 2026-04-12 – EÜR-Korrekturen: Gewerbesteuer + Bewirtungskonten ohne VSt ### Geändert diff --git a/docs/konfiguration.md b/docs/konfiguration.md index 77bc1cf..ff89fd0 100644 --- a/docs/konfiguration.md +++ b/docs/konfiguration.md @@ -68,7 +68,7 @@ Belege können automatisch aus einem Nextcloud-Ordner via WebDAV importiert werd Die Zugangsdaten werden in der Steuerkram-UI gepflegt: **Einstellungen → Nextcloud-Belegimport**. -Empfehlung: Ein Nextcloud-App-Passwort verwenden (Nextcloud → Einstellungen → Sicherheit → App-Passwörter). +Hinweis: Ein Nextcloud-App-Passwort verwenden (Nextcloud → Einstellungen → Sicherheit → App-Passwörter). ### Manueller Aufruf diff --git a/scripts/ki-batch.php b/scripts/ki-batch.php new file mode 100644 index 0000000..114b454 --- /dev/null +++ b/scripts/ki-batch.php @@ -0,0 +1,104 @@ +> /var/log/steuerkram-ki.log 2>&1 + */ + +declare(strict_types=1); + +chdir(dirname(__DIR__)); + +// DB-Zugangsdaten aus .env laden (für CLI-Kontext ohne Apache-Umgebung) +if (file_exists('.env')) { + foreach (file('.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { + if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) { + continue; + } + [$key, $value] = explode('=', $line, 2); + $key = trim($key); + $value = trim($value, " \t\n\r\0\x0B\"'"); + if (!array_key_exists($key, $_ENV)) { + $_ENV[$key] = $value; + putenv("{$key}={$value}"); + } + } +} + +require_once 'vendor/autoload.php'; + +use Steuerkram\Config; +use Steuerkram\Database; +use Steuerkram\Models\Beleg; +use Steuerkram\Models\Setting; +use Steuerkram\Services\KontierungsvorschlagService; + +Config::load(); + +$tag = '[ki-batch]'; + +// KI-Erkennung in den Einstellungen aktiv? +$settings = Setting::get(); +if (empty($settings['ki_vorschlag_aktiv'])) { + echo "{$tag} KI-Erkennung ist in den Einstellungen deaktiviert – nichts zu tun." . PHP_EOL; + exit(0); +} + +$svc = new KontierungsvorschlagService(); + +if (!$svc->isEnabled()) { + echo "{$tag} LLM nicht erreichbar – Abbruch." . PHP_EOL; + exit(1); +} + +// Optionale Beleg-ID als CLI-Argument: php ki-batch.php 123 +$argId = isset($argv[1]) && ctype_digit((string)$argv[1]) ? (int)$argv[1] : null; + +// Beleg holen: wenn ID angegeben, diesen direkt verarbeiten; sonst ältesten ausstehenden +$row = $argId !== null + ? Database::fetchOne('SELECT id FROM belege WHERE id = ? AND ki_vorschlag IS NULL AND locked = 0', [$argId]) + : Database::fetchOne('SELECT id FROM belege WHERE ki_vorschlag IS NULL AND locked = 0 ORDER BY id ASC LIMIT 1'); + +if (!$row) { + echo "{$tag} Kein ausstehender Beleg gefunden." . PHP_EOL; + exit(0); +} + +$belegId = (int)$row['id']; +$beleg = Beleg::find($belegId); + +if (!$beleg) { + echo "{$tag} Beleg #{$belegId} nicht ladbar – Abbruch." . PHP_EOL; + exit(1); +} + +echo "{$tag} Verarbeite Beleg #{$belegId} ({$beleg['beleg_nr']}) ..." . PHP_EOL; + +$start = microtime(true); +$result = $svc->generateForBeleg($beleg); + +if ($result === null) { + echo "{$tag} Beleg #{$belegId}: Kein Vorschlag generiert (nicht unterstützter Typ oder LLM-Fehler)." . PHP_EOL; + // Leeres Array speichern damit der Beleg nicht erneut verarbeitet wird + Beleg::saveKiVorschlag($belegId, []); + exit(0); +} + +Beleg::saveKiVorschlag($belegId, $result); + +$elapsed = round(microtime(true) - $start, 1); +$zeilen = count($result); +echo "{$tag} Beleg #{$belegId}: {$zeilen} Buchungszeile(n) generiert in {$elapsed}s." . PHP_EOL; + +exit(0); diff --git a/scripts/nextcloud-import.php b/scripts/nextcloud-import.php index 89964d8..11c7107 100644 --- a/scripts/nextcloud-import.php +++ b/scripts/nextcloud-import.php @@ -41,6 +41,7 @@ if (file_exists('.env')) { require_once 'vendor/autoload.php'; use Steuerkram\Config; +use Steuerkram\Models\Setting; use Steuerkram\Services\NextcloudImportService; Config::load(); // Database verbindet sich lazy über Config::get() @@ -64,4 +65,16 @@ echo sprintf( $result['errors'], ); +// KI-Vorschlag für jeden neu importierten Beleg anstoßen (wenn KI-Erkennung aktiv) +if (!empty($result['imported_ids'])) { + $settings = Setting::get(); + if (!empty($settings['ki_vorschlag_aktiv'])) { + $kiBatch = __DIR__ . '/ki-batch.php'; + echo '[nextcloud-import] Starte KI-Erkennung für ' . count($result['imported_ids']) . ' importierte(n) Beleg(e) ...' . PHP_EOL; + foreach ($result['imported_ids'] as $belegId) { + passthru('php ' . escapeshellarg($kiBatch) . ' ' . (int)$belegId . ' 2>&1'); + } + } +} + exit($result['errors'] > 0 ? 1 : 0); diff --git a/src/Controllers/BelegController.php b/src/Controllers/BelegController.php index 8a4448d..9e52d80 100644 --- a/src/Controllers/BelegController.php +++ b/src/Controllers/BelegController.php @@ -204,6 +204,8 @@ class BelegController $kiVorschlag = $beleg['ki_vorschlag'] !== null ? json_decode($beleg['ki_vorschlag'], true) : null; + // KI-Sektion anzeigen wenn Ergebnis vorhanden ODER LLM gerade verfügbar + $kiSektion = $kiAktiv || $kiVorschlag !== null; $view = new View(); $view->render('layout/base.php', [ @@ -215,6 +217,7 @@ class BelegController 'integrity' => $integrity, 'accounts' => $accounts, 'kiAktiv' => $kiAktiv, + 'kiSektion' => $kiSektion, 'kiVorschlag' => $kiVorschlag, ]), ]); diff --git a/src/Services/LlmService.php b/src/Services/LlmService.php index eb34f28..22b9007 100644 --- a/src/Services/LlmService.php +++ b/src/Services/LlmService.php @@ -15,10 +15,11 @@ namespace Steuerkram\Services; */ class LlmService { - private ?string $mode = null; // 'ollama' | 'openai' | null - private string $url = ''; - private string $model = ''; - private ?string $apiKey = null; + private ?string $mode = null; // 'ollama' | 'openai' | null + private string $url = ''; + private string $model = ''; + private string $visionModel = ''; + private ?string $apiKey = null; public function __construct() { @@ -30,6 +31,11 @@ class LlmService return $this->mode !== null; } + public function isVisionAvailable(): bool + { + return $this->mode === 'ollama'; + } + public function getProviderLabel(): ?string { return match($this->mode) { @@ -48,6 +54,45 @@ class LlmService }; } + /** + * Sendet Bilder (base64-kodiert) zusammen mit einem Prompt an das Vision-Modell. + * Nur im Ollama-Modus verfügbar. Gibt '' zurück wenn Vision nicht verfügbar. + * + * @param string $prompt + * @param string[] $base64Images Array von base64-kodierten Bildern (JPEG/PNG) + */ + public function generateVision(string $prompt, array $base64Images): string + { + if ($this->mode !== 'ollama' || empty($base64Images)) return ''; + + $payload = [ + 'model' => $this->visionModel, + 'messages' => [[ + 'role' => 'user', + 'content' => $prompt, + 'images' => $base64Images, + ]], + 'stream' => false, + 'options' => ['temperature' => 0.1], + ]; + + $ch = curl_init($this->url . '/api/chat'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 300, + CURLOPT_HTTPHEADER => ['Content-Type: application/json'], + CURLOPT_POSTFIELDS => json_encode($payload), + ]); + $response = curl_exec($ch); + $err = curl_error($ch); + curl_close($ch); + + if ($err || !$response) return ''; + $data = json_decode($response, true); + return $data['message']['content'] ?? ''; + } + private function detect(): void { $ollamaUrl = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? ''); @@ -55,9 +100,10 @@ class LlmService $apiKey = getenv('LLM_API_KEY') ?: ($_ENV['LLM_API_KEY'] ?? ''); if ($ollamaUrl !== '') { - $this->mode = 'ollama'; - $this->url = rtrim($ollamaUrl, '/'); - $this->model = getenv('OLLAMA_MODEL') ?: ($_ENV['OLLAMA_MODEL'] ?? 'llama3.2'); + $this->mode = 'ollama'; + $this->url = rtrim($ollamaUrl, '/'); + $this->model = getenv('OLLAMA_MODEL') ?: ($_ENV['OLLAMA_MODEL'] ?? 'llama3.2'); + $this->visionModel = getenv('VISION_MODEL') ?: ($_ENV['VISION_MODEL'] ?? 'qwen2.5vl:latest'); return; } diff --git a/src/Services/NextcloudImportService.php b/src/Services/NextcloudImportService.php index 5d48d9b..6c87641 100644 --- a/src/Services/NextcloudImportService.php +++ b/src/Services/NextcloudImportService.php @@ -69,7 +69,7 @@ class NextcloudImportService */ public function import(): array { - $result = ['imported' => 0, 'skipped' => 0, 'errors' => 0, 'details' => []]; + $result = ['imported' => 0, 'skipped' => 0, 'errors' => 0, 'details' => [], 'imported_ids' => []]; if (!$this->baseUrl || !$this->user || !$this->pass) { $result['details'][] = 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.'; @@ -262,7 +262,7 @@ class NextcloudImportService // In Datenbank anlegen $today = date('Y-m-d'); - Beleg::create([ + $belegId = Beleg::create([ 'beleg_nr' => $belegNr, 'filename' => $filename, 'storage_path' => $storagePath, @@ -280,6 +280,7 @@ class NextcloudImportService $result['details'][] = "Importiert: {$filename} → Beleg {$belegNr}"; $result['imported']++; + $result['imported_ids'][] = $belegId; } /** diff --git a/src/Views/belege/detail.php b/src/Views/belege/detail.php index a37633e..1cda943 100644 --- a/src/Views/belege/detail.php +++ b/src/Views/belege/detail.php @@ -271,7 +271,7 @@ $suppliers = $suppliers ?? []; - +
@@ -630,9 +630,10 @@ function updateNettoAnzeige() { } - + diff --git a/src/Views/layout/nav.php b/src/Views/layout/nav.php index ba4433a..3003041 100644 --- a/src/Views/layout/nav.php +++ b/src/Views/layout/nav.php @@ -5,7 +5,7 @@ function navActive(string $prefix, string $current): string { } ?>