Fix #55: LLM-Verbindungstest mit Diagnoseausgabe

- LlmService::testConnection() gibt cURL-Fehler, HTTP-Code, Antwortzeit und verfügbare Modelle zurück
- probeOllama() schreibt cURL-Fehler ins PHP-Fehlerlog
- Einstellungsseite: Button "Verbindung testen" mit Inline-Diagnose und Firewall-Hinweis
- Route POST /settings/llm-test
This commit is contained in:
FDS 2026-06-08 21:03:54 +02:00
parent f9f89b933f
commit 54f7d9bd38
5 changed files with 123 additions and 5 deletions

View file

@ -2,6 +2,13 @@
--- ---
## 2026-06-08 – Fix: KI-Verbindungstest mit Diagnoseinformationen (Issue #55)
- **`LlmService`:** Neue Methode `testConnection()` gibt detaillierte Diagnose zurück (cURL-Fehlernummer/-meldung, HTTP-Code, Antwortzeit, verfügbare Modelle)
- **`LlmService`:** `probeOllama()` schreibt bei cURL-Fehlern jetzt einen Eintrag ins PHP-Fehlerlog (`docker compose logs web`)
- **Einstellungsseite:** Neuer Button „Verbindung testen" im KI-Abschnitt; zeigt bei Erfolg verfügbare Modelle, bei Fehler cURL-Fehler, URL und Firewall-Hinweis an
- **Route:** `POST /settings/llm-test` → `SettingsController::llmTest()`
## 2026-06-01 – Fix: EN16931-Validierungsfehler in ZUGFeRD-Rechnungen ## 2026-06-01 – Fix: EN16931-Validierungsfehler in ZUGFeRD-Rechnungen
- **[BR-FX-EN-04]** behoben: `ZugferdService` setzt jetzt BT-72 (Liefer-/Leistungsdatum) via `setDocumentSupplyChainEvent()`; das Datum wird aus dem Feld `leistungszeitraum` geparst (Format `DD.MM.YYYY` → direkt; Format `MM.YYYY` → letzter Tag des Monats; sonst Fallback auf Rechnungsdatum) - **[BR-FX-EN-04]** behoben: `ZugferdService` setzt jetzt BT-72 (Liefer-/Leistungsdatum) via `setDocumentSupplyChainEvent()`; das Datum wird aus dem Feld `leistungszeitraum` geparst (Format `DD.MM.YYYY` → direkt; Format `MM.YYYY` → letzter Tag des Monats; sonst Fallback auf Rechnungsdatum)

View file

@ -165,6 +165,7 @@ $router->post('/settings/mail-test', fn($p) => (new SettingsController())->mailT
$router->post('/settings/logo', fn($p) => (new SettingsController())->uploadLogo($p)); $router->post('/settings/logo', fn($p) => (new SettingsController())->uploadLogo($p));
$router->post('/settings/logo/delete', fn($p) => (new SettingsController())->deleteLogo($p)); $router->post('/settings/logo/delete', fn($p) => (new SettingsController())->deleteLogo($p));
$router->post('/settings/generate-app-key', fn($p) => (new SettingsController())->generateAppKey($p)); $router->post('/settings/generate-app-key', fn($p) => (new SettingsController())->generateAppKey($p));
$router->post('/settings/llm-test', fn($p) => (new SettingsController())->llmTest($p));
$router->get('/settings/bankimport', fn($p) => (new SettingsController())->bankImport($p)); $router->get('/settings/bankimport', fn($p) => (new SettingsController())->bankImport($p));
$router->post('/settings/bankimport/suggest', fn($p) => (new SettingsController())->storeSuggestion($p)); $router->post('/settings/bankimport/suggest', fn($p) => (new SettingsController())->storeSuggestion($p));
$router->post('/settings/bankimport/create', fn($p) => (new SettingsController())->createBankImportFormat($p)); $router->post('/settings/bankimport/create', fn($p) => (new SettingsController())->createBankImportFormat($p));

View file

@ -126,6 +126,13 @@ class SettingsController extends BaseController
echo json_encode(['key' => bin2hex(random_bytes(32))]); echo json_encode(['key' => bin2hex(random_bytes(32))]);
} }
public function llmTest(array $params): void
{
header('Content-Type: application/json');
$result = (new \Steuerkram\Services\LlmService())->testConnection();
echo json_encode($result);
}
public function bankImport(array $params): void public function bankImport(array $params): void
{ {
$formats = BankSource::all('csv'); $formats = BankSource::all('csv');

View file

@ -134,9 +134,54 @@ class LlmService
} }
} }
/**
* Liefert Diagnose-Informationen für den Verbindungstest auf der Einstellungsseite.
* Gibt ein Array zurück: ['ok' => bool, 'url' => string, 'elapsed' => int, 'error'? => string, 'models'? => string[]]
*
* @return array<string, mixed>
*/
public function testConnection(): array
{
if ($this->mode === null) {
return ['ok' => false, 'error' => 'Kein LLM-Provider konfiguriert.'];
}
if ($this->mode === 'openai') {
return ['ok' => true, 'url' => $this->url, 'elapsed' => 0, 'info' => 'OpenAI-Modus – kein Verbindungstest möglich.'];
}
$url = $this->url . '/api/tags';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
CURLOPT_CONNECTTIMEOUT => 3,
]);
$start = microtime(true);
$response = curl_exec($ch);
$elapsed = (int)round((microtime(true) - $start) * 1000);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$errno = curl_errno($ch);
$errstr = curl_error($ch);
curl_close($ch);
if ($errno !== 0) {
error_log("[Steuerkram] LlmService::testConnection() cURL-Fehler #{$errno} auf {$url}: {$errstr}");
return ['ok' => false, 'url' => $url, 'elapsed' => $elapsed, 'error' => "cURL #{$errno}: {$errstr}"];
}
if ($code < 200 || $code >= 300) {
return ['ok' => false, 'url' => $url, 'elapsed' => $elapsed, 'error' => "HTTP {$code}"];
}
$data = json_decode($response, true);
$models = array_column($data['models'] ?? [], 'name');
return ['ok' => true, 'url' => $url, 'elapsed' => $elapsed, 'models' => $models];
}
private function probeOllama(string $baseUrl): bool private function probeOllama(string $baseUrl): bool
{ {
$ch = curl_init($baseUrl . '/api/tags'); $url = $baseUrl . '/api/tags';
$ch = curl_init($url);
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 2, CURLOPT_TIMEOUT => 2,
@ -144,10 +189,16 @@ class LlmService
CURLOPT_NOBODY => false, CURLOPT_NOBODY => false,
]); ]);
curl_exec($ch); curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_errno($ch); $errno = curl_errno($ch);
$err = curl_error($ch);
curl_close($ch); curl_close($ch);
return $err === 0 && $code >= 200 && $code < 300;
if ($errno !== 0) {
error_log("[Steuerkram] LlmService::probeOllama() cURL-Fehler #{$errno} auf {$url}: {$err}");
}
return $errno === 0 && $code >= 200 && $code < 300;
} }
private function callOllama(string $prompt): string private function callOllama(string $prompt): string

View file

@ -256,7 +256,7 @@
$reachable = $providerLabel !== null ? $llm->isReachable() : false; $reachable = $providerLabel !== null ? $llm->isReachable() : false;
$ollamaUrlEnv = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? ''); $ollamaUrlEnv = getenv('OLLAMA_URL') ?: ($_ENV['OLLAMA_URL'] ?? '');
?> ?>
<p class="text-sm" style="margin:0 0 0 1.75rem;color:<?= $reachable ? 'var(--color-success,#16a34a)' : '#dc2626' ?>"> <p class="text-sm" style="margin:0 0 .5rem 1.75rem;color:<?= $reachable ? 'var(--color-success,#16a34a)' : '#dc2626' ?>">
<?php if ($reachable): ?> <?php if ($reachable): ?>
✓ Provider erreichbar: <?= $h($providerLabel) ?> ✓ Provider erreichbar: <?= $h($providerLabel) ?>
<?php elseif ($providerLabel): ?> <?php elseif ($providerLabel): ?>
@ -269,6 +269,11 @@
— Kein LLM-Provider konfiguriert (Auto-Discovery localhost:11434 fehlgeschlagen) — Kein LLM-Provider konfiguriert (Auto-Discovery localhost:11434 fehlgeschlagen)
<?php endif; ?> <?php endif; ?>
</p> </p>
<div style="margin-left:1.75rem;display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
<button type="button" class="btn btn-secondary btn-sm" id="btn-llm-test">Verbindung testen</button>
<span id="llm-test-result" style="font-size:.8rem"></span>
</div>
<div id="llm-test-detail" style="margin:.5rem 0 0 1.75rem;font-size:.8rem;display:none"></div>
</div> </div>
</div> </div>
@ -477,6 +482,53 @@
</div> </div>
</div> </div>
<script>
document.getElementById('btn-llm-test')?.addEventListener('click', function () {
var btn = this;
var result = document.getElementById('llm-test-result');
var detail = document.getElementById('llm-test-detail');
btn.disabled = true;
result.textContent = 'Teste…';
result.style.color = '';
detail.style.display = 'none';
fetch('/settings/llm-test', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.ok) {
result.textContent = '✓ Verbindung OK (' + (data.elapsed || 0) + ' ms)';
result.style.color = 'green';
if (data.models && data.models.length > 0) {
detail.textContent = 'Modelle: ' + data.models.join(', ');
detail.style.display = 'block';
detail.style.color = '#374151';
} else if (data.info) {
detail.textContent = data.info;
detail.style.display = 'block';
detail.style.color = '#374151';
}
} else {
result.textContent = '✗ Verbindung fehlgeschlagen';
result.style.color = 'red';
if (data.error) {
var lines = ['Fehler: ' + data.error];
if (data.url) lines.push('URL: ' + data.url);
if (data.elapsed) lines.push('Nach ' + data.elapsed + ' ms');
lines.push('Tipp: sudo ufw allow from 172.16.0.0/12 to any port 11434 (oder firewall prüfen)');
lines.push('Logs: docker compose logs web');
detail.innerHTML = lines.map(function (l) { return '<div>' + l + '</div>'; }).join('');
detail.style.display = 'block';
detail.style.color = '#dc2626';
}
}
})
.catch(function () {
result.textContent = '✗ Netzwerkfehler';
result.style.color = 'red';
})
.finally(function () { btn.disabled = false; });
});
</script>
<script> <script>
document.getElementById('btn-mail-test')?.addEventListener('click', function () { document.getElementById('btn-mail-test')?.addEventListener('click', function () {
var btn = this; var btn = this;