This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user