KI-Backend: deutsches Datumsformat fixen, Fortschrittsanzeige + Nachfassen im Dialog

Live-Test lieferte JsonException: DateOnly/TimeOnly hatten keinen Converter
fürs im Systemprompt dokumentierte deutsche Format (TT.MM.JJJJ/HH:mm), .NET
nutzte stattdessen ISO 8601 in beide Richtungen. Neue Converter mit Fallback
aufs allgemeine Parsen.

AiAssistDialog: indeterminierter ProgressBar statt nur Text während der
Anfrage. Neuer Button "Erneut anfragen" erlaubt Nachfassen mit geänderter
Anweisung, ohne den Dialog neu zu starten — schickt die aktuell angehakten
Vorschläge als Entwurfskontext mit (AiPlanningService.MergeDraft), damit die
KI auf dem noch ungespeicherten Stand aufbaut. Side-by-side-Vergleich beider
Entwürfe als 4.5.18 zurückgestellt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 16:39:46 +02:00
co-authored by Claude Sonnet 5
parent 1928916eac
commit 2005b73a16
6 changed files with 258 additions and 20 deletions
@@ -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<AiLesson> { 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<AiLesson> { 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()
{
@@ -0,0 +1,80 @@
using LehrerApp.Core.AiPlanning;
using LehrerApp.Desktop.Services;
using System.Text.Json;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// <summary>
/// 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.
/// </summary>
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<DateOnly>("\"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<DateOnly>("\"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<TimeOnly>("\"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<AiLesson>(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<AiLesson>(json, Options);
Assert.Null(lesson!.Date);
Assert.Null(lesson.StartTime);
}
}
@@ -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;
/// <summary>Fehler beim Aufruf des KI-Backends, Message ist bereits deutsch und nutzergerichtet.</summary>
public class AiBackendException(string userMessage) : Exception(userMessage);
/// <summary>
/// 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.
/// </summary>
public class GermanDateOnlyJsonConverter : JsonConverter<DateOnly>
{
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));
}
/// <summary>Analog zu <see cref="GermanDateOnlyJsonConverter"/>, für "HH:mm" statt ISO-Zeiten.</summary>
public class GermanTimeOnlyJsonConverter : JsonConverter<TimeOnly>
{
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));
}
/// <summary>
/// Orchestriert die KI-gestützte Planungsunterstützung (TODO 4.5.9): baut aus einer <see cref="Unit"/>
/// 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<string> LoginAsync(string username, string password)
@@ -71,7 +119,12 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
}
/// <summary>Rein (nur Repository-Lesezugriffe, kein Netzwerk) — testbar mit Fakes.</summary>
public AiUnitContext BuildContext(Unit unit, string instruction)
/// <param name="draftOverrides">
/// Noch nicht gespeicherter Entwurf aus einer vorherigen Antwortrunde (Nachfassen mit
/// geänderter Anweisung, siehe <see cref="MergeDraft"/>) — überschreibt einzelne Lessons im
/// gesendeten Kontext, ohne dass dafür etwas in der Datenbank gespeichert werden muss.
/// </param>
public AiUnitContext BuildContext(Unit unit, string instruction, List<AiLesson>? 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,
};
}
/// <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
/// 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.
/// </summary>
private static List<AiLesson> MergeDraft(List<AiLesson> saved, List<AiLesson> 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<AiPlanningResponse> RequestPlanAsync(Unit unit, string instruction, string token,
bool allowModifyingExistingLessons = true)
bool allowModifyingExistingLessons = true, List<AiLesson>? draftOverrides = null)
{
var request = new AiPlanningRequest
{
Instruction = instruction,
Unit = BuildContext(unit, instruction),
Unit = BuildContext(unit, instruction, draftOverrides),
AllowModifyingExistingLessons = allowModifyingExistingLessons,
};
@@ -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();
@@ -13,20 +13,6 @@
<TextBlock Text="KI-Unterstützung" Classes="dialogtitle"/>
<TextBlock Text="{Binding UnitSummary}" FontSize="12" Opacity="0.6"/>
<StackPanel Spacing="8" IsVisible="{Binding !HasResults}">
<TextBlock Text="Anweisung" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Instruction}" AcceptsReturn="True" TextWrapping="Wrap" Height="120"
PlaceholderText="z.B. Ergänze zwei weitere Stunden zum Thema Redoxreaktionen mit steigendem Anspruch."
IsEnabled="{Binding !IsBusy}"/>
<CheckBox Content="Auch bestehende Stundeninhalte anpassen" IsChecked="{Binding AllowModifyingExisting}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Deaktivieren, um die Einheit nur um neue Stunden zu erweitern, ohne den Inhalt bereits vorhandener Stunden zu verändern."/>
</StackPanel>
<TextBlock Text="Anfrage läuft…" FontSize="12" Opacity="0.6" IsVisible="{Binding IsBusy}"/>
<TextBlock Text="{Binding ErrorMessage}" Foreground="Red" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding ErrorMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<StackPanel Spacing="10" IsVisible="{Binding HasResults}">
<TextBlock Text="{Binding Summary}" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding Summary, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
@@ -41,14 +27,35 @@
<TextBlock Text="Die KI hat keine Stunden vorgeschlagen." Opacity="0.6" FontSize="12"
IsVisible="{Binding !ReviewItems.Count}"/>
</StackPanel>
<StackPanel Spacing="8">
<TextBlock Text="Weitere Anweisung (nachfassen, baut auf den angehakten Vorschlägen oben auf)"
FontSize="12" Opacity="0.7" IsVisible="{Binding HasResults}"/>
<TextBlock Text="Anweisung" FontSize="12" Opacity="0.7" IsVisible="{Binding !HasResults}"/>
<TextBox Text="{Binding Instruction}" AcceptsReturn="True" TextWrapping="Wrap" Height="120"
PlaceholderText="z.B. Ergänze zwei weitere Stunden zum Thema Redoxreaktionen mit steigendem Anspruch."
IsEnabled="{Binding !IsBusy}"/>
<CheckBox Content="Auch bestehende Stundeninhalte anpassen" IsChecked="{Binding AllowModifyingExisting}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Deaktivieren, um die Einheit nur um neue Stunden zu erweitern, ohne den Inhalt bereits vorhandener Stunden zu verändern."/>
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding IsBusy}">
<TextBlock Text="Anfrage läuft…" FontSize="12" Opacity="0.6"/>
<ProgressBar IsIndeterminate="True" Height="4"/>
</StackPanel>
<TextBlock Text="{Binding ErrorMessage}" Foreground="Red" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding ErrorMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Grid Grid.Row="1" ColumnDefinitions="*,8,*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Anfrage senden" HorizontalAlignment="Stretch" Click="OnSend"
IsVisible="{Binding !HasResults}" IsEnabled="{Binding !IsBusy}"/>
<Button Grid.Column="2" Content="Übernehmen" HorizontalAlignment="Stretch" Click="OnApply"
<Button Grid.Column="2" Content="Erneut anfragen" HorizontalAlignment="Stretch" Click="OnSend"
IsVisible="{Binding HasResults}" IsEnabled="{Binding !IsBusy}"/>
<Button Grid.Column="4" Content="Übernehmen" HorizontalAlignment="Stretch" Click="OnApply"
IsVisible="{Binding HasResults}"/>
</Grid>
</Grid>
+32
View File
@@ -690,6 +690,20 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
ob Anthropic zuverlässig valides JSON im erwarteten Schema liefert, ob die berechneten
Kosten exakt mit der echten Abrechnung übereinstimmen, sowie die komplette Kette
Desktop → Backend → Anthropic unter echten Netzwerkbedingungen.
**Nachtrag (Bugfix, aus echtem Live-Test):** Erste echte Planungsanfrage lieferte beim
Verarbeiten der Antwort eine `JsonException`. Ursache: `AiPlanningService.JsonOptions` hatte
keinen Converter für `DateOnly`/`TimeOnly` registriert, sodass .NETs Standardformat (ISO 8601,
z.B. "2026-12-24") verwendet wurde — der Systemprompt in `plan.php` dokumentiert aber
durchgängig deutsches Format ("TT.MM.JJJJ"/"HH:mm"). Das betraf beide Richtungen: die
ausgehende Anfrage wurde entgegen der Prompt-Dokumentation im ISO-Format gesendet, und eine
dem Prompt folgende KI-Antwort im deutschen Format scheiterte beim Deserialisieren. Behoben
durch `GermanDateOnlyJsonConverter`/`GermanTimeOnlyJsonConverter` (in `AiPlanningService.cs`),
die deutsches Format schreiben und beim Lesen zuerst deutsches Format, dann defensiv
allgemeines Parsen (u.a. ISO) versuchen — LLMs weichen erfahrungsgemäß gelegentlich vom
dokumentierten Format ab, ein harter Fehlschlag bei kleinen Abweichungen wäre unnötig
fragil. Abgesichert durch `GermanDateTimeJsonConverterTests.cs`, u.a. mit dem konkreten
nullable-Feld-Fall (`AiLesson.Date`/`StartTime`), der den ursprünglichen Bug ausgelöst hat.
- [ ] **4.5.10** Falls doch ein schlanker Companion-/WebApp-Client entstehen soll: bewusst
**minimaler** Funktionsumfang — nur Wochenraster ansehen, eine Stunde verschieben, oder eine
Stunde als "Umplanung nötig" flaggen. Kein Editor für Einheiten/Kompetenzen/KI-Planung dort.
@@ -762,6 +776,24 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
**Nicht ohne echten API-Key verifizierbar**, ob Caching tatsächlich greift — nur an
`cache_read_input_tokens > 0` in `transactions` bei einer zweiten Anfrage innerhalb der
TTL beobachtbar (siehe `ai-backend/README.md`).
- [x] **4.5.17** Fortschrittsanzeige + einfaches Nachfassen im `AiAssistDialog` (Nutzer-Feedback
nach erstem echten Live-Test: Dialog wirkte während der Anfrage "aufgehängt", und eine
geänderte Anweisung erforderte bisher komplettes Abbrechen/Neustarten). **Umsetzung:**
indeterminierter `ProgressBar` unter "Anfrage läuft…". Das Anweisungsfeld bleibt nach der
ersten Antwort sichtbar (statt zu verschwinden); ein zweiter Button "Erneut anfragen" sendet
eine geänderte/ergänzte Anweisung erneut, wobei die aktuell angehakten Vorschläge der letzten
Runde als Entwurfskontext mitgeschickt werden (`AiPlanningService.MergeDraft`: ersetzt
Lessons mit passender Id im tatsächlichen Datenbankstand durch die Entwurfsversion, hängt
neue Vorschläge ohne Id an) — die KI baut so auf dem bereits gezeigten, noch nicht
gespeicherten Stand auf, ohne dass dafür etwas in der Datenbank landen muss. Ergebnisliste
wird bei jeder Runde einfach ersetzt, **kein** Seite-an-Seite-Vergleich zwischen den Runden
(das wäre 4.5.18). Bewusst einfacherer erster Schritt statt des größeren Umbaus.
- [ ] **4.5.18** (Zurückgestellt, Nutzer-Idee zu 4.5.17) Statt die Ergebnisliste beim Nachfassen
einfach zu ersetzen: alten und neuen KI-Entwurf gegenüberstellen und den Nutzer pro Stunde
(oder insgesamt) wählen lassen, mit welchem Stand weitergemacht wird. Größerer Umbau als
4.5.17 (zwei Entwürfe parallel im Speicher halten, Seite-an-Seite-UI, Entscheidung je Stunde
statt nur global) — deshalb erst mal zurückgestellt, bis sich zeigt, ob das einfache
Nachfassen aus 4.5.17 in der Praxis schon ausreicht.
---