From 835cccadec666785dca058e9fbaadc8d6bb9b530 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Mon, 7 Sep 2026 12:45:50 +0200 Subject: [PATCH] ki Backend fix - Answer Size --- .../AiPlanningServiceTests.cs | 24 +++++++++++++++++++ LehrerApp.Desktop/AppBootstrapper.cs | 8 ++++++- .../Services/AiPlanningService.cs | 6 +++++ ai-backend/README.md | 13 ++++++++++ ai-backend/config.example.php | 5 +++- ai-backend/plan.php | 9 +++++++ ai-backend/providers/AnthropicProvider.php | 9 ++++++- 7 files changed, 71 insertions(+), 3 deletions(-) diff --git a/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs b/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs index 44edace..4fbf47b 100644 --- a/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs +++ b/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs @@ -82,6 +82,23 @@ public sealed class AiPlanningServiceTests Assert.Equal("DIE ORIGINALANTWORT", error.RawResponse); } + [Fact] + public async Task RequestPlanAsync_Zeitueberschreitung_LiefertKonkreteProxyMeldung() + { + var http = new HttpClient(new CanceledResponseHandler()) + { + BaseAddress = new Uri("https://example.invalid/"), + }; + var service = new AiPlanningService(http, new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), + new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); + + var error = await Assert.ThrowsAsync(() => + service.RequestPlanAsync(new Unit(), "", "token")); + + Assert.Contains("3½ Minuten", error.Message); + Assert.Contains("Webserver-Proxys", error.Message); + } + [Fact] public void Rettungsdialog_ImportiertMarkierteStundeAlsNeu() { @@ -118,6 +135,13 @@ public sealed class AiPlanningServiceTests }); } + private sealed class CanceledResponseHandler : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) => Task.FromCanceled( + new CancellationToken(canceled: true)); + } + [Fact] public void BuildContext_FuelltGruppenUndFachKontext() { diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index ba7dcb2..ebac69d 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -203,7 +203,13 @@ public static class AppBootstrapper // ── KI-Unterstützung (4.5.9, optional – nur wenn in den Einstellungen aktiviert) ────── services.AddSingleton(_ => new AiSettingsService(appData)); - services.AddSingleton(_ => new HttpClient { BaseAddress = new Uri(AiBackendUrl) }); + services.AddSingleton(_ => new HttpClient + { + BaseAddress = new Uri(AiBackendUrl), + // Das PHP-Backend wartet höchstens 180 s auf umfangreiche KI-Antworten. Der Client + // bleibt bewusst etwas länger offen, damit dessen konkrete Fehlermeldung noch ankommt. + Timeout = TimeSpan.FromSeconds(210), + }); services.AddSingleton(); // ── WebUntis-iCal-Abgleich (optional – nur wenn URL hinterlegt und aktiviert) ───────── diff --git a/LehrerApp.Desktop/Services/AiPlanningService.cs b/LehrerApp.Desktop/Services/AiPlanningService.cs index 026204a..91fa337 100644 --- a/LehrerApp.Desktop/Services/AiPlanningService.cs +++ b/LehrerApp.Desktop/Services/AiPlanningService.cs @@ -350,6 +350,12 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, HttpResponseMessage resp; try { resp = await http.SendAsync(req); } + catch (TaskCanceledException) + { + throw new AiBackendException( + "Die KI-Anfrage hat länger als 3½ Minuten gedauert und wurde beendet. " + + "Falls das wiederholt passiert, bitte das Zeitlimit des Webserver-Proxys prüfen."); + } catch (HttpRequestException) { throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen."); diff --git a/ai-backend/README.md b/ai-backend/README.md index 2ed3c80..574079f 100644 --- a/ai-backend/README.md +++ b/ai-backend/README.md @@ -188,6 +188,19 @@ deaktiviert (`AllowOverride None`), oder es läuft nginx statt Apache (dort gilt grundsätzlich nicht) — dann hilft nur eine serverseitige Konfiguration durch den Hoster/Support (z.B. bei nginx ein `fastcgi_param HTTP_AUTHORIZATION $http_authorization;`). +## Fehlerbehebung: KI-Antwort endet mitten im JSON + +Eine im Rettungsdialog am Ende abgeschnittene Modellantwort entsteht typischerweise nicht durch +die Socketverbindung, sondern durch Anthropics `stop_reason: max_tokens`. Das Backend erkennt +diesen Fall ausdrücklich und nennt das konfigurierte Limit in der Fehlermeldung. Der empfohlene +Wert für `max_output_tokens` ist **16000**; abgerechnet werden trotzdem nur tatsächlich erzeugte +Tokens. + +Für echte Zeitüberschreitungen wartet der Anthropic-cURL-Aufruf höchstens 180 Sekunden und der +Desktop-Client 210 Sekunden. Bricht eine Anfrage reproduzierbar früher ab, liegt davor noch ein +Zeitlimit des Hosters (nginx/Apache/FastCGI/Reverse Proxy), das auf mindestens 180 Sekunden gesetzt +werden muss. + ## Was hiermit NICHT geprüft ist - Ob Anthropic zuverlässig valides JSON im erwarteten Schema liefert (reine Prompt-Qualitätsfrage, diff --git a/ai-backend/config.example.php b/ai-backend/config.example.php index 3dbedc0..481dab2 100644 --- a/ai-backend/config.example.php +++ b/ai-backend/config.example.php @@ -27,7 +27,10 @@ return [ ], // Sicherheitsnetz gegen ausufernde Antworten (und damit Kosten) pro Anfrage. - 'max_output_tokens' => 8000, + // 8.000 waren bei ausführlichen Einheitenplanungen nachweislich zu knapp und führten zu + // mitten im JSON abgeschnittenen Antworten. Das ist eine Obergrenze, kein vorab berechneter + // Festpreis: kürzere Antworten verbrauchen und kosten weiterhin nur ihre tatsächlichen Tokens. + 'max_output_tokens' => 16000, // USD je 1 Million Token, getrennt nach Input/Output/Prompt-Cache. Vor dem produktiven Einsatz // gegen die aktuelle Anthropic-Preisseite gegenprüfen — Preise ändern sich. diff --git a/ai-backend/plan.php b/ai-backend/plan.php index 3d8a224..0e4ba7f 100644 --- a/ai-backend/plan.php +++ b/ai-backend/plan.php @@ -161,6 +161,15 @@ $result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userC // Erst NACH der Abrechnung validieren: die Token wurden real verbraucht, das wird auch dann // verrechnet, wenn die KI kein valides JSON geliefert hat (siehe Planungsdokument). +if (($result['stopReason'] ?? null) === 'max_tokens') { + ai_backend_fail(502, + 'Die KI-Antwort wurde am Ausgabelimit von ' . $config['max_output_tokens'] . + ' Tokens abgeschnitten. Bitte den Umfang der Anfrage verkleinern oder das Serverlimit erhöhen.', [ + 'errorCode' => 'output_limit_reached', + 'rawResponse' => $result['content'], + ]); +} + $parsed = ai_backend_decode_json_response($result['content']); if (!is_array($parsed) || !isset($parsed['lessons']) || !is_array($parsed['lessons'])) { // Die bezahlte Modellantwort nicht wegwerfen: Der Desktop-Client kann sie in einem diff --git a/ai-backend/providers/AnthropicProvider.php b/ai-backend/providers/AnthropicProvider.php index b4880d9..d041c23 100644 --- a/ai-backend/providers/AnthropicProvider.php +++ b/ai-backend/providers/AnthropicProvider.php @@ -37,7 +37,11 @@ class AnthropicProvider implements ProviderInterface ], 'messages' => [['role' => 'user', 'content' => $userContent]], ]), - CURLOPT_TIMEOUT => 90, + // Umfangreiche Einheitenplanungen können bei großen Antworten länger dauern. Der + // Desktop-Client wartet etwas länger (210 s), damit immer dieser Server mit einer + // verständlichen Fehlermeldung antworten kann, statt dass zuerst der Client abbricht. + CURLOPT_CONNECTTIMEOUT => 15, + CURLOPT_TIMEOUT => 180, ]); $raw = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); @@ -68,6 +72,9 @@ class AnthropicProvider implements ProviderInterface return [ 'content' => $text, + // Insbesondere "max_tokens" darf nicht verloren gehen: Dann ist der Text zwar eine + // technisch erfolgreiche API-Antwort, das JSON aber zwangsläufig am Ende abgeschnitten. + 'stopReason' => $data['stop_reason'] ?? null, 'inputTokens' => (int) ($data['usage']['input_tokens'] ?? 0), 'outputTokens' => (int) ($data['usage']['output_tokens'] ?? 0), 'cacheCreationInputTokens' => (int) ($data['usage']['cache_creation_input_tokens'] ?? 0),