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
@@ -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<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>
/// 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
@@ -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<string>? 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;
@@ -20,7 +20,12 @@
<ItemsControl ItemsSource="{Binding ReviewItems}">
<ItemsControl.ItemTemplate>
<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>
</ItemsControl.ItemTemplate>
</ItemsControl>