Refactor: KI-Analyse nicht mehr automatisch beim Öffnen eines Belegs

This commit is contained in:
fds2610-bot 2026-04-15 20:21:09 +02:00
parent b51124fd0a
commit b9b88defb8
10 changed files with 253 additions and 35 deletions

View file

@ -11,6 +11,9 @@ DB_PASS=steuerkram
# OLLAMA_URL=http://localhost:11434 # OLLAMA_URL=http://localhost:11434
# OLLAMA_MODEL=llama3.2 # 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) # Option B: Remote-LLM per OpenAI-kompatibler API (z.B. OpenAI, OpenRouter, LM Studio)
# LLM_API_URL=https://api.openai.com/v1 # LLM_API_URL=https://api.openai.com/v1
# LLM_API_KEY=sk-... # LLM_API_KEY=sk-...

View file

@ -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 ## 2026-04-12 – EÜR-Korrekturen: Gewerbesteuer + Bewirtungskonten ohne VSt
### Geändert ### Geändert

View file

@ -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**. 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 ### Manueller Aufruf

104
scripts/ki-batch.php Normal file
View file

@ -0,0 +1,104 @@
<?php
/**
* KI-Kontierungsvorschlag – Batch-Verarbeitung (CLI-Einstiegspunkt).
*
* Verarbeitet EINEN ausstehenden Beleg pro Aufruf (ki_vorschlag IS NULL, locked = 0).
* Verhindert so, dass mehrere Vision-Model-Anfragen parallel an Ollama geschickt werden.
*
* Voraussetzung: ki_vorschlag_aktiv = 1 in den Einstellungen, Ollama erreichbar.
*
* Aufruf:
* docker compose exec web php scripts/ki-batch.php
*
* Für optionalen eigenen Cronjob (manuelle Uploads ohne Nextcloud):
* *\/5 * * * * docker compose -f /opt/steuerkram/docker-compose.yml exec -T web \
* php scripts/ki-batch.php >> /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);

View file

@ -41,6 +41,7 @@ if (file_exists('.env')) {
require_once 'vendor/autoload.php'; require_once 'vendor/autoload.php';
use Steuerkram\Config; use Steuerkram\Config;
use Steuerkram\Models\Setting;
use Steuerkram\Services\NextcloudImportService; use Steuerkram\Services\NextcloudImportService;
Config::load(); // Database verbindet sich lazy über Config::get() Config::load(); // Database verbindet sich lazy über Config::get()
@ -64,4 +65,16 @@ echo sprintf(
$result['errors'], $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); exit($result['errors'] > 0 ? 1 : 0);

View file

@ -204,6 +204,8 @@ class BelegController
$kiVorschlag = $beleg['ki_vorschlag'] !== null $kiVorschlag = $beleg['ki_vorschlag'] !== null
? json_decode($beleg['ki_vorschlag'], true) ? json_decode($beleg['ki_vorschlag'], true)
: null; : null;
// KI-Sektion anzeigen wenn Ergebnis vorhanden ODER LLM gerade verfügbar
$kiSektion = $kiAktiv || $kiVorschlag !== null;
$view = new View(); $view = new View();
$view->render('layout/base.php', [ $view->render('layout/base.php', [
@ -215,6 +217,7 @@ class BelegController
'integrity' => $integrity, 'integrity' => $integrity,
'accounts' => $accounts, 'accounts' => $accounts,
'kiAktiv' => $kiAktiv, 'kiAktiv' => $kiAktiv,
'kiSektion' => $kiSektion,
'kiVorschlag' => $kiVorschlag, 'kiVorschlag' => $kiVorschlag,
]), ]),
]); ]);

View file

@ -18,6 +18,7 @@ class LlmService
private ?string $mode = null; // 'ollama' | 'openai' | null private ?string $mode = null; // 'ollama' | 'openai' | null
private string $url = ''; private string $url = '';
private string $model = ''; private string $model = '';
private string $visionModel = '';
private ?string $apiKey = null; private ?string $apiKey = null;
public function __construct() public function __construct()
@ -30,6 +31,11 @@ class LlmService
return $this->mode !== null; return $this->mode !== null;
} }
public function isVisionAvailable(): bool
{
return $this->mode === 'ollama';
}
public function getProviderLabel(): ?string public function getProviderLabel(): ?string
{ {
return match($this->mode) { 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 private function detect(): void
{ {
$ollamaUrl = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? ''); $ollamaUrl = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? '');
@ -58,6 +103,7 @@ class LlmService
$this->mode = 'ollama'; $this->mode = 'ollama';
$this->url = rtrim($ollamaUrl, '/'); $this->url = rtrim($ollamaUrl, '/');
$this->model = getenv('OLLAMA_MODEL') ?: ($_ENV['OLLAMA_MODEL'] ?? 'llama3.2'); $this->model = getenv('OLLAMA_MODEL') ?: ($_ENV['OLLAMA_MODEL'] ?? 'llama3.2');
$this->visionModel = getenv('VISION_MODEL') ?: ($_ENV['VISION_MODEL'] ?? 'qwen2.5vl:latest');
return; return;
} }

View file

@ -69,7 +69,7 @@ class NextcloudImportService
*/ */
public function import(): array 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) { if (!$this->baseUrl || !$this->user || !$this->pass) {
$result['details'][] = 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.'; $result['details'][] = 'FEHLER: NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_PASS müssen gesetzt sein.';
@ -262,7 +262,7 @@ class NextcloudImportService
// In Datenbank anlegen // In Datenbank anlegen
$today = date('Y-m-d'); $today = date('Y-m-d');
Beleg::create([ $belegId = Beleg::create([
'beleg_nr' => $belegNr, 'beleg_nr' => $belegNr,
'filename' => $filename, 'filename' => $filename,
'storage_path' => $storagePath, 'storage_path' => $storagePath,
@ -280,6 +280,7 @@ class NextcloudImportService
$result['details'][] = "Importiert: {$filename} → Beleg {$belegNr}"; $result['details'][] = "Importiert: {$filename} → Beleg {$belegNr}";
$result['imported']++; $result['imported']++;
$result['imported_ids'][] = $belegId;
} }
/** /**

View file

@ -271,7 +271,7 @@ $suppliers = $suppliers ?? [];
</div> </div>
<!-- KI-Kontierungsvorschlag --> <!-- KI-Kontierungsvorschlag -->
<?php if (!$beleg['locked'] && ($kiAktiv ?? false)): ?> <?php if (!$beleg['locked'] && ($kiSektion ?? false)): ?>
<div id="ki-badge" style="margin-bottom:1rem"></div> <div id="ki-badge" style="margin-bottom:1rem"></div>
<?php endif; ?> <?php endif; ?>
@ -630,9 +630,10 @@ function updateNettoAnzeige() {
} }
</script> </script>
<?php if (!$beleg['locked'] && ($kiAktiv ?? false)): ?> <?php if (!$beleg['locked'] && ($kiSektion ?? false)): ?>
<script> <script>
const _belegId = <?= (int)$beleg['id'] ?>; const _belegId = <?= (int)$beleg['id'] ?>;
const _kiAktiv = <?= json_encode((bool)($kiAktiv ?? false)) ?>;
const _kiVorschlag = <?= json_encode($kiVorschlag ?? null) ?>; const _kiVorschlag = <?= json_encode($kiVorschlag ?? null) ?>;
function _selectById(selectEl, id) { function _selectById(selectEl, id) {
@ -680,7 +681,9 @@ function _applyKiVorschlag(v) {
} }
} }
function _showKiBadgeDone(v) { function _showKiBadgeDone(arr) {
// arr ist immer ein Array (bereits normalisiert)
const v = arr[0];
const katEl = document.getElementById('kategorie_konto_id'); const katEl = document.getElementById('kategorie_konto_id');
const zhlEl = document.getElementById('zahlungskonto_id'); const zhlEl = document.getElementById('zahlungskonto_id');
const katOpt = katEl ? katEl.querySelector('option[value="' + v.kategorie_konto_id + '"]') : null; const katOpt = katEl ? katEl.querySelector('option[value="' + v.kategorie_konto_id + '"]') : null;
@ -690,18 +693,44 @@ function _showKiBadgeDone(v) {
const supTxt = v.supplier_name const supTxt = v.supplier_name
? ' · Lieferant: ' + v.supplier_name + (v.supplier_found === false ? ' <span style="color:var(--color-warning);font-weight:600">(neu – bitte prüfen)</span>' : '') ? ' · Lieferant: ' + v.supplier_name + (v.supplier_found === false ? ' <span style="color:var(--color-warning);font-weight:600">(neu – bitte prüfen)</span>' : '')
: ''; : '';
const details =
// Erste Zeile (bereits ins Formular übertragen)
let details =
(v.buchungstyp ? v.buchungstyp + ' · ' : '') + (v.buchungstyp ? v.buchungstyp + ' · ' : '') +
'USt ' + v.ust_satz + ' % · ' +
'Kategorie: ' + katTxt + ' · ' + 'Kategorie: ' + katTxt + ' · ' +
'Zahlung: ' + zhlTxt + supTxt; 'Zahlung: ' + zhlTxt + supTxt;
// Alle Buchungszeilen mit Beträgen anzeigen
const fmt = n => parseFloat(n).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2});
let zeilen = '';
arr.forEach((z, i) => {
const netto = parseFloat(z.betrag_netto) || 0;
const ust = Math.round(netto * z.ust_satz / 100 * 100) / 100;
const brutto = Math.round((netto + ust) * 100) / 100;
const hinweis = (i > 0) ? ' <span style="color:var(--color-muted);font-style:italic">(bitte als weitere Buchung erfassen)</span>' : '';
zeilen += '<br><span style="font-size:.85em">Zeile ' + (i + 1) + ': '
+ 'Netto ' + fmt(netto) + ' € | USt ' + z.ust_satz + '% ' + fmt(ust) + ' € | Brutto ' + fmt(brutto) + ' €'
+ hinweis + '</span>';
});
const neuBtn = _kiAktiv
? '<button class="btn btn-secondary btn-sm" style="flex-shrink:0;margin-top:.1rem" onclick="_startKiAnalyse()">Neu generieren</button>'
: '';
document.getElementById('ki-badge').innerHTML = document.getElementById('ki-badge').innerHTML =
'<div class="flash flash-info" style="display:flex;justify-content:space-between;align-items:center;gap:1rem">' + '<div class="flash flash-info" style="display:flex;justify-content:space-between;align-items:flex-start;gap:1rem">' +
'<div><strong>KI-Vorschlag:</strong> <span style="font-size:.9em">' + details + '</span></div>' + '<div><strong>KI-Vorschlag:</strong> <span style="font-size:.9em">' + details + '</span>' + zeilen + '</div>' +
'<button class="btn btn-secondary btn-sm" style="flex-shrink:0" onclick="_startKiAnalyse()">Neu generieren</button>' + neuBtn +
'</div>'; '</div>';
} }
function _showKiBadgeNoResult() {
const neuBtn = _kiAktiv
? ' <button class="btn btn-secondary btn-sm" onclick="_startKiAnalyse()">Erneut versuchen</button>'
: '';
document.getElementById('ki-badge').innerHTML =
'<div class="flash flash-warning">Kein Kontierungsvorschlag generiert.' + neuBtn + '</div>';
}
function _showKiBadgeLoading() { function _showKiBadgeLoading() {
document.getElementById('ki-badge').innerHTML = document.getElementById('ki-badge').innerHTML =
'<div class="flash flash-info" style="opacity:.65">Analysiere Beleg … bitte warten.</div>'; '<div class="flash flash-info" style="opacity:.65">Analysiere Beleg … bitte warten.</div>';
@ -709,36 +738,47 @@ function _showKiBadgeLoading() {
function _showKiBadgeError() { function _showKiBadgeError() {
document.getElementById('ki-badge').innerHTML = document.getElementById('ki-badge').innerHTML =
'<div class="flash flash-warning">Kein Kontierungsvorschlag generiert. ' + '<div class="flash flash-warning">Fehler bei der KI-Analyse. ' +
'<button class="btn btn-secondary btn-sm" onclick="_startKiAnalyse()">Erneut versuchen</button></div>'; '<button class="btn btn-secondary btn-sm" onclick="_startKiAnalyse()">Erneut versuchen</button></div>';
} }
// Normalisiert _kiVorschlag zu einem Array (Objekt oder Array aus DB möglich).
// Gibt null zurück wenn kein Ergebnis vorhanden.
// Gibt leeres Array [] zurück wenn verarbeitet aber kein Ergebnis erzeugt.
function _normalizeKiArr(raw) {
if (raw === null || raw === undefined) return null;
const arr = Array.isArray(raw) ? raw : [raw];
// Leeres Array = "verarbeitet, kein Ergebnis"
if (arr.length === 0) return [];
// Mindestens erstes Element muss gültig sein
return (arr[0] && arr[0].kategorie_konto_id) ? arr : [];
}
async function _startKiAnalyse() { async function _startKiAnalyse() {
await fetch('/belege/' + _belegId + '/vorschlag/reset', {method: 'POST'}); await fetch('/belege/' + _belegId + '/vorschlag/reset', {method: 'POST'});
_showKiBadgeLoading(); _showKiBadgeLoading();
try { try {
const r = await fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'}); const r = await fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'});
const v = await r.json(); const raw = await r.json();
if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(v); } const arr = _normalizeKiArr(raw);
else _showKiBadgeError(); if (arr && arr.length > 0) { _applyKiVorschlag(arr[0]); _showKiBadgeDone(arr); }
else _showKiBadgeNoResult();
} catch (e) { } catch (e) {
_showKiBadgeError(); _showKiBadgeError();
} }
} }
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
if (_kiVorschlag && _kiVorschlag.kategorie_konto_id) { const arr = _normalizeKiArr(_kiVorschlag);
_applyKiVorschlag(_kiVorschlag); if (arr === null) {
_showKiBadgeDone(_kiVorschlag); // Noch nicht verarbeitet – nichts anzeigen
} else if (arr.length === 0) {
// Verarbeitet, aber kein Ergebnis
_showKiBadgeNoResult();
} else { } else {
_showKiBadgeLoading(); // Vorschlag vorhanden → ins Formular übernehmen und anzeigen
fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'}) _applyKiVorschlag(arr[0]);
.then(r => r.json()) _showKiBadgeDone(arr);
.then(v => {
if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(v); }
else _showKiBadgeError();
})
.catch(() => _showKiBadgeError());
} }
}); });
</script> </script>

View file

@ -5,7 +5,7 @@ function navActive(string $prefix, string $current): string {
} }
?> ?>
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-brand"><img src="/static/favicon/web-app-manifest-512x512.png" alt="" style="height:1.1em;width:auto;vertical-align:middle;margin-right:.4em;" />Steuer<span>kram</span></div> <div class="sidebar-brand"><a href="https://codeberg.org/fds2610/Steuerkram" target="_blank" rel="noopener noreferrer" style="display:inline-flex;align-items:center;color:inherit;text-decoration:none;"><img src="/static/favicon/web-app-manifest-512x512.png" alt="" style="height:1.1em;width:auto;vertical-align:middle;margin-right:.4em;" />Steuer<span>kram</span></a></div>
<div class="sidebar-section"> <div class="sidebar-section">
<div class="sidebar-label">Übersicht</div> <div class="sidebar-label">Übersicht</div>