diff --git a/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs b/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs index f34e9e2..2911113 100644 --- a/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs +++ b/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs @@ -143,6 +143,68 @@ public sealed class AiPlanningServiceTests Assert.Contains(context.Lessons, l => l.Topic == "Neu aus Runde 1" && l.Id == null); } + [Fact] + public void DescribeChanges_ErkenntNurTatsaechlichGeaenderteFelder() + { + var existing = new Lesson + { + Topic = "Alt", Date = new DateOnly(2026, 3, 10), StartTime = new TimeOnly(8, 0), + Homework = "Alte HA", Reflection = "Alte Reflexion", + }; + var proposed = new AiLesson + { + Id = existing.Id, Topic = "Neu", Date = new DateOnly(2026, 3, 17), StartTime = new TimeOnly(8, 0), + Homework = "Alte HA", Reflection = "Neue Reflexion", Phases = [], + }; + + var service = Build(new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), + new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); + + var diffs = service.DescribeChanges(existing, proposed); + + Assert.Contains(diffs, d => d.Contains("Thema")); + Assert.Contains(diffs, d => d.Contains("Datum")); + Assert.Contains(diffs, d => d.Contains("Reflexion")); + Assert.DoesNotContain(diffs, d => d.Contains("Beginn")); + Assert.DoesNotContain(diffs, d => d.Contains("Hausaufgabe")); + } + + [Fact] + public void DescribeChanges_IdentischeLesson_LiefertKeineUnterschiede() + { + var existing = new Lesson { Topic = "Gleich", Date = new DateOnly(2026, 3, 10) }; + var proposed = new AiLesson { Id = existing.Id, Topic = "Gleich", Date = new DateOnly(2026, 3, 10), Phases = [] }; + + var service = Build(new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), + new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); + + Assert.Empty(service.DescribeChanges(existing, proposed)); + } + + [Fact] + public void DescribeChanges_UnterschiedlicheAnzahlPhasen_WirdAlsVerlaufsplanAenderungErkannt() + { + var existing = new Lesson + { + Topic = "T", + Phases = [new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 10 }], + }; + var proposed = new AiLesson + { + Id = existing.Id, Topic = "T", + Phases = + [ + new AiPhaseStep { Name = "Einstieg", DurationMinutes = 10 }, + new AiPhaseStep { Name = "Erarbeitung", DurationMinutes = 20 }, + ], + }; + + var service = Build(new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), + new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); + + Assert.Contains(service.DescribeChanges(existing, proposed), d => d.Contains("Verlaufsplan")); + } + [Fact] public void ApplyResponse_BekannteId_WirdAlsUpdateBehandelt() { diff --git a/LehrerApp.Desktop/Services/AiPlanningService.cs b/LehrerApp.Desktop/Services/AiPlanningService.cs index 19a1423..9ac93d9 100644 --- a/LehrerApp.Desktop/Services/AiPlanningService.cs +++ b/LehrerApp.Desktop/Services/AiPlanningService.cs @@ -155,15 +155,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, StartTime = l.StartTime, Homework = l.Homework, Reflection = l.Reflection, - Phases = l.Phases.Select(p => new AiPhaseStep - { - Name = p.Name, - DurationMinutes = p.DurationMinutes, - Activity = p.Activity, - Material = p.Material, - Shorthand = p.Shorthand, - AlternativePathName = p.AlternativePathId is { } pathId ? pathNames.GetValueOrDefault(pathId) : null, - }).ToList(), + Phases = ToAiPhases(l.Phases, pathNames), }) .ToList(); @@ -187,6 +179,76 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, }; } + private static List ToAiPhases(List phases, Dictionary pathNames) => + phases.Select(p => new AiPhaseStep + { + Name = p.Name, + DurationMinutes = p.DurationMinutes, + Activity = p.Activity, + Material = p.Material, + Shorthand = p.Shorthand, + AlternativePathName = p.AlternativePathId is { } pathId ? pathNames.GetValueOrDefault(pathId) : null, + }).ToList(); + + private static bool PhasesEqual(List a, List b) + { + if (a.Count != b.Count) return false; + for (var i = 0; i < a.Count; i++) + { + var x = a[i]; var y = b[i]; + if (x.Name != y.Name || x.DurationMinutes != y.DurationMinutes || x.Activity != y.Activity + || x.Material != y.Material || x.Shorthand != y.Shorthand || x.AlternativePathName != y.AlternativePathName) + return false; + } + return true; + } + + /// + /// Vergleicht eine bestehende Lesson mit dem KI-Vorschlag für dieselbe Id auf Feldebene und + /// beschreibt die Unterschiede in kurzen, menschenlesbaren Sätzen — Grundlage für die + /// Detailanzeige im AiAssistDialog (4.5.14 Planungsdiff), statt Änderungen nur pauschal als + /// "Geändert" zu markieren. Nur echte Unterschiede werden aufgeführt, Felder ohne Änderung + /// tauchen nicht auf. Rein (kein I/O außer dem bereits im Speicher gehaltenen Alternativpfad- + /// Katalog), daher ohne echtes Deployment testbar. + /// + public List DescribeChanges(Lesson existing, AiLesson proposed) + { + var diffs = new List(); + + if (!string.Equals(existing.Topic, proposed.Topic, StringComparison.Ordinal)) + diffs.Add($"Thema: „{existing.Topic}“ → „{proposed.Topic}“"); + + if (proposed.Date is { } date && date != existing.Date) + diffs.Add($"Datum: {existing.Date:dd.MM.yyyy} → {date:dd.MM.yyyy}"); + + if (proposed.StartTime != existing.StartTime) + { + var oldText = existing.StartTime?.ToString("HH:mm") ?? "kein Beginn"; + var newText = proposed.StartTime?.ToString("HH:mm") ?? "kein Beginn"; + diffs.Add($"Beginn: {oldText} → {newText}"); + } + + if (proposed.LessonNumber != existing.LessonNumber) + diffs.Add($"Stundennummer: {existing.LessonNumber?.ToString() ?? "–"} → {proposed.LessonNumber?.ToString() ?? "–"}"); + + if (!string.Equals(existing.Homework, proposed.Homework, StringComparison.Ordinal)) + diffs.Add("Hausaufgabe geändert"); + + if (!string.Equals(existing.Reflection, proposed.Reflection, StringComparison.Ordinal)) + diffs.Add("Reflexion geändert"); + + var pathNames = altPaths.GetAll().ToDictionary(p => p.Id, p => p.Name); + var existingPhases = ToAiPhases(existing.Phases, pathNames); + if (!PhasesEqual(existingPhases, proposed.Phases)) + { + diffs.Add(existingPhases.Count == proposed.Phases.Count + ? "Verlaufsplan geändert" + : $"Verlaufsplan geändert ({existingPhases.Count} → {proposed.Phases.Count} Phase(n))"); + } + + return diffs; + } + /// /// Ersetzt in (dem tatsächlichen Datenbankstand) jede Lesson, deren Id /// auch in vorkommt, durch die Entwurfsversion, und hängt Entwürfe ohne diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs index 1992162..62376df 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs @@ -1094,14 +1094,19 @@ public partial class AiLessonReviewItem : ObservableObject public bool IsNew { get; } public string DisplayLabel { get; } + /// Feld-Diff gegenüber der bestehenden Lesson (4.5.14 Planungsdiff), als fertig formatierte + /// Aufzählung fürs UI — leer bei neuen Vorschlägen, da es dort nichts zu vergleichen gibt. + public string DiffText { get; } + [ObservableProperty] private bool _accepted = true; - public AiLessonReviewItem(AiLesson source, bool isNew) + public AiLessonReviewItem(AiLesson source, bool isNew, List? fieldDiffs = null) { Source = source; IsNew = isNew; var dateText = source.Date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "kein Datum"; DisplayLabel = isNew ? $"Neu: {source.Topic} ({dateText})" : $"Geändert: {source.Topic} ({dateText})"; + DiffText = fieldDiffs is { Count: > 0 } ? string.Join("\n", fieldDiffs.Select(d => "• " + d)) : ""; } } @@ -1152,17 +1157,18 @@ public partial class AiAssistDialogViewModel : ObservableObject try { var response = await _aiPlanning.RequestPlanAsync(_unit, Instruction, token, AllowModifyingExisting, draftOverrides); - var existingIds = _lessons.GetByUnit(_unit.Id).Select(l => l.Id).ToHashSet(); + var existingLessons = _lessons.GetByUnit(_unit.Id).ToDictionary(l => l.Id); ReviewItems.Clear(); foreach (var l in response.Lessons) { - var isExisting = l.Id is { } id && existingIds.Contains(id); + var isExisting = l.Id is { } id && existingLessons.ContainsKey(id); // Falls der Modus Änderungen an bestehenden Stunden verbietet, aber die KI die // Anweisung trotzdem ignoriert hat: gar nicht erst zur Übernahme anbieten, statt // dem Nutzer eine Auswahl zu zeigen, die ApplyResponse ohnehin verwerfen würde. if (isExisting && !AllowModifyingExisting) continue; - ReviewItems.Add(new AiLessonReviewItem(l, isNew: !isExisting)); + var fieldDiffs = isExisting ? _aiPlanning.DescribeChanges(existingLessons[l.Id!.Value], l) : null; + ReviewItems.Add(new AiLessonReviewItem(l, isNew: !isExisting, fieldDiffs)); } Summary = response.Summary; HasResults = true; diff --git a/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml b/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml index d5722b5..ed243c4 100644 --- a/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml @@ -20,7 +20,12 @@ - + + + + diff --git a/TODO.md b/TODO.md index 512ecac..0c7b137 100644 --- a/TODO.md +++ b/TODO.md @@ -743,13 +743,24 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`: Runde: der Kürzel-Katalog (`IShorthandCodeRepository`) selbst wird der KI noch nicht als Kontext mitgegeben (nur die Konvention allgemein erklärt) — wäre der nächste sinnvolle Schritt, analog zu `CompetencyCatalog`/`AlternativePathCatalog`. -- [ ] **4.5.14** Planungsdiff: KI-Vorschläge (4.5.9) mit dem bereits Geplanten auf Feldebene - vergleichbar machen, nicht nur pauschal als "Neu"/"Geändert" markieren wie aktuell im - `AiAssistDialog`. Bei geänderten Stunden sollte sichtbar sein, was sich konkret unterscheidet - (Thema, einzelne Verlaufsplan-Phasen, Hausaufgabe, ...), und die Übernahme sollte sich sinnvoll - zusammenführen lassen — z.B. nur einzelne Phasen einer Stunde übernehmen statt zwingend die - ganze Stunde, oder eigene zwischenzeitliche Änderungen nicht versehentlich überschreiben, - falls sich die Einheit seit dem Absenden der Anfrage schon geändert hat. +- [x] **4.5.14** Planungsdiff: KI-Vorschläge (4.5.9) mit dem bereits Geplanten auf Feldebene + vergleichbar machen, nicht nur pauschal als "Neu"/"Geändert" markieren wie bisher im + `AiAssistDialog`. **Umsetzung (erster, bewusst begrenzter Schritt — Teil 2/3 der + ursprünglichen Idee zurückgestellt, siehe 4.5.19):** `AiPlanningService.DescribeChanges` + vergleicht eine bestehende Lesson mit dem KI-Vorschlag für dieselbe Id und liefert nur die + tatsächlich unterschiedlichen Felder als kurze deutsche Sätze (Thema, Datum, Beginn, + Stundennummer, "Hausaufgabe/Reflexion geändert", Verlaufsplan-Änderung inkl. Phasenanzahl + bei unterschiedlicher Phasenzahl). `AiAssistDialogViewModel.Send` ruft das je geänderter + Lesson auf und reicht das Ergebnis an `AiLessonReviewItem.DiffText` durch, im Dialog als + kleine Aufzählung unter der jeweiligen Checkbox angezeigt. Rein/ohne I/O, mit dedizierten + Tests (u.a. dass identische Lessons keine Diffs liefern und nur wirklich geänderte Felder + auftauchen). +- [ ] **4.5.19** (Zurückgestellt, Rest von 4.5.14) Granularere Übernahme statt nur ganzer Stunden — + z.B. nur einzelne Verlaufsplan-Phasen einer Stunde übernehmen statt zwingend die komplette + Stunde. Außerdem: eigene zwischenzeitliche Änderungen an einer Lesson nicht versehentlich + durch eine übernommene KI-Antwort überschreiben, falls sich die Einheit seit dem Absenden + der Anfrage schon geändert hat (aktuell nicht erkannt/abgesichert). Größerer Umbau als + 4.5.14 (Teil-Merge-UI je Phase, Änderungszeitpunkt-Vergleich) — deshalb zurückgestellt. - [x] **4.5.15** Umfangs-Umschalter im `AiAssistDialog`: "Einheit umplanen ohne Stunden zu ändern" vs. "mit Stunden ändern" (Nutzer-Nachtrag zum Konzeptgespräch nach 4.5.9). Neue Checkbox "Auch bestehende Stundeninhalte anpassen" (Default: an, entspricht dem bisherigen Verhalten).