diff --git a/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs b/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs index 022f76c..f34e9e2 100644 --- a/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs +++ b/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs @@ -103,6 +103,46 @@ public sealed class AiPlanningServiceTests Assert.Contains(context.AlternativePathCatalog, p => p.Name == "Vertiefung" && p.Id == path.Id); } + [Fact] + public void BuildContext_DraftOverride_ErsetztBestehendeLessonMitGleicherId() + { + var group = new LearningGroup(); + var unit = new Unit { GroupId = group.Id, Title = "T" }; + var existing = new Lesson { UnitId = unit.Id, GroupId = group.Id, Topic = "Alter Titel" }; + var lessons = new FakeLessons(); + lessons.Add(existing); + + var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]), + new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); + + var draft = new List { new() { Id = existing.Id, Topic = "Neuer Entwurfstitel", Phases = [] } }; + var context = service.BuildContext(unit, "", draft); + + var aiLesson = Assert.Single(context.Lessons); + Assert.Equal(existing.Id, aiLesson.Id); + Assert.Equal("Neuer Entwurfstitel", aiLesson.Topic); + } + + [Fact] + public void BuildContext_DraftOverride_HaengtNeueEntwurfLessonsOhneIdAn() + { + var group = new LearningGroup(); + var unit = new Unit { GroupId = group.Id, Title = "T" }; + var existing = new Lesson { UnitId = unit.Id, GroupId = group.Id, Topic = "Bestehend" }; + var lessons = new FakeLessons(); + lessons.Add(existing); + + var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]), + new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); + + var draft = new List { new() { Id = null, Topic = "Neu aus Runde 1", Phases = [] } }; + var context = service.BuildContext(unit, "", draft); + + Assert.Equal(2, context.Lessons.Count); + Assert.Contains(context.Lessons, l => l.Topic == "Bestehend"); + Assert.Contains(context.Lessons, l => l.Topic == "Neu aus Runde 1" && l.Id == null); + } + [Fact] public void ApplyResponse_BekannteId_WirdAlsUpdateBehandelt() { diff --git a/LehrerApp.Desktop.Tests/GermanDateTimeJsonConverterTests.cs b/LehrerApp.Desktop.Tests/GermanDateTimeJsonConverterTests.cs new file mode 100644 index 0000000..ab96456 --- /dev/null +++ b/LehrerApp.Desktop.Tests/GermanDateTimeJsonConverterTests.cs @@ -0,0 +1,80 @@ +using LehrerApp.Core.AiPlanning; +using LehrerApp.Desktop.Services; +using System.Text.Json; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +/// +/// Deckt den Bug ab, der beim ersten echten Test der KI-Unterstützung auftrat: .NETs +/// DateOnly/TimeOnly haben standardmäßig ein ISO-JSON-Format ("yyyy-MM-dd"/"HH:mm:ss"), der +/// KI-Wire-Vertrag dokumentiert aber deutsches Format ("TT.MM.JJJJ"/"HH:mm") — jede KI-Antwort im +/// dokumentierten Format scheiterte deshalb mit einer JsonException beim Deserialisieren. +/// +public sealed class GermanDateTimeJsonConverterTests +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new GermanDateOnlyJsonConverter(), new GermanTimeOnlyJsonConverter() }, + }; + + [Fact] + public void DateOnly_SerialisiertAlsDeutschesFormat() + { + Assert.Equal("\"24.12.2026\"", JsonSerializer.Serialize(new DateOnly(2026, 12, 24), Options)); + } + + [Fact] + public void DateOnly_ParstDeutschesFormat() + { + Assert.Equal(new DateOnly(2026, 12, 24), JsonSerializer.Deserialize("\"24.12.2026\"", Options)); + } + + /// LLMs weichen erfahrungsgemäß gelegentlich vom im Prompt dokumentierten Format ab — + /// ISO 8601 wird deshalb defensiv trotzdem akzeptiert statt hart zu scheitern. + [Fact] + public void DateOnly_FaelltBeiAbweichendemFormatAufAllgemeinesParsenZurueck() + { + Assert.Equal(new DateOnly(2026, 12, 24), JsonSerializer.Deserialize("\"2026-12-24\"", Options)); + } + + [Fact] + public void TimeOnly_SerialisiertAlsHHmm() + { + Assert.Equal("\"14:30\"", JsonSerializer.Serialize(new TimeOnly(14, 30), Options)); + } + + [Fact] + public void TimeOnly_ParstHHmm() + { + Assert.Equal(new TimeOnly(14, 30), JsonSerializer.Deserialize("\"14:30\"", Options)); + } + + /// Der eigentliche Bug betraf DateOnly?/TimeOnly? (nullable) in AiLesson, nicht die + /// nicht-nullable Basistypen — dieser Test deckt genau das ab, nicht nur die Converter isoliert. + [Fact] + public void AiLesson_MitDeutschemDatumUndUhrzeit_DeserialisiertKorrekt() + { + const string json = """ + {"id":null,"date":"24.12.2026","lessonNumber":3,"topic":"Test","startTime":"14:30", + "phases":[],"homework":null,"reflection":null} + """; + + var lesson = JsonSerializer.Deserialize(json, Options); + + Assert.Equal(new DateOnly(2026, 12, 24), lesson!.Date); + Assert.Equal(new TimeOnly(14, 30), lesson.StartTime); + } + + [Fact] + public void AiLesson_OhneDatumUndUhrzeit_BleibtNull() + { + const string json = """{"id":null,"topic":"Test","phases":[]}"""; + + var lesson = JsonSerializer.Deserialize(json, Options); + + Assert.Null(lesson!.Date); + Assert.Null(lesson.StartTime); + } +} diff --git a/LehrerApp.Desktop/Services/AiPlanningService.cs b/LehrerApp.Desktop/Services/AiPlanningService.cs index a4aabad..19a1423 100644 --- a/LehrerApp.Desktop/Services/AiPlanningService.cs +++ b/LehrerApp.Desktop/Services/AiPlanningService.cs @@ -1,15 +1,62 @@ using LehrerApp.Core.AiPlanning; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; +using System.Globalization; using System.Net; using System.Net.Http.Json; using System.Text.Json; +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); +/// +/// Der KI-Wire-Vertrag verwendet deutsches Datumsformat (siehe ai-backend/plan.php Systemprompt, +/// Abschnitt "Eingabeschema"/"Antwortformat"), nicht .NETs Standardformat für DateOnly (ISO +/// "yyyy-MM-dd") — ohne diesen Converter würde jede im dokumentierten Format zurückgegebene +/// KI-Antwort beim Deserialisieren mit einer JsonException scheitern. Fällt defensiv auf +/// allgemeines Parsen zurück, falls die KI sich nicht exakt ans Format hält, statt hart zu +/// scheitern — LLMs weichen erfahrungsgemäß gelegentlich vom dokumentierten Format ab. +/// +public class GermanDateOnlyJsonConverter : JsonConverter +{ + private const string Format = "dd.MM.yyyy"; + + public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString() ?? ""; + if (DateOnly.TryParseExact(value, Format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var exact)) + return exact; + if (DateOnly.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var fallback)) + return fallback; + throw new JsonException($"Datum \"{value}\" konnte nicht gelesen werden (erwartet: {Format})."); + } + + public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options) => + writer.WriteStringValue(value.ToString(Format, CultureInfo.InvariantCulture)); +} + +/// Analog zu , für "HH:mm" statt ISO-Zeiten. +public class GermanTimeOnlyJsonConverter : JsonConverter +{ + private const string Format = "HH:mm"; + + public override TimeOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString() ?? ""; + if (TimeOnly.TryParseExact(value, Format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var exact)) + return exact; + if (TimeOnly.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var fallback)) + return fallback; + throw new JsonException($"Uhrzeit \"{value}\" konnte nicht gelesen werden (erwartet: {Format})."); + } + + public override void Write(Utf8JsonWriter writer, TimeOnly value, JsonSerializerOptions options) => + writer.WriteStringValue(value.ToString(Format, CultureInfo.InvariantCulture)); +} + /// /// Orchestriert die KI-gestützte Planungsunterstützung (TODO 4.5.9): baut aus einer /// den Export-Kontext, ruft das externe PHP-Backend (ai-backend/) auf und wendet dessen Antwort auf @@ -26,6 +73,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, + Converters = { new GermanDateOnlyJsonConverter(), new GermanTimeOnlyJsonConverter() }, }; public async Task LoginAsync(string username, string password) @@ -71,7 +119,12 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, } /// Rein (nur Repository-Lesezugriffe, kein Netzwerk) — testbar mit Fakes. - public AiUnitContext BuildContext(Unit unit, string instruction) + /// + /// Noch nicht gespeicherter Entwurf aus einer vorherigen Antwortrunde (Nachfassen mit + /// geänderter Anweisung, siehe ) — überschreibt einzelne Lessons im + /// gesendeten Kontext, ohne dass dafür etwas in der Datenbank gespeichert werden muss. + /// + public AiUnitContext BuildContext(Unit unit, string instruction, List? draftOverrides = null) { var group = groups.GetById(unit.GroupId); var subject = group?.SubjectId is { } subjectId ? subjects.GetById(subjectId) : null; @@ -114,6 +167,9 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, }) .ToList(); + if (draftOverrides is not null) + unitLessons = MergeDraft(unitLessons, draftOverrides); + return new AiUnitContext { Id = unit.Id, @@ -131,13 +187,29 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, }; } + /// + /// Ersetzt in (dem tatsächlichen Datenbankstand) jede Lesson, deren Id + /// auch in vorkommt, durch die Entwurfsversion, und hängt Entwürfe ohne + /// Id (neu vorgeschlagene Lessons) an. So sieht die KI beim Nachfassen den Stand inklusive der + /// zuvor vorgeschlagenen, aber noch nicht gespeicherten Änderungen — ohne dass dafür etwas in + /// der Datenbank landen muss, bevor der Nutzer "Übernehmen" klickt. + /// + private static List MergeDraft(List saved, List draft) + { + var draftById = draft.Where(d => d.Id.HasValue).ToDictionary(d => d.Id!.Value); + var merged = saved.Select(l => l.Id.HasValue && draftById.TryGetValue(l.Id.Value, out var replacement) + ? replacement : l).ToList(); + merged.AddRange(draft.Where(d => !d.Id.HasValue)); + return merged; + } + public async Task RequestPlanAsync(Unit unit, string instruction, string token, - bool allowModifyingExistingLessons = true) + bool allowModifyingExistingLessons = true, List? draftOverrides = null) { var request = new AiPlanningRequest { Instruction = instruction, - Unit = BuildContext(unit, instruction), + Unit = BuildContext(unit, instruction, draftOverrides), AllowModifyingExistingLessons = allowModifyingExistingLessons, }; diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs index 303c9e7..1992162 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs @@ -1141,10 +1141,17 @@ public partial class AiAssistDialogViewModel : ObservableObject return; } + // Nachfassen (HasResults bereits true): die aktuell angehakten Vorschläge der letzten Runde + // als Entwurf mitschicken, damit die KI auf dem bereits gezeigten, noch nicht gespeicherten + // Stand aufbaut statt nur auf dem tatsächlichen Datenbankstand der Einheit. + var draftOverrides = HasResults + ? ReviewItems.Where(i => i.Accepted).Select(i => i.Source).ToList() + : null; + ErrorMessage = ""; IsBusy = true; try { - var response = await _aiPlanning.RequestPlanAsync(_unit, Instruction, token, AllowModifyingExisting); + var response = await _aiPlanning.RequestPlanAsync(_unit, Instruction, token, AllowModifyingExisting, draftOverrides); var existingIds = _lessons.GetByUnit(_unit.Id).Select(l => l.Id).ToHashSet(); ReviewItems.Clear(); diff --git a/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml b/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml index abd9554..d5722b5 100644 --- a/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml @@ -13,20 +13,6 @@ - - - - - - - - - @@ -41,14 +27,35 @@ + + + + + + + + + + + + + - +