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
@@ -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,
};