KI-Backend: Antwortretten - Funktion
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-09-04 12:10:42 +02:00
parent 7ef0c95a73
commit fedfceb81d
10 changed files with 435 additions and 14 deletions
@@ -10,7 +10,14 @@ 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);
public class AiBackendException(string userMessage, string? rawResponse = null) : Exception(userMessage)
{
/// <summary>
/// Unveränderte Modellantwort, sofern der Fehler beim Lesen einer Planungsantwort entstand.
/// Sie wird ausschließlich für den ausdrücklich vom Nutzer gestarteten Rettungsdialog gehalten.
/// </summary>
public string? RawResponse { get; } = rawResponse;
}
/// <summary>
/// Der KI-Wire-Vertrag verwendet deutsches Datumsformat (siehe ai-backend/plan.php Systemprompt,
@@ -87,16 +94,19 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
private static async Task<AiBackendException> BuildRequestFailedExceptionAsync(HttpResponseMessage resp)
{
string? backendReason = null;
string? rawResponse = null;
try
{
var body = await resp.Content.ReadFromJsonAsync<BackendErrorResult>(JsonOptions);
var responseText = await resp.Content.ReadAsStringAsync();
var body = JsonSerializer.Deserialize<BackendErrorResult>(responseText, JsonOptions);
backendReason = string.IsNullOrWhiteSpace(body?.Error) ? null : body!.Error;
rawResponse = string.IsNullOrWhiteSpace(body?.RawResponse) ? null : body!.RawResponse;
}
catch { /* Antwortkörper war kein valides {"error": "..."}-JSON - Fallback unten greift. */ }
return new AiBackendException(backendReason is null
? "Die Anfrage an den KI-Dienst ist fehlgeschlagen."
: $"Die Anfrage an den KI-Dienst ist fehlgeschlagen: {backendReason}");
: $"Die Anfrage an den KI-Dienst ist fehlgeschlagen: {backendReason}", rawResponse);
}
public async Task<string> LoginAsync(string username, string password)
@@ -352,14 +362,64 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
if (!resp.IsSuccessStatusCode)
throw await BuildRequestFailedExceptionAsync(resp);
var responseText = await resp.Content.ReadAsStringAsync();
try
{
var result = await resp.Content.ReadFromJsonAsync<AiPlanningResponse>(JsonOptions);
var result = JsonSerializer.Deserialize<AiPlanningResponse>(responseText, JsonOptions);
return result ?? throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
}
catch (Exception ex) when (ex is not AiBackendException)
{
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
string? rawModelResponse = null;
try
{
using var envelope = JsonDocument.Parse(responseText);
if (envelope.RootElement.TryGetProperty("rawResponse", out var rawElement)
&& rawElement.ValueKind == JsonValueKind.String)
rawModelResponse = rawElement.GetString();
}
catch (JsonException) { /* Die HTTP-Antwort selbst war ungültig; unten komplett zeigen. */ }
throw new AiBackendException(
"Die Antwort der KI konnte nicht verarbeitet werden. Du kannst die Antwort manuell retten.",
string.IsNullOrWhiteSpace(rawModelResponse) ? responseText : rawModelResponse);
}
}
/// <summary>
/// Liest eine von Hand markierte Modellantwort. Akzeptiert die normale Antwort-Hülle, ein
/// einzelnes Lesson-Objekt oder ein Array von Lessons, damit auch nur der relevante Ausschnitt
/// der Rohantwort markiert werden kann.
/// </summary>
public static List<AiLesson> ParsePlanningLessons(string json)
{
if (string.IsNullOrWhiteSpace(json))
throw new AiBackendException("Bitte markiere zuerst den JSON-Abschnitt der Antwort.");
try
{
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
List<AiLesson>? parsed = root.ValueKind switch
{
JsonValueKind.Object when root.TryGetProperty("lessons", out _) =>
JsonSerializer.Deserialize<AiPlanningResponse>(json, JsonOptions)?.Lessons,
JsonValueKind.Object =>
[JsonSerializer.Deserialize<AiLesson>(json, JsonOptions)
?? throw new JsonException("Leere Stunde")],
JsonValueKind.Array => JsonSerializer.Deserialize<List<AiLesson>>(json, JsonOptions),
_ => null,
};
if (parsed is null || parsed.Count == 0)
throw new JsonException("Keine Stunde enthalten");
if (parsed.Any(l => string.IsNullOrWhiteSpace(l.Topic)))
throw new JsonException("Mindestens einer Stunde fehlt das Feld topic");
return parsed;
}
catch (JsonException ex)
{
throw new AiBackendException($"Der markierte Text ist noch kein gültiges Stunden-JSON: {ex.Message}");
}
}
@@ -541,5 +601,9 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
private class LoginResult { public string Token { get; set; } = ""; }
private class BalanceResult { public decimal BalanceUsd { get; set; } }
private class BackendErrorResult { public string? Error { get; set; } }
private class BackendErrorResult
{
public string? Error { get; set; }
public string? RawResponse { get; set; }
}
}
@@ -1491,10 +1491,16 @@ public partial class AiAssistDialogViewModel : ObservableObject
[ObservableProperty] private string _errorMessage = "";
[ObservableProperty] private bool _hasResults;
[ObservableProperty] private string? _summary;
[ObservableProperty] private string? _rawResponse;
public string UnitSummary { get; }
public ObservableCollection<AiLessonReviewItem> ReviewItems { get; } = [];
public bool Result { get; private set; }
public Unit Unit => _unit;
public Guid? FocusLessonId => _focusLesson?.Id;
public bool CanRescueResponse => !string.IsNullOrWhiteSpace(RawResponse);
partial void OnRawResponseChanged(string? value) => OnPropertyChanged(nameof(CanRescueResponse));
/// Aus dem Editor einer einzelnen Stunde heraus gestartet (statt aus der Einheiten-Übersicht,
/// Nutzer-Feedback nach den ersten Live-Tests) — die KI darf dann ausschließlich diese eine
@@ -1533,7 +1539,7 @@ public partial class AiAssistDialogViewModel : ObservableObject
? ReviewItems.Where(i => i.Accepted).Select(i => i.Source).ToList()
: null;
ErrorMessage = ""; IsBusy = true;
ErrorMessage = ""; RawResponse = null; IsBusy = true;
try
{
var response = await _aiPlanning.RequestPlanAsync(_unit, Instruction, token, AllowModifyingExisting,
@@ -1565,7 +1571,11 @@ public partial class AiAssistDialogViewModel : ObservableObject
Summary = response.Summary;
HasResults = true;
}
catch (AiBackendException ex) { ErrorMessage = ex.Message; }
catch (AiBackendException ex)
{
ErrorMessage = ex.Message;
RawResponse = ex.RawResponse;
}
finally { IsBusy = false; }
}
@@ -1579,6 +1589,132 @@ public partial class AiAssistDialogViewModel : ObservableObject
}
[RelayCommand] private void Cancel() => Result = false;
public void MarkRescueImported() => Result = true;
}
/// <summary>Aus einer manuell geprüften KI-Antwort auswählbare Stunde.</summary>
public record AiRescueLessonOption(AiLesson Lesson, string Label);
/// <summary>Ziel für den manuellen Import in eine bereits vorhandene Stunde.</summary>
public record AiRescueTargetOption(Lesson Lesson, string Label);
/// <summary>
/// Rettungsdialog für syntaktisch fehlerhafte oder mit Freitext vermischte Modellantworten. Der
/// Nutzer entscheidet selbst, welcher Textabschnitt geparst und wohin die Stunde importiert wird.
/// </summary>
public partial class AiResponseRescueDialogViewModel : ObservableObject
{
private readonly AiPlanningService _aiPlanning;
private readonly ILessonRepository _lessons;
private readonly Unit _unit;
[ObservableProperty] private string _responseText;
[ObservableProperty] private string _parseMessage =
"Markiere den gültigen JSON-Abschnitt oder bearbeite den Text und klicke auf „Markierung prüfen“.";
[ObservableProperty] private AiRescueLessonOption? _selectedParsedLesson;
[ObservableProperty] private AiRescueTargetOption? _selectedTarget;
public ObservableCollection<AiRescueLessonOption> ParsedLessons { get; } = [];
public ObservableCollection<AiRescueTargetOption> ExistingLessons { get; } = [];
public bool HasParsedLesson => SelectedParsedLesson is not null;
public bool CanImportIntoExisting => SelectedParsedLesson is not null && SelectedTarget is not null;
public bool Result { get; private set; }
partial void OnSelectedParsedLessonChanged(AiRescueLessonOption? value)
{
OnPropertyChanged(nameof(HasParsedLesson));
OnPropertyChanged(nameof(CanImportIntoExisting));
}
partial void OnSelectedTargetChanged(AiRescueTargetOption? value) =>
OnPropertyChanged(nameof(CanImportIntoExisting));
public AiResponseRescueDialogViewModel(AiPlanningService aiPlanning, ILessonRepository lessons,
Unit unit, string rawResponse, Guid? preferredTargetId = null)
{
_aiPlanning = aiPlanning;
_lessons = lessons;
_unit = unit;
_responseText = rawResponse;
foreach (var lesson in lessons.GetByUnit(unit.Id).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber))
{
var date = lesson.Date.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture);
var number = lesson.LessonNumber is { } n ? $", Stunde {n}" : "";
ExistingLessons.Add(new AiRescueTargetOption(lesson, $"{date}{number}: {lesson.Topic}"));
}
SelectedTarget = ExistingLessons.FirstOrDefault(x => x.Lesson.Id == preferredTargetId)
?? ExistingLessons.FirstOrDefault();
}
public void ParseSelection(string selectedText)
{
ParsedLessons.Clear();
SelectedParsedLesson = null;
try
{
var parsed = AiPlanningService.ParsePlanningLessons(selectedText);
foreach (var lesson in parsed)
{
var date = lesson.Date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "ohne Datum";
ParsedLessons.Add(new AiRescueLessonOption(lesson, $"{date}: {lesson.Topic}"));
}
SelectedParsedLesson = ParsedLessons[0];
ParseMessage = parsed.Count == 1
? "Eine gültige Stunde erkannt."
: $"{parsed.Count} gültige Stunden erkannt. Bitte die gewünschte Stunde auswählen.";
}
catch (AiBackendException ex)
{
ParseMessage = ex.Message;
}
}
[RelayCommand]
private void ImportIntoExisting()
{
if (SelectedParsedLesson is null || SelectedTarget is null) return;
var lesson = CloneForImport(SelectedParsedLesson.Lesson, SelectedTarget.Lesson.Id);
Save([lesson], focusLessonId: SelectedTarget.Lesson.Id);
}
[RelayCommand]
private void ImportAsNew()
{
if (SelectedParsedLesson is null) return;
Save([CloneForImport(SelectedParsedLesson.Lesson, null)]);
}
private void Save(List<AiLesson> source, Guid? focusLessonId = null)
{
foreach (var lesson in _aiPlanning.ApplyResponse(_unit, source,
allowModifyingExistingLessons: true, focusLessonId))
_lessons.Save(lesson);
Result = true;
}
private static AiLesson CloneForImport(AiLesson source, Guid? id) => new()
{
Id = id,
Date = source.Date,
LessonNumber = source.LessonNumber,
Topic = source.Topic,
StartTime = source.StartTime,
Homework = source.Homework,
Reflection = source.Reflection,
Phases = source.Phases.Select(p => new AiPhaseStep
{
Name = p.Name,
DurationMinutes = p.DurationMinutes,
Activity = p.Activity,
Material = p.Material,
Shorthand = p.Shorthand,
AlternativePathName = p.AlternativePathName,
MaterialSuggestion = p.MaterialSuggestion,
}).ToList(),
};
}
// ── Dialog: Einheit als Vorlage in andere Gruppe kopieren (4.1.4) ────────────
@@ -78,8 +78,12 @@
<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 Spacing="8" IsVisible="{Binding ErrorMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding ErrorMessage}" Foreground="Red" FontSize="12" TextWrapping="Wrap"/>
<Button Content="🛟 Anfrage retten…" HorizontalAlignment="Left" Click="OnRescue"
IsVisible="{Binding CanRescueResponse}"
ToolTip.Tip="Vollständige KI-Antwort öffnen, JSON markieren oder korrigieren und manuell importieren."/>
</StackPanel>
</StackPanel>
</ScrollViewer>
@@ -27,6 +27,22 @@ public partial class AiAssistDialog : Window
await vm.SendCommand.ExecuteAsync(null);
}
private async void OnRescue(object? s, RoutedEventArgs e)
{
if (DataContext is not AiAssistDialogViewModel { RawResponse: { } raw } vm) return;
var rescueVm = new AiResponseRescueDialogViewModel(
App.Services.GetRequiredService<AiPlanningService>(),
App.Services.GetRequiredService<LehrerApp.Core.Interfaces.ILessonRepository>(),
vm.Unit, raw, vm.FocusLessonId);
var rescue = new AiResponseRescueDialog { DataContext = rescueVm };
if (await rescue.ShowDialog<bool>(this))
{
vm.MarkRescueImported();
Close(true);
}
}
private void OnApply(object? s, RoutedEventArgs e)
{
if (DataContext is AiAssistDialogViewModel vm && vm.ApplyCommand.CanExecute(null))
@@ -0,0 +1,50 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.AiResponseRescueDialog"
x:DataType="vm:AiResponseRescueDialogViewModel"
Title="KI-Antwort retten"
Width="820" Height="720" MinWidth="620" MinHeight="520"
CanResize="True" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="Auto,*,Auto,Auto,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="6" Margin="0,0,0,12">
<TextBlock Text="KI-Antwort retten" Classes="dialogtitle"/>
<TextBlock Text="Die vollständige Antwort steht unten. Markiere ein vollständiges JSON-Objekt, ein Array von Stunden oder die normale Antwort mit dem Feld „lessons“. Du kannst den Text vorher auch korrigieren. Ohne Markierung wird der gesamte Text geprüft."
TextWrapping="Wrap" FontSize="12" Opacity="0.75"/>
</StackPanel>
<TextBox x:Name="ResponseTextBox" Grid.Row="1" Text="{Binding ResponseText}"
AcceptsReturn="True" AcceptsTab="True" TextWrapping="NoWrap"
FontFamily="Monospace" FontSize="12"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto"/>
<Grid Grid.Row="2" ColumnDefinitions="Auto,*" Margin="0,12,0,0">
<Button Grid.Column="0" Content="Markierung prüfen" Click="OnValidateSelection"/>
<TextBlock Grid.Column="1" Text="{Binding ParseMessage}" Margin="12,0,0,0"
VerticalAlignment="Center" TextWrapping="Wrap" FontSize="12"/>
</Grid>
<Grid Grid.Row="3" ColumnDefinitions="*,12,*" Margin="0,14,0,0">
<StackPanel Grid.Column="0" Spacing="5">
<TextBlock Text="Erkannte Stunde" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding ParsedLessons}" SelectedItem="{Binding SelectedParsedLesson}"
DisplayMemberBinding="{Binding Label}" IsEnabled="{Binding HasParsedLesson}"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="5">
<TextBlock Text="Vorhandene Zielstunde" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding ExistingLessons}" SelectedItem="{Binding SelectedTarget}"
DisplayMemberBinding="{Binding Label}"/>
</StackPanel>
</Grid>
<Grid Grid.Row="4" ColumnDefinitions="Auto,*,Auto,10,Auto" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" Click="OnCancel"/>
<Button Grid.Column="2" Content="In Zielstunde importieren" Click="OnImportIntoExisting"
IsEnabled="{Binding CanImportIntoExisting}"/>
<Button Grid.Column="4" Content="Als neue Stunde importieren" Click="OnImportAsNew"
IsEnabled="{Binding HasParsedLesson}"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,35 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class AiResponseRescueDialog : Window
{
public AiResponseRescueDialog() => InitializeComponent();
private void OnValidateSelection(object? sender, RoutedEventArgs e)
{
if (DataContext is not AiResponseRescueDialogViewModel vm) return;
var candidate = string.IsNullOrWhiteSpace(ResponseTextBox.SelectedText)
? ResponseTextBox.Text ?? ""
: ResponseTextBox.SelectedText;
vm.ParseSelection(candidate);
}
private void OnImportIntoExisting(object? sender, RoutedEventArgs e)
{
if (DataContext is not AiResponseRescueDialogViewModel vm || !vm.CanImportIntoExisting) return;
vm.ImportIntoExistingCommand.Execute(null);
if (vm.Result) Close(true);
}
private void OnImportAsNew(object? sender, RoutedEventArgs e)
{
if (DataContext is not AiResponseRescueDialogViewModel vm || !vm.HasParsedLesson) return;
vm.ImportAsNewCommand.Execute(null);
if (vm.Result) Close(true);
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}