This commit is contained in:
@@ -82,6 +82,23 @@ public sealed class AiPlanningServiceTests
|
|||||||
Assert.Equal("DIE ORIGINALANTWORT", error.RawResponse);
|
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]
|
[Fact]
|
||||||
public void Rettungsdialog_ImportiertMarkierteStundeAlsNeu()
|
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]
|
[Fact]
|
||||||
public void BuildContext_FuelltGruppenUndFachKontext()
|
public void BuildContext_FuelltGruppenUndFachKontext()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -203,7 +203,13 @@ public static class AppBootstrapper
|
|||||||
|
|
||||||
// ── KI-Unterstützung (4.5.9, optional – nur wenn in den Einstellungen aktiviert) ──────
|
// ── KI-Unterstützung (4.5.9, optional – nur wenn in den Einstellungen aktiviert) ──────
|
||||||
services.AddSingleton(_ => new AiSettingsService(appData));
|
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>();
|
services.AddSingleton<AiPlanningService>();
|
||||||
|
|
||||||
// ── WebUntis-iCal-Abgleich (optional – nur wenn URL hinterlegt und aktiviert) ─────────
|
// ── WebUntis-iCal-Abgleich (optional – nur wenn URL hinterlegt und aktiviert) ─────────
|
||||||
|
|||||||
@@ -350,6 +350,12 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
|||||||
|
|
||||||
HttpResponseMessage resp;
|
HttpResponseMessage resp;
|
||||||
try { resp = await http.SendAsync(req); }
|
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)
|
catch (HttpRequestException)
|
||||||
{
|
{
|
||||||
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
|
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
|
||||||
|
|||||||
@@ -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
|
grundsätzlich nicht) — dann hilft nur eine serverseitige Konfiguration durch den Hoster/Support
|
||||||
(z.B. bei nginx ein `fastcgi_param HTTP_AUTHORIZATION $http_authorization;`).
|
(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
|
## Was hiermit NICHT geprüft ist
|
||||||
|
|
||||||
- Ob Anthropic zuverlässig valides JSON im erwarteten Schema liefert (reine Prompt-Qualitätsfrage,
|
- Ob Anthropic zuverlässig valides JSON im erwarteten Schema liefert (reine Prompt-Qualitätsfrage,
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ return [
|
|||||||
],
|
],
|
||||||
|
|
||||||
// Sicherheitsnetz gegen ausufernde Antworten (und damit Kosten) pro Anfrage.
|
// 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
|
// 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.
|
// gegen die aktuelle Anthropic-Preisseite gegenprüfen — Preise ändern sich.
|
||||||
|
|||||||
@@ -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
|
// 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).
|
// 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']);
|
$parsed = ai_backend_decode_json_response($result['content']);
|
||||||
if (!is_array($parsed) || !isset($parsed['lessons']) || !is_array($parsed['lessons'])) {
|
if (!is_array($parsed) || !isset($parsed['lessons']) || !is_array($parsed['lessons'])) {
|
||||||
// Die bezahlte Modellantwort nicht wegwerfen: Der Desktop-Client kann sie in einem
|
// Die bezahlte Modellantwort nicht wegwerfen: Der Desktop-Client kann sie in einem
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ class AnthropicProvider implements ProviderInterface
|
|||||||
],
|
],
|
||||||
'messages' => [['role' => 'user', 'content' => $userContent]],
|
'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);
|
$raw = curl_exec($ch);
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
@@ -68,6 +72,9 @@ class AnthropicProvider implements ProviderInterface
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'content' => $text,
|
'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),
|
'inputTokens' => (int) ($data['usage']['input_tokens'] ?? 0),
|
||||||
'outputTokens' => (int) ($data['usage']['output_tokens'] ?? 0),
|
'outputTokens' => (int) ($data['usage']['output_tokens'] ?? 0),
|
||||||
'cacheCreationInputTokens' => (int) ($data['usage']['cache_creation_input_tokens'] ?? 0),
|
'cacheCreationInputTokens' => (int) ($data['usage']['cache_creation_input_tokens'] ?? 0),
|
||||||
|
|||||||
Reference in New Issue
Block a user