KI-Feature 4.5.14: Feld-Diff für geänderte Stunden im AiAssistDialog

Statt Vorschläge nur pauschal als "Neu"/"Geändert" zu markieren, zeigt der
Dialog jetzt je geänderter Stunde, was sich konkret unterscheidet (Thema,
Datum, Beginn, Stundennummer, Hausaufgabe/Reflexion, Verlaufsplan) —
AiPlanningService.DescribeChanges vergleicht bestehende Lesson und
KI-Vorschlag feldweise und listet nur echte Unterschiede auf.

Granulare Übernahme einzelner Phasen und Schutz vor Überschreiben eigener
zwischenzeitlicher Änderungen als 4.5.19 zurückgestellt (größerer Umbau).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 16:50:35 +02:00
co-authored by Claude Sonnet 5
parent 2005b73a16
commit 8f2be89133
5 changed files with 167 additions and 21 deletions
@@ -143,6 +143,68 @@ public sealed class AiPlanningServiceTests
Assert.Contains(context.Lessons, l => l.Topic == "Neu aus Runde 1" && l.Id == null); 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] [Fact]
public void ApplyResponse_BekannteId_WirdAlsUpdateBehandelt() public void ApplyResponse_BekannteId_WirdAlsUpdateBehandelt()
{ {
@@ -155,15 +155,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
StartTime = l.StartTime, StartTime = l.StartTime,
Homework = l.Homework, Homework = l.Homework,
Reflection = l.Reflection, Reflection = l.Reflection,
Phases = l.Phases.Select(p => new AiPhaseStep Phases = ToAiPhases(l.Phases, pathNames),
{
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(),
}) })
.ToList(); .ToList();
@@ -187,6 +179,76 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
}; };
} }
private static List<AiPhaseStep> ToAiPhases(List<LessonPhaseStep> phases, Dictionary<Guid, string> 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<AiPhaseStep> a, List<AiPhaseStep> 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;
}
/// <summary>
/// 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.
/// </summary>
public List<string> DescribeChanges(Lesson existing, AiLesson proposed)
{
var diffs = new List<string>();
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;
}
/// <summary> /// <summary>
/// Ersetzt in <paramref name="saved"/> (dem tatsächlichen Datenbankstand) jede Lesson, deren Id /// Ersetzt in <paramref name="saved"/> (dem tatsächlichen Datenbankstand) jede Lesson, deren Id
/// auch in <paramref name="draft"/> vorkommt, durch die Entwurfsversion, und hängt Entwürfe ohne /// auch in <paramref name="draft"/> vorkommt, durch die Entwurfsversion, und hängt Entwürfe ohne
@@ -1094,14 +1094,19 @@ public partial class AiLessonReviewItem : ObservableObject
public bool IsNew { get; } public bool IsNew { get; }
public string DisplayLabel { 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; [ObservableProperty] private bool _accepted = true;
public AiLessonReviewItem(AiLesson source, bool isNew) public AiLessonReviewItem(AiLesson source, bool isNew, List<string>? fieldDiffs = null)
{ {
Source = source; Source = source;
IsNew = isNew; IsNew = isNew;
var dateText = source.Date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "kein Datum"; var dateText = source.Date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "kein Datum";
DisplayLabel = isNew ? $"Neu: {source.Topic} ({dateText})" : $"Geändert: {source.Topic} ({dateText})"; 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 try
{ {
var response = await _aiPlanning.RequestPlanAsync(_unit, Instruction, token, AllowModifyingExisting, draftOverrides); 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(); ReviewItems.Clear();
foreach (var l in response.Lessons) 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 // Falls der Modus Änderungen an bestehenden Stunden verbietet, aber die KI die
// Anweisung trotzdem ignoriert hat: gar nicht erst zur Übernahme anbieten, statt // Anweisung trotzdem ignoriert hat: gar nicht erst zur Übernahme anbieten, statt
// dem Nutzer eine Auswahl zu zeigen, die ApplyResponse ohnehin verwerfen würde. // dem Nutzer eine Auswahl zu zeigen, die ApplyResponse ohnehin verwerfen würde.
if (isExisting && !AllowModifyingExisting) continue; 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; Summary = response.Summary;
HasResults = true; HasResults = true;
@@ -20,7 +20,12 @@
<ItemsControl ItemsSource="{Binding ReviewItems}"> <ItemsControl ItemsSource="{Binding ReviewItems}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:AiLessonReviewItem"> <DataTemplate x:DataType="vm:AiLessonReviewItem">
<CheckBox Content="{Binding DisplayLabel}" IsChecked="{Binding Accepted}" Margin="0,3"/> <StackPanel Margin="0,3" Spacing="2">
<CheckBox Content="{Binding DisplayLabel}" IsChecked="{Binding Accepted}"/>
<TextBlock Text="{Binding DiffText}" FontSize="11" Opacity="0.6" TextWrapping="Wrap"
Margin="24,0,0,0"
IsVisible="{Binding DiffText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
+18 -7
View File
@@ -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 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, Kontext mitgegeben (nur die Konvention allgemein erklärt) — wäre der nächste sinnvolle Schritt,
analog zu `CompetencyCatalog`/`AlternativePathCatalog`. analog zu `CompetencyCatalog`/`AlternativePathCatalog`.
- [ ] **4.5.14** Planungsdiff: KI-Vorschläge (4.5.9) mit dem bereits Geplanten auf Feldebene - [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 aktuell im vergleichbar machen, nicht nur pauschal als "Neu"/"Geändert" markieren wie bisher im
`AiAssistDialog`. Bei geänderten Stunden sollte sichtbar sein, was sich konkret unterscheidet `AiAssistDialog`. **Umsetzung (erster, bewusst begrenzter Schritt — Teil 2/3 der
(Thema, einzelne Verlaufsplan-Phasen, Hausaufgabe, ...), und die Übernahme sollte sich sinnvoll ursprünglichen Idee zurückgestellt, siehe 4.5.19):** `AiPlanningService.DescribeChanges`
zusammenführen lassen — z.B. nur einzelne Phasen einer Stunde übernehmen statt zwingend die vergleicht eine bestehende Lesson mit dem KI-Vorschlag für dieselbe Id und liefert nur die
ganze Stunde, oder eigene zwischenzeitliche Änderungen nicht versehentlich überschreiben, tatsächlich unterschiedlichen Felder als kurze deutsche Sätze (Thema, Datum, Beginn,
falls sich die Einheit seit dem Absenden der Anfrage schon geändert hat. 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" - [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 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). "Auch bestehende Stundeninhalte anpassen" (Default: an, entspricht dem bisherigen Verhalten).