ki Backend fix - Answer Size
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-09-07 12:45:50 +02:00
parent 709ea88c2a
commit 835cccadec
7 changed files with 71 additions and 3 deletions
@@ -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<AiBackendException>(() =>
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<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken) => Task.FromCanceled<HttpResponseMessage>(
new CancellationToken(canceled: true));
}
[Fact]
public void BuildContext_FuelltGruppenUndFachKontext()
{
+7 -1
View File
@@ -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<AiPlanningService>();
// ── WebUntis-iCal-Abgleich (optional nur wenn URL hinterlegt und aktiviert) ─────────
@@ -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.");
+13
View File
@@ -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,
+4 -1
View File
@@ -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.
+9
View File
@@ -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
+8 -1
View File
@@ -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),