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); 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] [Fact]
public void ApplyResponse_BekannteId_WirdAlsUpdateBehandelt() 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.AiPlanning;
using LehrerApp.Core.Interfaces; using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models; using LehrerApp.Core.Models;
using System.Globalization;
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
namespace LehrerApp.Desktop.Services; namespace LehrerApp.Desktop.Services;
/// <summary>Fehler beim Aufruf des KI-Backends, Message ist bereits deutsch und nutzergerichtet.</summary> /// <summary>Fehler beim Aufruf des KI-Backends, Message ist bereits deutsch und nutzergerichtet.</summary>
public class AiBackendException(string userMessage) : Exception(userMessage); 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> /// <summary>
/// Orchestriert die KI-gestützte Planungsunterstützung (TODO 4.5.9): baut aus einer <see cref="Unit"/> /// 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 /// 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, PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true, PropertyNameCaseInsensitive = true,
Converters = { new GermanDateOnlyJsonConverter(), new GermanTimeOnlyJsonConverter() },
}; };
public async Task<string> LoginAsync(string username, string password) 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> /// <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 group = groups.GetById(unit.GroupId);
var subject = group?.SubjectId is { } subjectId ? subjects.GetById(subjectId) : null; var subject = group?.SubjectId is { } subjectId ? subjects.GetById(subjectId) : null;
@@ -114,6 +167,9 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
}) })
.ToList(); .ToList();
if (draftOverrides is not null)
unitLessons = MergeDraft(unitLessons, draftOverrides);
return new AiUnitContext return new AiUnitContext
{ {
Id = unit.Id, 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, 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 var request = new AiPlanningRequest
{ {
Instruction = instruction, Instruction = instruction,
Unit = BuildContext(unit, instruction), Unit = BuildContext(unit, instruction, draftOverrides),
AllowModifyingExistingLessons = allowModifyingExistingLessons, AllowModifyingExistingLessons = allowModifyingExistingLessons,
}; };
@@ -1141,10 +1141,17 @@ public partial class AiAssistDialogViewModel : ObservableObject
return; 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; ErrorMessage = ""; IsBusy = true;
try 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(); var existingIds = _lessons.GetByUnit(_unit.Id).Select(l => l.Id).ToHashSet();
ReviewItems.Clear(); ReviewItems.Clear();
@@ -13,20 +13,6 @@
<TextBlock Text="KI-Unterstützung" Classes="dialogtitle"/> <TextBlock Text="KI-Unterstützung" Classes="dialogtitle"/>
<TextBlock Text="{Binding UnitSummary}" FontSize="12" Opacity="0.6"/> <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}"> <StackPanel Spacing="10" IsVisible="{Binding HasResults}">
<TextBlock Text="{Binding Summary}" FontSize="12" TextWrapping="Wrap" <TextBlock Text="{Binding Summary}" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding Summary, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> 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" <TextBlock Text="Die KI hat keine Stunden vorgeschlagen." Opacity="0.6" FontSize="12"
IsVisible="{Binding !ReviewItems.Count}"/> IsVisible="{Binding !ReviewItems.Count}"/>
</StackPanel> </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> </StackPanel>
</ScrollViewer> </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="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Anfrage senden" HorizontalAlignment="Stretch" Click="OnSend" <Button Grid.Column="2" Content="Anfrage senden" HorizontalAlignment="Stretch" Click="OnSend"
IsVisible="{Binding !HasResults}" IsEnabled="{Binding !IsBusy}"/> 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}"/> IsVisible="{Binding HasResults}"/>
</Grid> </Grid>
</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 ob Anthropic zuverlässig valides JSON im erwarteten Schema liefert, ob die berechneten
Kosten exakt mit der echten Abrechnung übereinstimmen, sowie die komplette Kette Kosten exakt mit der echten Abrechnung übereinstimmen, sowie die komplette Kette
Desktop → Backend → Anthropic unter echten Netzwerkbedingungen. 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 - [ ] **4.5.10** Falls doch ein schlanker Companion-/WebApp-Client entstehen soll: bewusst
**minimaler** Funktionsumfang — nur Wochenraster ansehen, eine Stunde verschieben, oder eine **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. 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 **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 `cache_read_input_tokens > 0` in `transactions` bei einer zweiten Anfrage innerhalb der
TTL beobachtbar (siehe `ai-backend/README.md`). 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.
--- ---