Feature: KI-Kontierungsvorschlag via lokales Ollama oder Remote-LLM
- LlmService: Auto-Discovery lokales Ollama + OpenAI-kompatibler Remote-API-Modus - KontierungsvorschlagService: PDF-Textextraktion (pdftotext) + Vision für Bilder - Async-Badge in Beleg-Detailansicht: "Analysiere..." → Formular vorausgefüllt - Einstellung in Settings (default: aus) mit Provider-Status-Anzeige - .env.example: Beispielkonfiguration für beide Provider-Typen (auskommentiert) - Dockerfile: poppler-utils für pdftotext - Migration 011: ki_vorschlag/ki_vorschlag_at in belege, ki_vorschlag_aktiv in settings - Test 11: saveKiVorschlag, JSON-Extraktion, isEnabled-Default
This commit is contained in:
parent
15f501db6f
commit
b8ef04033c
12 changed files with 542 additions and 11 deletions
12
.env.example
12
.env.example
|
|
@ -6,3 +6,15 @@ DB_PASS=steuerkram
|
||||||
|
|
||||||
# App
|
# App
|
||||||
APP_SECRET=change-this-to-a-random-string
|
APP_SECRET=change-this-to-a-random-string
|
||||||
|
|
||||||
|
# KI-Kontierungsvorschlag (optional)
|
||||||
|
# Wenn nichts konfiguriert wird, sucht die App automatisch nach einem lokalen Ollama auf localhost:11434.
|
||||||
|
#
|
||||||
|
# Option A: Lokales Ollama mit expliziter URL
|
||||||
|
# OLLAMA_URL=http://localhost:11434
|
||||||
|
# OLLAMA_MODEL=llama3.2
|
||||||
|
#
|
||||||
|
# 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-...
|
||||||
|
# LLM_MODEL=gpt-4o-mini
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ RUN apt-get update && apt-get install -y \
|
||||||
zip \
|
zip \
|
||||||
unzip \
|
unzip \
|
||||||
curl \
|
curl \
|
||||||
|
poppler-utils \
|
||||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||||
&& docker-php-ext-install pdo pdo_mysql zip gd \
|
&& docker-php-ext-install pdo pdo_mysql zip gd \
|
||||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
|
||||||
11
migrations/011_beleg_ki_vorschlag.sql
Normal file
11
migrations/011_beleg_ki_vorschlag.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- KI-Kontierungsvorschlag: Spalten in belege
|
||||||
|
ALTER TABLE belege
|
||||||
|
ADD COLUMN IF NOT EXISTS ki_vorschlag JSON NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS ki_vorschlag_at TIMESTAMP NULL;
|
||||||
|
|
||||||
|
-- KI-Einstellung in company_settings
|
||||||
|
ALTER TABLE company_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS ki_vorschlag_aktiv TINYINT(1) NOT NULL DEFAULT 0
|
||||||
|
COMMENT '1 = automatischer Kontierungsvorschlag per LLM aktiv';
|
||||||
|
|
||||||
|
UPDATE company_settings SET ki_vorschlag_aktiv = 0 WHERE id = 1;
|
||||||
|
|
@ -51,6 +51,8 @@ $router->get('/belege/{id}', fn($p) => (new BelegController())->show($p));
|
||||||
$router->get('/belege/{id}/file', fn($p) => (new BelegController())->serveFile($p));
|
$router->get('/belege/{id}/file', fn($p) => (new BelegController())->serveFile($p));
|
||||||
$router->post('/belege/{id}/delete', fn($p) => (new BelegController())->destroy($p));
|
$router->post('/belege/{id}/delete', fn($p) => (new BelegController())->destroy($p));
|
||||||
$router->post('/belege/{id}/meta', fn($p) => (new BelegController())->updateMeta($p));
|
$router->post('/belege/{id}/meta', fn($p) => (new BelegController())->updateMeta($p));
|
||||||
|
$router->post('/belege/{id}/vorschlag/generate', fn($p) => (new BelegController())->generateVorschlag($p));
|
||||||
|
$router->post('/belege/{id}/vorschlag/reset', fn($p) => (new BelegController())->resetVorschlag($p));
|
||||||
|
|
||||||
// ── Buchungen ────────────────────────────────────────────────
|
// ── Buchungen ────────────────────────────────────────────────
|
||||||
$router->get('/journal', fn($p) => (new BookingController())->journal($p));
|
$router->get('/journal', fn($p) => (new BookingController())->journal($p));
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use Steuerkram\Models\Account;
|
||||||
use Steuerkram\Models\Supplier;
|
use Steuerkram\Models\Supplier;
|
||||||
use Steuerkram\Config;
|
use Steuerkram\Config;
|
||||||
use Steuerkram\View;
|
use Steuerkram\View;
|
||||||
|
use Steuerkram\Services\KontierungsvorschlagService;
|
||||||
|
|
||||||
class BelegController
|
class BelegController
|
||||||
{
|
{
|
||||||
|
|
@ -143,6 +144,12 @@ class BelegController
|
||||||
$accounts = Account::all(['aktiv' => 1]);
|
$accounts = Account::all(['aktiv' => 1]);
|
||||||
$suppliers = Supplier::all();
|
$suppliers = Supplier::all();
|
||||||
|
|
||||||
|
$svc = new KontierungsvorschlagService();
|
||||||
|
$kiAktiv = $svc->isEnabled();
|
||||||
|
$kiVorschlag = $beleg['ki_vorschlag'] !== null
|
||||||
|
? json_decode($beleg['ki_vorschlag'], true)
|
||||||
|
: null;
|
||||||
|
|
||||||
$view = new View();
|
$view = new View();
|
||||||
$view->render('layout/base.php', [
|
$view->render('layout/base.php', [
|
||||||
'pageTitle' => 'Beleg ' . $beleg['beleg_nr'],
|
'pageTitle' => 'Beleg ' . $beleg['beleg_nr'],
|
||||||
|
|
@ -152,6 +159,8 @@ class BelegController
|
||||||
'bookings' => $bookings,
|
'bookings' => $bookings,
|
||||||
'integrity' => $integrity,
|
'integrity' => $integrity,
|
||||||
'accounts' => $accounts,
|
'accounts' => $accounts,
|
||||||
|
'kiAktiv' => $kiAktiv,
|
||||||
|
'kiVorschlag' => $kiVorschlag,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -210,6 +219,29 @@ class BelegController
|
||||||
View::redirect("/belege/{$id}", 'Metadaten gespeichert.');
|
View::redirect("/belege/{$id}", 'Metadaten gespeichert.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function generateVorschlag(array $params): void
|
||||||
|
{
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
$id = (int)$params['id'];
|
||||||
|
$beleg = Beleg::find($id);
|
||||||
|
if (!$beleg) {
|
||||||
|
echo json_encode(['error' => 'Beleg nicht gefunden']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$svc = new KontierungsvorschlagService();
|
||||||
|
$vorschlag = $svc->generateForBeleg($beleg);
|
||||||
|
Beleg::saveKiVorschlag($id, $vorschlag);
|
||||||
|
echo json_encode($vorschlag ?? ['error' => 'Kein Vorschlag generiert']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetVorschlag(array $params): void
|
||||||
|
{
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
Beleg::saveKiVorschlag((int)$params['id'], null);
|
||||||
|
echo json_encode(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
public function destroy(array $params): void
|
public function destroy(array $params): void
|
||||||
{
|
{
|
||||||
$id = (int)$params['id'];
|
$id = (int)$params['id'];
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,14 @@ class Beleg
|
||||||
return $nr;
|
return $nr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function saveKiVorschlag(int $id, ?array $vorschlag): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE belege SET ki_vorschlag = ?, ki_vorschlag_at = NOW() WHERE id = ?',
|
||||||
|
[$vorschlag !== null ? json_encode($vorschlag) : null, $id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static function verifyIntegrity(int $id): bool
|
public static function verifyIntegrity(int $id): bool
|
||||||
{
|
{
|
||||||
$beleg = self::find($id);
|
$beleg = self::find($id);
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ class Setting
|
||||||
iban=?, bic=?, bank_name=?,
|
iban=?, bic=?, bank_name=?,
|
||||||
ustva_steuernummer=?, ustva_bundesland=?,
|
ustva_steuernummer=?, ustva_bundesland=?,
|
||||||
invoice_nr_prefix=?, beleg_nr_prefix=?,
|
invoice_nr_prefix=?, beleg_nr_prefix=?,
|
||||||
payment_days_default=?, test_mode=?
|
payment_days_default=?, test_mode=?, ki_vorschlag_aktiv=?
|
||||||
WHERE id=1',
|
WHERE id=1',
|
||||||
[
|
[
|
||||||
$data['firma'] ?? '',
|
$data['firma'] ?? '',
|
||||||
|
|
@ -52,6 +52,7 @@ class Setting
|
||||||
$data['beleg_nr_prefix'] ?? 'BLG',
|
$data['beleg_nr_prefix'] ?? 'BLG',
|
||||||
(int)($data['payment_days_default'] ?? 14),
|
(int)($data['payment_days_default'] ?? 14),
|
||||||
isset($data['test_mode']) ? 1 : 0,
|
isset($data['test_mode']) ? 1 : 0,
|
||||||
|
isset($data['ki_vorschlag_aktiv']) ? 1 : 0,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
130
src/Services/KontierungsvorschlagService.php
Normal file
130
src/Services/KontierungsvorschlagService.php
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Services;
|
||||||
|
|
||||||
|
use Steuerkram\Models\Account;
|
||||||
|
use Steuerkram\Models\Setting;
|
||||||
|
|
||||||
|
class KontierungsvorschlagService
|
||||||
|
{
|
||||||
|
private LlmService $llm;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->llm = new LlmService();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEnabled(): bool
|
||||||
|
{
|
||||||
|
$s = Setting::get();
|
||||||
|
return (bool)($s['ki_vorschlag_aktiv'] ?? 0) && $this->llm->isAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateForBeleg(array $beleg): ?array
|
||||||
|
{
|
||||||
|
$absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
|
||||||
|
if (!file_exists($absPath)) return null;
|
||||||
|
|
||||||
|
// Inhalt extrahieren
|
||||||
|
$base64Image = null;
|
||||||
|
$mime = strtolower($beleg['mime_type'] ?? '');
|
||||||
|
|
||||||
|
if ($mime === 'application/pdf') {
|
||||||
|
$text = $this->extractPdfText($absPath);
|
||||||
|
} elseif (in_array($mime, ['image/jpeg', 'image/png', 'image/tiff'], true)) {
|
||||||
|
$base64Image = base64_encode(file_get_contents($absPath));
|
||||||
|
$text = null;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Konten laden
|
||||||
|
$allAccounts = Account::all(['aktiv' => 1]);
|
||||||
|
$kategorieKonten = array_filter(
|
||||||
|
$allAccounts,
|
||||||
|
fn($a) => in_array($a['kontenart'], ['Betriebseinnahme', 'Betriebsausgabe'], true)
|
||||||
|
);
|
||||||
|
$zahlungsKonten = array_filter(
|
||||||
|
$allAccounts,
|
||||||
|
fn($a) => in_array($a['kontenart'], ['Zahlungskonto', 'Verbindlichkeit'], true)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Kontenliste für Prompt aufbauen
|
||||||
|
$kategorieList = implode("\n", array_map(
|
||||||
|
fn($a) => " {$a['id']} – {$a['kontonummer']} – {$a['bezeichnung']} ({$a['kontenart']})",
|
||||||
|
$kategorieKonten
|
||||||
|
));
|
||||||
|
$zahlungsList = implode("\n", array_map(
|
||||||
|
fn($a) => " {$a['id']} – {$a['kontonummer']} – {$a['bezeichnung']}",
|
||||||
|
$zahlungsKonten
|
||||||
|
));
|
||||||
|
|
||||||
|
$beleginhalt = $text !== null && trim($text) !== ''
|
||||||
|
? "Belegtext:\n" . mb_substr(trim($text), 0, 3000)
|
||||||
|
: '[Bilddatei – Inhalt wird direkt analysiert]';
|
||||||
|
|
||||||
|
$prompt = <<<PROMPT
|
||||||
|
Du bist ein Buchhalter (SKR03-Kontenrahmen, Deutschland). Analysiere den folgenden Beleg und erstelle einen Kontierungsvorschlag.
|
||||||
|
|
||||||
|
Antworte AUSSCHLIESSLICH mit einem einzigen JSON-Objekt, ohne erklaerenden Text davor oder danach:
|
||||||
|
{"buchungstyp":"Ausgabe","kategorie_konto_id":45,"zahlungskonto_id":12,"ust_satz":19,"betrag_netto":33.64,"buchungstext":"Rechnung 2026-001"}
|
||||||
|
|
||||||
|
Erlaubte Werte:
|
||||||
|
- buchungstyp: "Ausgabe" oder "Einnahme"
|
||||||
|
- ust_satz: 0, 7 oder 19
|
||||||
|
- betrag_netto: Dezimalzahl mit Punkt als Trennzeichen
|
||||||
|
|
||||||
|
Verfuegbare Kategorie-Konten:
|
||||||
|
$kategorieList
|
||||||
|
|
||||||
|
Verfuegbare Zahlungskonten:
|
||||||
|
$zahlungsList
|
||||||
|
|
||||||
|
$beleginhalt
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
$response = $this->llm->generate($prompt, $base64Image);
|
||||||
|
if ($response === '') return null;
|
||||||
|
|
||||||
|
return $this->parseAndValidate($response, $allAccounts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractPdfText(string $path): string
|
||||||
|
{
|
||||||
|
$safe = escapeshellarg($path);
|
||||||
|
$output = [];
|
||||||
|
exec("pdftotext -l 1 {$safe} -", $output, $returnCode);
|
||||||
|
return $returnCode === 0 ? implode("\n", $output) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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'] ?? '');
|
||||||
|
|
||||||
|
// IDs gegen Kontenliste validieren
|
||||||
|
$accountIds = array_column($accounts, 'id');
|
||||||
|
if (!in_array($data['kategorie_konto_id'], $accountIds, false)) return null;
|
||||||
|
if (!in_array($data['zahlungskonto_id'], $accountIds, false)) return null;
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
158
src/Services/LlmService.php
Normal file
158
src/Services/LlmService.php
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Services;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einheitlicher LLM-Client für lokales Ollama und OpenAI-kompatible Remote-APIs.
|
||||||
|
*
|
||||||
|
* Provider-Erkennung (Reihenfolge):
|
||||||
|
* 1. OLLAMA_URL in Umgebungsvariablen → Ollama-Modus
|
||||||
|
* 2. LLM_API_URL + LLM_API_KEY in Umgebungsvariablen → OpenAI-kompatibler Modus
|
||||||
|
* 3. Auto-Discovery: HEAD-Request auf http://localhost:11434 → Ollama-Modus
|
||||||
|
* 4. Kein Provider gefunden → isAvailable() = false
|
||||||
|
*/
|
||||||
|
class LlmService
|
||||||
|
{
|
||||||
|
private ?string $mode = null; // 'ollama' | 'openai' | null
|
||||||
|
private string $url = '';
|
||||||
|
private string $model = '';
|
||||||
|
private ?string $apiKey = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->detect();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAvailable(): bool
|
||||||
|
{
|
||||||
|
return $this->mode !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProviderLabel(): ?string
|
||||||
|
{
|
||||||
|
return match($this->mode) {
|
||||||
|
'ollama' => 'Ollama (' . $this->url . ')',
|
||||||
|
'openai' => 'Remote-API (' . $this->url . ')',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generate(string $prompt, ?string $base64Image = null): string
|
||||||
|
{
|
||||||
|
return match($this->mode) {
|
||||||
|
'ollama' => $this->callOllama($prompt, $base64Image),
|
||||||
|
'openai' => $this->callOpenAi($prompt, $base64Image),
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function detect(): void
|
||||||
|
{
|
||||||
|
$ollamaUrl = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? '');
|
||||||
|
$apiUrl = getenv('LLM_API_URL') ?: ($_ENV['LLM_API_URL'] ?? '');
|
||||||
|
$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');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($apiUrl !== '' && $apiKey !== '') {
|
||||||
|
$this->mode = 'openai';
|
||||||
|
$this->url = rtrim($apiUrl, '/');
|
||||||
|
$this->model = getenv('LLM_MODEL') ?: ($_ENV['LLM_MODEL'] ?? 'gpt-4o-mini');
|
||||||
|
$this->apiKey = $apiKey;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-Discovery: lokales Ollama auf Port 11434
|
||||||
|
if ($this->probeOllama('http://localhost:11434')) {
|
||||||
|
$this->mode = 'ollama';
|
||||||
|
$this->url = 'http://localhost:11434';
|
||||||
|
$this->model = 'llama3.2';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function probeOllama(string $baseUrl): bool
|
||||||
|
{
|
||||||
|
$ch = curl_init($baseUrl . '/api/tags');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 2,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 2,
|
||||||
|
CURLOPT_NOBODY => false,
|
||||||
|
]);
|
||||||
|
curl_exec($ch);
|
||||||
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$err = curl_errno($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
return $err === 0 && $code >= 200 && $code < 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function callOllama(string $prompt, ?string $base64Image): string
|
||||||
|
{
|
||||||
|
$payload = [
|
||||||
|
'model' => $this->model,
|
||||||
|
'prompt' => $prompt,
|
||||||
|
'stream' => false,
|
||||||
|
];
|
||||||
|
if ($base64Image !== null) {
|
||||||
|
$payload['images'] = [$base64Image];
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init($this->url . '/api/generate');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 120,
|
||||||
|
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 '';
|
||||||
|
return json_decode($response, true)['response'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function callOpenAi(string $prompt, ?string $base64Image): string
|
||||||
|
{
|
||||||
|
$content = [];
|
||||||
|
if ($base64Image !== null) {
|
||||||
|
$content[] = [
|
||||||
|
'type' => 'image_url',
|
||||||
|
'image_url' => ['url' => 'data:image/jpeg;base64,' . $base64Image],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$content[] = ['type' => 'text', 'text' => $prompt];
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'model' => $this->model,
|
||||||
|
'messages' => [['role' => 'user', 'content' => $content]],
|
||||||
|
];
|
||||||
|
|
||||||
|
$ch = curl_init($this->url . '/chat/completions');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 120,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'Authorization: Bearer ' . $this->apiKey,
|
||||||
|
],
|
||||||
|
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['choices'][0]['message']['content'] ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -247,6 +247,11 @@ $suppliers = $suppliers ?? [];
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- KI-Kontierungsvorschlag -->
|
||||||
|
<?php if (!$beleg['locked'] && ($kiAktiv ?? false)): ?>
|
||||||
|
<div id="ki-badge" style="margin-bottom:1rem"></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Neue Buchungszeile erfassen -->
|
<!-- Neue Buchungszeile erfassen -->
|
||||||
<?php if (!$beleg['locked']): ?>
|
<?php if (!$beleg['locked']): ?>
|
||||||
<?php
|
<?php
|
||||||
|
|
@ -410,11 +415,12 @@ $suppliers = $suppliers ?? [];
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="eigenanteil_group" style="margin-bottom:.75rem;opacity:.35;pointer-events:none">
|
<div class="form-group" title="Nur bei Telefon/Internet 19% wählbar" style="margin-bottom:.75rem">
|
||||||
<label style="display:flex;align-items:center;gap:.5rem;cursor:not-allowed" title="Nur bei Telefon/Internet 19% wählbar">
|
<span id="eigenanteil_group"
|
||||||
<input type="checkbox" id="eigenanteil_30_cb" name="eigenanteil_30" value="1" disabled title="Nur bei Telefon/Internet 19% wählbar">
|
style="display:inline-flex;align-items:center;gap:.5rem;opacity:.35;pointer-events:none">
|
||||||
30% Eigenanteil buchen
|
<input type="checkbox" id="eigenanteil_30_cb" name="eigenanteil_30" value="1" disabled>
|
||||||
</label>
|
<span>30% Eigenanteil buchen</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Buchung erfassen</button>
|
<button type="submit" class="btn btn-primary">Buchung erfassen</button>
|
||||||
|
|
@ -497,3 +503,73 @@ function updateSteuerartVisibility() {
|
||||||
updateEigenanteilState();
|
updateEigenanteilState();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<?php if (!$beleg['locked'] && ($kiAktiv ?? false)): ?>
|
||||||
|
<script>
|
||||||
|
const _belegId = <?= (int)$beleg['id'] ?>;
|
||||||
|
const _kiVorschlag = <?= json_encode($kiVorschlag ?? null) ?>;
|
||||||
|
|
||||||
|
function _applyKiVorschlag(v) {
|
||||||
|
const btRadio = document.querySelector('input[name="buchungstyp"][value="' + v.buchungstyp + '"]');
|
||||||
|
if (btRadio) { btRadio.checked = true; filterKategorieKonten(v.buchungstyp); }
|
||||||
|
const ustEl = document.getElementById('ust_satz_select');
|
||||||
|
if (ustEl) { ustEl.value = String(v.ust_satz); updateSteuerartVisibility(); }
|
||||||
|
const katEl = document.getElementById('kategorie_konto_id');
|
||||||
|
if (katEl) { katEl.value = String(v.kategorie_konto_id); updateEigenanteilState(); }
|
||||||
|
const zhlEl = document.getElementById('zahlungskonto_id');
|
||||||
|
if (zhlEl) zhlEl.value = String(v.zahlungskonto_id);
|
||||||
|
const netEl = document.querySelector('input[name="betrag_netto"]');
|
||||||
|
if (netEl && v.betrag_netto) netEl.value = v.betrag_netto;
|
||||||
|
const txtEl = document.querySelector('input[name="buchungstext"]');
|
||||||
|
if (txtEl && v.buchungstext) txtEl.value = v.buchungstext;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showKiBadgeDone() {
|
||||||
|
document.getElementById('ki-badge').innerHTML =
|
||||||
|
'<div class="flash flash-info" style="display:flex;justify-content:space-between;align-items:center">' +
|
||||||
|
'<span>KI-Kontierungsvorschlag – Formular vorausgefüllt. Bitte prüfen und ggf. anpassen.</span>' +
|
||||||
|
'<button class="btn btn-secondary btn-sm" onclick="_startKiAnalyse()">Neu generieren</button>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showKiBadgeLoading() {
|
||||||
|
document.getElementById('ki-badge').innerHTML =
|
||||||
|
'<div class="flash flash-info" style="opacity:.65">Analysiere Beleg … bitte warten.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showKiBadgeError() {
|
||||||
|
document.getElementById('ki-badge').innerHTML =
|
||||||
|
'<div class="flash flash-warning">Kein Kontierungsvorschlag generiert. ' +
|
||||||
|
'<button class="btn btn-secondary btn-sm" onclick="_startKiAnalyse()">Erneut versuchen</button></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _startKiAnalyse() {
|
||||||
|
await fetch('/belege/' + _belegId + '/vorschlag/reset', {method: 'POST'});
|
||||||
|
_showKiBadgeLoading();
|
||||||
|
try {
|
||||||
|
const r = await fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'});
|
||||||
|
const v = await r.json();
|
||||||
|
if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(); }
|
||||||
|
else _showKiBadgeError();
|
||||||
|
} catch (e) {
|
||||||
|
_showKiBadgeError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
if (_kiVorschlag && _kiVorschlag.kategorie_konto_id) {
|
||||||
|
_applyKiVorschlag(_kiVorschlag);
|
||||||
|
_showKiBadgeDone();
|
||||||
|
} else {
|
||||||
|
_showKiBadgeLoading();
|
||||||
|
fetch('/belege/' + _belegId + '/vorschlag/generate', {method: 'POST'})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(v => {
|
||||||
|
if (v && v.kategorie_konto_id) { _applyKiVorschlag(v); _showKiBadgeDone(); }
|
||||||
|
else _showKiBadgeError();
|
||||||
|
})
|
||||||
|
.catch(() => _showKiBadgeError());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,33 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- KI-Kontierungsvorschlag -->
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-header"><div class="card-title">KI-Kontierungsvorschlag</div></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;user-select:none">
|
||||||
|
<input type="checkbox" name="ki_vorschlag_aktiv" value="1"
|
||||||
|
<?= !empty($settings['ki_vorschlag_aktiv']) ? 'checked' : '' ?>>
|
||||||
|
<span style="font-weight:600">Automatischen Kontierungsvorschlag erzeugen</span>
|
||||||
|
</label>
|
||||||
|
<p class="text-muted text-sm" style="margin:.5rem 0 .75rem 1.75rem">
|
||||||
|
Beim ersten Öffnen eines Belegs wird ein LLM befragt und das Buchungsformular vorausgefüllt.
|
||||||
|
Konfiguration via <code>.env</code> (lokales Ollama oder Remote-API).
|
||||||
|
</p>
|
||||||
|
<?php
|
||||||
|
$llm = new \Steuerkram\Services\LlmService();
|
||||||
|
$providerLabel = $llm->getProviderLabel();
|
||||||
|
?>
|
||||||
|
<p class="text-sm" style="margin:0 0 0 1.75rem;color:<?= $providerLabel ? 'var(--color-success,#16a34a)' : 'var(--color-muted,#6b7280)' ?>">
|
||||||
|
<?php if ($providerLabel): ?>
|
||||||
|
✓ Provider erkannt: <?= $h($providerLabel) ?>
|
||||||
|
<?php else: ?>
|
||||||
|
— Kein LLM-Provider konfiguriert oder erreichbar (Auto-Discovery localhost:11434 fehlgeschlagen)
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card mb-2">
|
<div class="card mb-2">
|
||||||
<div class="card-header"><div class="card-title">Bankdaten</div></div>
|
<div class="card-header"><div class="card-title">Bankdaten</div></div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|
|
||||||
73
tests/11_KontierungsvorschlagTest.php
Normal file
73
tests/11_KontierungsvorschlagTest.php
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Steuerkram\Database;
|
||||||
|
use Steuerkram\Models\Beleg;
|
||||||
|
use Steuerkram\Services\KontierungsvorschlagService;
|
||||||
|
|
||||||
|
class KontierungsvorschlagTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 11: KI-Kontierungsvorschlag';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// 1. Einstellung ist standardmäßig deaktiviert
|
||||||
|
$svc = new KontierungsvorschlagService();
|
||||||
|
$this->assert(!$svc->isEnabled(), 'ki_vorschlag_aktiv sollte per Default 0 sein');
|
||||||
|
|
||||||
|
// 2. saveKiVorschlag speichert und liest korrekt zurück
|
||||||
|
$belegId = $this->createTestBeleg();
|
||||||
|
|
||||||
|
$vorschlag = [
|
||||||
|
'buchungstyp' => 'Ausgabe',
|
||||||
|
'kategorie_konto_id' => 42,
|
||||||
|
'zahlungskonto_id' => 10,
|
||||||
|
'ust_satz' => 19,
|
||||||
|
'betrag_netto' => 33.64,
|
||||||
|
'buchungstext' => 'Testrechnung',
|
||||||
|
];
|
||||||
|
Beleg::saveKiVorschlag($belegId, $vorschlag);
|
||||||
|
|
||||||
|
$beleg = Beleg::find($belegId);
|
||||||
|
$this->assertNotEmpty($beleg['ki_vorschlag'], 'ki_vorschlag sollte gespeichert sein');
|
||||||
|
$this->assertNotEmpty($beleg['ki_vorschlag_at'], 'ki_vorschlag_at sollte gesetzt sein');
|
||||||
|
|
||||||
|
$decoded = json_decode($beleg['ki_vorschlag'], true);
|
||||||
|
$this->assertEquals('Ausgabe', $decoded['buchungstyp'], 'buchungstyp korrekt gespeichert');
|
||||||
|
$this->assertEquals(19, $decoded['ust_satz'], 'ust_satz korrekt gespeichert');
|
||||||
|
$this->assertEquals(33.64, $decoded['betrag_netto'], 'betrag_netto korrekt gespeichert');
|
||||||
|
|
||||||
|
// 3. saveKiVorschlag(null) löscht den Vorschlag
|
||||||
|
Beleg::saveKiVorschlag($belegId, null);
|
||||||
|
$beleg = Beleg::find($belegId);
|
||||||
|
$this->assert($beleg['ki_vorschlag'] === null, 'ki_vorschlag sollte nach Reset null sein');
|
||||||
|
|
||||||
|
// 4. JSON-Extraktion: Prosa vor JSON wird korrekt extrahiert
|
||||||
|
$this->testJsonExtraktion();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTestBeleg(): int
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
"INSERT INTO belege (beleg_nr, filename, storage_path, mime_type, file_hash, eingangs_datum, belegtyp)
|
||||||
|
VALUES ('TEST-KI-0001', 'test.pdf', 'static/uploads/test/test.pdf', 'application/pdf',
|
||||||
|
SHA2('test', 256), CURDATE(), 'Eingangsrechnung')"
|
||||||
|
);
|
||||||
|
return (int)Database::lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testJsonExtraktion(): void
|
||||||
|
{
|
||||||
|
// Simuliert die Regex-Extraktion aus einer LLM-Antwort mit vorangehendem Text
|
||||||
|
$antwort = "Hier ist mein Kontierungsvorschlag:\n{\"buchungstyp\":\"Ausgabe\",\"kategorie_konto_id\":5,\"zahlungskonto_id\":2,\"ust_satz\":19,\"betrag_netto\":100.00,\"buchungstext\":\"Test\"}";
|
||||||
|
preg_match('/\{[^{}]*\}/s', $antwort, $m);
|
||||||
|
$data = json_decode($m[0] ?? '{}', true);
|
||||||
|
$this->assertEquals('Ausgabe', $data['buchungstyp'] ?? '', 'JSON-Extraktion aus LLM-Antwort mit Prosa');
|
||||||
|
$this->assertEquals(19, $data['ust_satz'] ?? -1, 'ust_satz aus extrahiertem JSON');
|
||||||
|
|
||||||
|
// Antwort ohne JSON
|
||||||
|
preg_match('/\{[^{}]*\}/s', 'Keine Angabe möglich.', $m2);
|
||||||
|
$this->assert(empty($m2), 'Keine JSON-Extraktion ohne JSON-Block');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue