From fedfceb81d0810d13e7aaf89f39714a5edefba0a Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Fri, 4 Sep 2026 12:10:42 +0200 Subject: [PATCH] KI-Backend: Antwortretten - Funktion --- LehrerApp.Core/AiPlanning/AiPlanningDtos.cs | 4 + .../AiPlanningServiceTests.cs | 103 +++++++++++++ .../Services/AiPlanningService.cs | 76 +++++++++- .../ViewModels/Groups/PlanningViewModels.cs | 140 +++++++++++++++++- .../Views/Groups/AiAssistDialog.axaml | 8 +- .../Views/Groups/AiAssistDialog.axaml.cs | 16 ++ .../Views/Groups/AiResponseRescueDialog.axaml | 50 +++++++ .../Groups/AiResponseRescueDialog.axaml.cs | 35 +++++ ai-backend/db.php | 4 +- ai-backend/plan.php | 13 +- 10 files changed, 435 insertions(+), 14 deletions(-) create mode 100644 LehrerApp.Desktop/Views/Groups/AiResponseRescueDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Groups/AiResponseRescueDialog.axaml.cs diff --git a/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs b/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs index aa1227d..74873ae 100644 --- a/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs +++ b/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs @@ -100,6 +100,10 @@ public class AiPlanningResponse { public List Lessons { get; set; } = []; public string? Summary { get; set; } + // Vom Backend zusätzlich mitgelieferte, unveränderte Modellantwort. Wird nur benötigt, falls + // die typisierte Deserialisierung scheitert; bei einer regulär verarbeiteten Antwort wird sie + // weder angezeigt noch gespeichert. + public string? RawResponse { get; set; } } /// diff --git a/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs b/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs index 088a494..44edace 100644 --- a/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs +++ b/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs @@ -1,6 +1,9 @@ using LehrerApp.Core.AiPlanning; using LehrerApp.Core.Models; using LehrerApp.Desktop.Services; +using LehrerApp.Desktop.ViewModels.Groups; +using System.Net; +using System.Text; using Xunit; namespace LehrerApp.Desktop.Tests; @@ -15,6 +18,106 @@ public sealed class AiPlanningServiceTests FakeCompetencyDomains competencyDomains, FakeAlternativeLessonPaths altPaths) => new(new HttpClient(), lessons, groups, subjects, competencyDomains, altPaths); + [Fact] + public void ParsePlanningLessons_AkzeptiertNormaleAntwortUndDeutschesDatum() + { + const string json = """ + {"lessons":[{"id":null,"date":"15.09.2026","lessonNumber":2,"topic":"Redox","startTime":"08:35","phases":[]}],"summary":"ok"} + """; + + var lesson = Assert.Single(AiPlanningService.ParsePlanningLessons(json)); + + Assert.Equal("Redox", lesson.Topic); + Assert.Equal(new DateOnly(2026, 9, 15), lesson.Date); + Assert.Equal(new TimeOnly(8, 35), lesson.StartTime); + } + + [Fact] + public void ParsePlanningLessons_AkzeptiertEinzelneMarkierteStunde() + { + const string json = """ + {"topic":"Nur diese Stunde","phases":[{"name":"Einstieg","durationMinutes":5,"activity":"Impuls","material":"Bild","shorthand":"UG"}]} + """; + + var lesson = Assert.Single(AiPlanningService.ParsePlanningLessons(json)); + + Assert.Equal("Nur diese Stunde", lesson.Topic); + Assert.Single(lesson.Phases); + } + + [Fact] + public void ParsePlanningLessons_UngueltigeMarkierung_LiefertVerstaendlichenFehler() + { + var error = Assert.Throws(() => + AiPlanningService.ParsePlanningLessons("Hier kommt das JSON: { kaputt }")); + + Assert.Contains("noch kein gültiges Stunden-JSON", error.Message); + } + + [Fact] + public async Task RequestPlanAsync_Backendfehler_BewahrtOriginalantwortFuerRettung() + { + const string body = """ + {"error":"Kein gültiges JSON.","rawResponse":"Vorspann\n{kaputt}"} + """; + var service = BuildWithResponse(HttpStatusCode.BadGateway, body); + + var error = await Assert.ThrowsAsync(() => + service.RequestPlanAsync(new Unit(), "", "token")); + + Assert.Equal("Vorspann\n{kaputt}", error.RawResponse); + } + + [Fact] + public async Task RequestPlanAsync_Typfehler_BewahrtEingebetteteOriginalantwortFuerRettung() + { + const string body = """ + {"lessons":[{"topic":"Test","date":"kein Datum"}],"rawResponse":"DIE ORIGINALANTWORT"} + """; + var service = BuildWithResponse(HttpStatusCode.OK, body); + + var error = await Assert.ThrowsAsync(() => + service.RequestPlanAsync(new Unit(), "", "token")); + + Assert.Equal("DIE ORIGINALANTWORT", error.RawResponse); + } + + [Fact] + public void Rettungsdialog_ImportiertMarkierteStundeAlsNeu() + { + var group = new LearningGroup(); + var unit = new Unit { GroupId = group.Id, Title = "T" }; + var lessons = new FakeLessons(); + var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]), + new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); + var vm = new AiResponseRescueDialogViewModel(service, lessons, unit, "Rohantwort"); + + vm.ParseSelection("""{"topic":"Gerettete Stunde","phases":[]}"""); + vm.ImportAsNewCommand.Execute(null); + + Assert.True(vm.Result); + Assert.Equal("Gerettete Stunde", Assert.Single(lessons.GetByUnit(unit.Id)).Topic); + } + + private static AiPlanningService BuildWithResponse(HttpStatusCode status, string body) + { + var http = new HttpClient(new StaticResponseHandler(status, body)) + { + BaseAddress = new Uri("https://example.invalid/"), + }; + return new AiPlanningService(http, new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), + new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); + } + + private sealed class StaticResponseHandler(HttpStatusCode status, string body) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, + CancellationToken cancellationToken) => Task.FromResult(new HttpResponseMessage(status) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }); + } + [Fact] public void BuildContext_FuelltGruppenUndFachKontext() { diff --git a/LehrerApp.Desktop/Services/AiPlanningService.cs b/LehrerApp.Desktop/Services/AiPlanningService.cs index 1b58ce3..026204a 100644 --- a/LehrerApp.Desktop/Services/AiPlanningService.cs +++ b/LehrerApp.Desktop/Services/AiPlanningService.cs @@ -10,7 +10,14 @@ using System.Text.Json.Serialization; namespace LehrerApp.Desktop.Services; /// Fehler beim Aufruf des KI-Backends, Message ist bereits deutsch und nutzergerichtet. -public class AiBackendException(string userMessage) : Exception(userMessage); +public class AiBackendException(string userMessage, string? rawResponse = null) : Exception(userMessage) +{ + /// + /// Unveränderte Modellantwort, sofern der Fehler beim Lesen einer Planungsantwort entstand. + /// Sie wird ausschließlich für den ausdrücklich vom Nutzer gestarteten Rettungsdialog gehalten. + /// + public string? RawResponse { get; } = rawResponse; +} /// /// Der KI-Wire-Vertrag verwendet deutsches Datumsformat (siehe ai-backend/plan.php Systemprompt, @@ -87,16 +94,19 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, private static async Task BuildRequestFailedExceptionAsync(HttpResponseMessage resp) { string? backendReason = null; + string? rawResponse = null; try { - var body = await resp.Content.ReadFromJsonAsync(JsonOptions); + 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. */ } return new AiBackendException(backendReason is null ? "Die Anfrage an den KI-Dienst ist fehlgeschlagen." - : $"Die Anfrage an den KI-Dienst ist fehlgeschlagen: {backendReason}"); + : $"Die Anfrage an den KI-Dienst ist fehlgeschlagen: {backendReason}", rawResponse); } public async Task LoginAsync(string username, string password) @@ -352,14 +362,64 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, if (!resp.IsSuccessStatusCode) throw await BuildRequestFailedExceptionAsync(resp); + var responseText = await resp.Content.ReadAsStringAsync(); try { - var result = await resp.Content.ReadFromJsonAsync(JsonOptions); + var result = JsonSerializer.Deserialize(responseText, JsonOptions); return result ?? throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden."); } catch (Exception ex) when (ex is not AiBackendException) { - throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen."); + string? rawModelResponse = null; + try + { + using var envelope = JsonDocument.Parse(responseText); + if (envelope.RootElement.TryGetProperty("rawResponse", out var rawElement) + && rawElement.ValueKind == JsonValueKind.String) + rawModelResponse = rawElement.GetString(); + } + catch (JsonException) { /* Die HTTP-Antwort selbst war ungültig; unten komplett zeigen. */ } + + throw new AiBackendException( + "Die Antwort der KI konnte nicht verarbeitet werden. Du kannst die Antwort manuell retten.", + string.IsNullOrWhiteSpace(rawModelResponse) ? responseText : rawModelResponse); + } + } + + /// + /// Liest eine von Hand markierte Modellantwort. Akzeptiert die normale Antwort-Hülle, ein + /// einzelnes Lesson-Objekt oder ein Array von Lessons, damit auch nur der relevante Ausschnitt + /// der Rohantwort markiert werden kann. + /// + public static List ParsePlanningLessons(string json) + { + if (string.IsNullOrWhiteSpace(json)) + throw new AiBackendException("Bitte markiere zuerst den JSON-Abschnitt der Antwort."); + + try + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + List? parsed = root.ValueKind switch + { + JsonValueKind.Object when root.TryGetProperty("lessons", out _) => + JsonSerializer.Deserialize(json, JsonOptions)?.Lessons, + JsonValueKind.Object => + [JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new JsonException("Leere Stunde")], + JsonValueKind.Array => JsonSerializer.Deserialize>(json, JsonOptions), + _ => null, + }; + + if (parsed is null || parsed.Count == 0) + throw new JsonException("Keine Stunde enthalten"); + if (parsed.Any(l => string.IsNullOrWhiteSpace(l.Topic))) + throw new JsonException("Mindestens einer Stunde fehlt das Feld topic"); + return parsed; + } + catch (JsonException ex) + { + throw new AiBackendException($"Der markierte Text ist noch kein gültiges Stunden-JSON: {ex.Message}"); } } @@ -541,5 +601,9 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, private class LoginResult { public string Token { get; set; } = ""; } private class BalanceResult { public decimal BalanceUsd { get; set; } } - private class BackendErrorResult { public string? Error { get; set; } } + private class BackendErrorResult + { + public string? Error { get; set; } + public string? RawResponse { get; set; } + } } diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs index 1c1fdad..82e77d5 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs @@ -1491,10 +1491,16 @@ public partial class AiAssistDialogViewModel : ObservableObject [ObservableProperty] private string _errorMessage = ""; [ObservableProperty] private bool _hasResults; [ObservableProperty] private string? _summary; + [ObservableProperty] private string? _rawResponse; public string UnitSummary { get; } public ObservableCollection ReviewItems { get; } = []; public bool Result { get; private set; } + public Unit Unit => _unit; + public Guid? FocusLessonId => _focusLesson?.Id; + public bool CanRescueResponse => !string.IsNullOrWhiteSpace(RawResponse); + + partial void OnRawResponseChanged(string? value) => OnPropertyChanged(nameof(CanRescueResponse)); /// Aus dem Editor einer einzelnen Stunde heraus gestartet (statt aus der Einheiten-Übersicht, /// Nutzer-Feedback nach den ersten Live-Tests) — die KI darf dann ausschließlich diese eine @@ -1533,7 +1539,7 @@ public partial class AiAssistDialogViewModel : ObservableObject ? ReviewItems.Where(i => i.Accepted).Select(i => i.Source).ToList() : null; - ErrorMessage = ""; IsBusy = true; + ErrorMessage = ""; RawResponse = null; IsBusy = true; try { var response = await _aiPlanning.RequestPlanAsync(_unit, Instruction, token, AllowModifyingExisting, @@ -1565,7 +1571,11 @@ public partial class AiAssistDialogViewModel : ObservableObject Summary = response.Summary; HasResults = true; } - catch (AiBackendException ex) { ErrorMessage = ex.Message; } + catch (AiBackendException ex) + { + ErrorMessage = ex.Message; + RawResponse = ex.RawResponse; + } finally { IsBusy = false; } } @@ -1579,6 +1589,132 @@ public partial class AiAssistDialogViewModel : ObservableObject } [RelayCommand] private void Cancel() => Result = false; + + public void MarkRescueImported() => Result = true; +} + +/// Aus einer manuell geprüften KI-Antwort auswählbare Stunde. +public record AiRescueLessonOption(AiLesson Lesson, string Label); + +/// Ziel für den manuellen Import in eine bereits vorhandene Stunde. +public record AiRescueTargetOption(Lesson Lesson, string Label); + +/// +/// Rettungsdialog für syntaktisch fehlerhafte oder mit Freitext vermischte Modellantworten. Der +/// Nutzer entscheidet selbst, welcher Textabschnitt geparst und wohin die Stunde importiert wird. +/// +public partial class AiResponseRescueDialogViewModel : ObservableObject +{ + private readonly AiPlanningService _aiPlanning; + private readonly ILessonRepository _lessons; + private readonly Unit _unit; + + [ObservableProperty] private string _responseText; + [ObservableProperty] private string _parseMessage = + "Markiere den gültigen JSON-Abschnitt oder bearbeite den Text und klicke auf „Markierung prüfen“."; + [ObservableProperty] private AiRescueLessonOption? _selectedParsedLesson; + [ObservableProperty] private AiRescueTargetOption? _selectedTarget; + + public ObservableCollection ParsedLessons { get; } = []; + public ObservableCollection ExistingLessons { get; } = []; + public bool HasParsedLesson => SelectedParsedLesson is not null; + public bool CanImportIntoExisting => SelectedParsedLesson is not null && SelectedTarget is not null; + public bool Result { get; private set; } + + partial void OnSelectedParsedLessonChanged(AiRescueLessonOption? value) + { + OnPropertyChanged(nameof(HasParsedLesson)); + OnPropertyChanged(nameof(CanImportIntoExisting)); + } + + partial void OnSelectedTargetChanged(AiRescueTargetOption? value) => + OnPropertyChanged(nameof(CanImportIntoExisting)); + + public AiResponseRescueDialogViewModel(AiPlanningService aiPlanning, ILessonRepository lessons, + Unit unit, string rawResponse, Guid? preferredTargetId = null) + { + _aiPlanning = aiPlanning; + _lessons = lessons; + _unit = unit; + _responseText = rawResponse; + + foreach (var lesson in lessons.GetByUnit(unit.Id).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber)) + { + var date = lesson.Date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture); + var number = lesson.LessonNumber is { } n ? $", Stunde {n}" : ""; + ExistingLessons.Add(new AiRescueTargetOption(lesson, $"{date}{number}: {lesson.Topic}")); + } + + SelectedTarget = ExistingLessons.FirstOrDefault(x => x.Lesson.Id == preferredTargetId) + ?? ExistingLessons.FirstOrDefault(); + } + + public void ParseSelection(string selectedText) + { + ParsedLessons.Clear(); + SelectedParsedLesson = null; + try + { + var parsed = AiPlanningService.ParsePlanningLessons(selectedText); + foreach (var lesson in parsed) + { + var date = lesson.Date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "ohne Datum"; + ParsedLessons.Add(new AiRescueLessonOption(lesson, $"{date}: {lesson.Topic}")); + } + SelectedParsedLesson = ParsedLessons[0]; + ParseMessage = parsed.Count == 1 + ? "Eine gültige Stunde erkannt." + : $"{parsed.Count} gültige Stunden erkannt. Bitte die gewünschte Stunde auswählen."; + } + catch (AiBackendException ex) + { + ParseMessage = ex.Message; + } + } + + [RelayCommand] + private void ImportIntoExisting() + { + if (SelectedParsedLesson is null || SelectedTarget is null) return; + var lesson = CloneForImport(SelectedParsedLesson.Lesson, SelectedTarget.Lesson.Id); + Save([lesson], focusLessonId: SelectedTarget.Lesson.Id); + } + + [RelayCommand] + private void ImportAsNew() + { + if (SelectedParsedLesson is null) return; + Save([CloneForImport(SelectedParsedLesson.Lesson, null)]); + } + + private void Save(List source, Guid? focusLessonId = null) + { + foreach (var lesson in _aiPlanning.ApplyResponse(_unit, source, + allowModifyingExistingLessons: true, focusLessonId)) + _lessons.Save(lesson); + Result = true; + } + + private static AiLesson CloneForImport(AiLesson source, Guid? id) => new() + { + Id = id, + Date = source.Date, + LessonNumber = source.LessonNumber, + Topic = source.Topic, + StartTime = source.StartTime, + Homework = source.Homework, + Reflection = source.Reflection, + Phases = source.Phases.Select(p => new AiPhaseStep + { + Name = p.Name, + DurationMinutes = p.DurationMinutes, + Activity = p.Activity, + Material = p.Material, + Shorthand = p.Shorthand, + AlternativePathName = p.AlternativePathName, + MaterialSuggestion = p.MaterialSuggestion, + }).ToList(), + }; } // ── Dialog: Einheit als Vorlage in andere Gruppe kopieren (4.1.4) ──────────── diff --git a/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml b/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml index 8bd8f34..b2da187 100644 --- a/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml @@ -78,8 +78,12 @@ - + + +