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:
@@ -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>
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user