From 30f0fee0b28ae6ca5de2e1b172e7344b05e265f8 Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Mon, 21 Sep 2026 00:27:52 +0200 Subject: [PATCH] fix: KI-Backend brach lange Anfragen durch PHP-Timeout ab ai_backend_call_and_charge (db.php) hob PHPs eigenes max_execution_time nie an, obwohl AnthropicProvider dem curl-Aufruf bis zu 180s erlaubt. Auf vielen Hosts liegt PHPs Default bei nur 30s - sobald ein Aufruf laenger brauchte, killte PHP das Skript mitten in curl_exec() mit einem unabfangbaren Fatal Error statt einer sauberen {"error": "..."}-Antwort. Der Desktop-Client zeigte dadurch nur die pauschale Fallback-Meldung ohne Grund - sah wie ein kompletter Ausfall der Einzelstunden-KI-Planung aus, war aber ein request-Dauer-abhaengiger Timeout, der jeden der fuenf ai_backend_call_and_charge-Endpunkte treffen konnte. Fix zentral in ai_backend_call_and_charge (set_time_limit(200)) statt in jedem Endpunkt einzeln - nach Deployment vom Nutzer verifiziert. Zusaetzlich dauerhaftes Diagnose-Logging in AiPlanningService (Statuscode/Dauer/ Antwort-Body bei Fehlern ohne auswertbaren Grund) fuer kuenftige Faelle. Co-Authored-By: Claude Sonnet 5 --- .../AiPlanningServiceTests.cs | 8 +++-- LehrerApp.Desktop.Tests/Fakes.cs | 3 +- .../HazardAssessmentWizardViewModelTests.cs | 3 +- .../Services/AiPlanningService.cs | 32 ++++++++++++++++--- TODO.md | 17 ++++++++++ ai-backend/db.php | 9 ++++++ 6 files changed, 63 insertions(+), 9 deletions(-) diff --git a/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs b/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs index 9ad7acc..e8348cd 100644 --- a/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs +++ b/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs @@ -18,7 +18,7 @@ public sealed class AiPlanningServiceTests FakeCompetencyDomains competencyDomains, FakeAlternativeLessonPaths altPaths, FakeCompetencyCatalogVersions? competencyVersions = null) => new(new HttpClient(), lessons, groups, subjects, competencyDomains, - competencyVersions ?? new FakeCompetencyCatalogVersions(), altPaths); + competencyVersions ?? new FakeCompetencyCatalogVersions(), altPaths, TestSupport.BuildAppLogger()); [Fact] public void ParsePlanningLessons_AkzeptiertNormaleAntwortUndDeutschesDatum() @@ -92,7 +92,8 @@ public sealed class AiPlanningServiceTests BaseAddress = new Uri("https://example.invalid/"), }; var service = new AiPlanningService(http, new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), - new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([])); + new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([]), + TestSupport.BuildAppLogger()); var error = await Assert.ThrowsAsync(() => service.RequestPlanAsync(new Unit(), "", "token")); @@ -191,7 +192,8 @@ public sealed class AiPlanningServiceTests BaseAddress = new Uri("https://example.invalid/"), }; return new AiPlanningService(http, new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), - new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([])); + new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([]), + TestSupport.BuildAppLogger()); } private sealed class StaticResponseHandler(HttpStatusCode status, string body) : HttpMessageHandler diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index 9530874..5bae1aa 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -30,7 +30,8 @@ public static class TestSupport /// solange AiSettingsService.IsLoggedIn false ist. public static AiPlanningService BuildAiPlanningService() => new( new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), - new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([])); + new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([]), + BuildAppLogger()); /// Analog zu , eigenes Temp-Verzeichnis je Aufruf. public static McpSettingsService BuildMcpSettingsService() diff --git a/LehrerApp.Desktop.Tests/HazardAssessmentWizardViewModelTests.cs b/LehrerApp.Desktop.Tests/HazardAssessmentWizardViewModelTests.cs index af08d66..4797433 100644 --- a/LehrerApp.Desktop.Tests/HazardAssessmentWizardViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/HazardAssessmentWizardViewModelTests.cs @@ -254,7 +254,8 @@ public sealed class HazardAssessmentWizardViewModelTests aiSettings.SetEnabled(true); aiSettings.SetCredentialsAndToken("sebastian", "token-abc"); var ai = new AiPlanningService(new HttpClient(), new FakeLessons(), new FakeGroups([]), - new FakeSubjects([]), new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([])); + new FakeSubjects([]), new FakeCompetencyDomains(), new FakeCompetencyCatalogVersions(), new FakeAlternativeLessonPaths([]), + TestSupport.BuildAppLogger()); var unit = new Unit { Title = "Redoxreaktionen" }; var lesson = new Lesson { Topic = "Natrium in Wasser" }; diff --git a/LehrerApp.Desktop/Services/AiPlanningService.cs b/LehrerApp.Desktop/Services/AiPlanningService.cs index de7fb15..3619955 100644 --- a/LehrerApp.Desktop/Services/AiPlanningService.cs +++ b/LehrerApp.Desktop/Services/AiPlanningService.cs @@ -74,7 +74,7 @@ public class GermanTimeOnlyJsonConverter : JsonConverter public class AiPlanningService(HttpClient http, ILessonRepository lessons, IGroupRepository groups, ISubjectRepository subjects, ICompetencyDomainRepository competencyDomains, ICompetencyCatalogVersionRepository competencyVersions, - IAlternativeLessonPathRepository altPaths) + IAlternativeLessonPathRepository altPaths, AppLogger logger) { // Wire-Format zum PHP-Backend ist camelCase (siehe ai-backend/) — beide Seiten sind hier // im eigenen Zugriff, daher bewusst konsistent camelCase statt der C#-üblichen PascalCase-Defaults. @@ -93,19 +93,33 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, /// Websuche in substance.php (falscher Tool-Bezeichner, fehlender Beta-Header o.ä.) der /// eigentliche Grund nirgends sichtbar, nur "Die Anfrage ist fehlgeschlagen". /// - private static async Task BuildRequestFailedExceptionAsync(HttpResponseMessage resp) + private async Task BuildRequestFailedExceptionAsync(HttpResponseMessage resp) { string? backendReason = null; string? rawResponse = null; + var responseText = await resp.Content.ReadAsStringAsync(); try { - var responseText = await resp.Content.ReadAsStringAsync(); var body = JsonSerializer.Deserialize(responseText, JsonOptions); backendReason = string.IsNullOrWhiteSpace(body?.Error) ? null : body!.Error; rawResponse = string.IsNullOrWhiteSpace(body?.RawResponse) ? null : body!.RawResponse; } catch { /* Antwortkörper war kein valides {"error": "..."}-JSON - Fallback unten greift. */ } + // Diagnose für "sofortige, grundlose" Fehlschläge (2026-09-21): das Backend selbst liefert + // bei jedem eigenen Fehlerpfad ein {"error": "..."} zurück (ai_backend_fail) - landet der + // Fallback unten trotzdem ohne Grund, kam die Antwort nie bei plan.php an (Proxy/Webserver- + // Fehlerseite, z.B. 413 Payload Too Large) oder PHP ist mitten im Request abgestürzt. Ohne + // Content-Type/Statuscode/Rohtext lässt sich das von hier aus nicht unterscheiden. + if (backendReason is null) + { + logger.Warn("AiPlanningService: Backend-Fehler ohne auswertbaren Grund. " + + $"Status={(int)resp.StatusCode} {resp.StatusCode}, " + + $"Content-Type={resp.Content.Headers.ContentType?.MediaType ?? "(keiner)"}, " + + $"Body-Länge={responseText.Length}, " + + $"Body (erste 500 Zeichen)={responseText[..Math.Min(500, responseText.Length)]}"); + } + return new AiBackendException(backendReason is null ? "Die Anfrage an den KI-Dienst ist fehlgeschlagen." : $"Die Anfrage an den KI-Dienst ist fehlgeschlagen: {backendReason}", rawResponse); @@ -354,19 +368,29 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, Content = JsonContent.Create(request, options: JsonOptions), }; req.Headers.Authorization = new("Bearer", token); + var requestBytes = await req.Content!.ReadAsByteArrayAsync(); + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); HttpResponseMessage resp; try { resp = await http.SendAsync(req); } catch (TaskCanceledException) { + logger.Warn($"AiPlanningService: plan.php-Anfrage nach {stopwatch.ElapsedMilliseconds}ms " + + $"abgebrochen (Client-Timeout), Request-Größe={requestBytes.Length} Bytes."); 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 ex) { + logger.Warn($"AiPlanningService: plan.php-Anfrage nach {stopwatch.ElapsedMilliseconds}ms " + + $"ohne Antwort gescheitert, Request-Größe={requestBytes.Length} Bytes: {ex.Message}"); throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen."); } + stopwatch.Stop(); + if (!resp.IsSuccessStatusCode) + logger.Warn($"AiPlanningService: plan.php antwortete nach {stopwatch.ElapsedMilliseconds}ms " + + $"mit {(int)resp.StatusCode} {resp.StatusCode}, Request-Größe={requestBytes.Length} Bytes."); if (resp.StatusCode == HttpStatusCode.Unauthorized) throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden."); diff --git a/TODO.md b/TODO.md index b414799..61ccc3a 100644 --- a/TODO.md +++ b/TODO.md @@ -2971,6 +2971,23 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`: schreibbar, damit auch ein extern (z.B. in Claude Desktop) formulierter Prompt zur Wiederverwendung gespeichert werden kann, nicht nur ein vom eigenen KI-Backend erzeugter. +**Ebenfalls behoben (2026-09-21, Nutzer-Feedback: "keine KI-Planung mehr zu einer einzelnen +Stunde"):** `ai_backend_call_and_charge` (`db.php`) rief den LLM-Provider auf, ohne PHPs eigenes +`max_execution_time` anzuheben. `AnthropicProvider` erlaubt dem curl-Aufruf bis zu 180s, aber auf +vielen Hosts liegt PHPs Default bei nur 30s — sobald ein Aufruf einmal länger brauchte (hier: +erstmaliger Aufruf mit gefülltem `planningIdeas`, mehr Kontext im Prompt), killte PHP das Skript +mitten in `curl_exec()` mit einem unabfangbaren Fatal Error. Der Client bekam dadurch keine gültige +`{"error": "..."}`-Antwort mehr und zeigte nur die pauschale Fallback-Meldung aus +`AiPlanningService.BuildRequestFailedExceptionAsync` — sah wie ein kompletter Ausfall der +Einzelstunden-KI-Planung aus, war aber ein request-Dauer-abhängiger Timeout, der jeden der fünf +`ai_backend_call_and_charge`-Endpunkte treffen konnte (`plan.php`, `explain.php`, +`untis-status.php`, `gbu.php`, `pdf-template.php`). Fix zentral in `ai_backend_call_and_charge` +selbst (`set_time_limit(200)` vor dem Provider-Aufruf) statt in jedem Endpunkt einzeln — nach +Deployment auf `backapi.science-teaching.de` vom Nutzer verifiziert, die zuvor fehlschlagende +Einzelstunden-Anfrage lief danach durch. Zusätzlich dauerhaft nützliches Diagnose-Logging in +`AiPlanningService` ergänzt (Statuscode/Dauer/Antwort-Body bei Fehlern ohne auswertbaren Grund), +für den Fall, dass ein ähnliches Problem an anderer Stelle wieder auftritt. + **Wichtige Abweichung von der ursprünglichen Planung (5.2):** Vor der Umsetzung zeigte sich, dass 5.2 wie ursprünglich beschrieben eine zweite, parallele Fehlzeiten-Erfassung neben dem bereits bestehenden Anwesenheits-Tracking aus Kapitel 3 (`ParticipationEntry.Attendance`, diff --git a/ai-backend/db.php b/ai-backend/db.php index fa97a05..d19e405 100644 --- a/ai-backend/db.php +++ b/ai-backend/db.php @@ -136,6 +136,15 @@ function ai_backend_call_and_charge(PDO $pdo, array $config, array $user, string $provider = new AnthropicProvider($config['anthropic']['api_key'], $modelKey); } + // AnthropicProvider erlaubt dem LLM-Aufruf per curl bis zu 180s (siehe dort) — PHPs eigenes + // max_execution_time-Limit (auf vielen Hosts standardmäßig nur 30s) läuft davon unbeeinflusst + // weiter und killt das Skript sonst mitten im curl_exec() mit einem unabfangbaren Fatal Error, + // sobald ein Aufruf einmal länger braucht als das Hosting-Default. Das ergibt beim Client keine + // lesbare Fehlermeldung (kein JSON-Body mehr), nur den pauschalen Fallback in + // AiPlanningService.BuildRequestFailedExceptionAsync. Deshalb hier großzügig über die 180s der + // Provider-Seite hinaus verlängern, statt das Limit stillschweigend greifen zu lassen. + set_time_limit(200); + try { $result = $provider->sendMessage($systemPrompt, $userContent, $config['max_output_tokens']); } catch (RuntimeException $e) {