This commit is contained in:
@@ -100,6 +100,10 @@ public class AiPlanningResponse
|
|||||||
{
|
{
|
||||||
public List<AiLesson> Lessons { get; set; } = [];
|
public List<AiLesson> Lessons { get; set; } = [];
|
||||||
public string? Summary { get; set; }
|
public string? Summary { get; set; }
|
||||||
|
// Vom Backend zusätzlich mitgelieferte, unveränderte Modellantwort. Wird nur benötigt, falls
|
||||||
|
// die typisierte Deserialisierung scheitert; bei einer regulär verarbeiteten Antwort wird sie
|
||||||
|
// weder angezeigt noch gespeichert.
|
||||||
|
public string? RawResponse { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
using LehrerApp.Core.AiPlanning;
|
using LehrerApp.Core.AiPlanning;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Tests;
|
namespace LehrerApp.Desktop.Tests;
|
||||||
@@ -15,6 +18,106 @@ public sealed class AiPlanningServiceTests
|
|||||||
FakeCompetencyDomains competencyDomains, FakeAlternativeLessonPaths altPaths) =>
|
FakeCompetencyDomains competencyDomains, FakeAlternativeLessonPaths altPaths) =>
|
||||||
new(new HttpClient(), lessons, groups, subjects, competencyDomains, altPaths);
|
new(new HttpClient(), lessons, groups, subjects, competencyDomains, altPaths);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsePlanningLessons_AkzeptiertNormaleAntwortUndDeutschesDatum()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{"lessons":[{"id":null,"date":"15.09.2026","lessonNumber":2,"topic":"Redox","startTime":"08:35","phases":[]}],"summary":"ok"}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var lesson = Assert.Single(AiPlanningService.ParsePlanningLessons(json));
|
||||||
|
|
||||||
|
Assert.Equal("Redox", lesson.Topic);
|
||||||
|
Assert.Equal(new DateOnly(2026, 9, 15), lesson.Date);
|
||||||
|
Assert.Equal(new TimeOnly(8, 35), lesson.StartTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsePlanningLessons_AkzeptiertEinzelneMarkierteStunde()
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{"topic":"Nur diese Stunde","phases":[{"name":"Einstieg","durationMinutes":5,"activity":"Impuls","material":"Bild","shorthand":"UG"}]}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var lesson = Assert.Single(AiPlanningService.ParsePlanningLessons(json));
|
||||||
|
|
||||||
|
Assert.Equal("Nur diese Stunde", lesson.Topic);
|
||||||
|
Assert.Single(lesson.Phases);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParsePlanningLessons_UngueltigeMarkierung_LiefertVerstaendlichenFehler()
|
||||||
|
{
|
||||||
|
var error = Assert.Throws<AiBackendException>(() =>
|
||||||
|
AiPlanningService.ParsePlanningLessons("Hier kommt das JSON: { kaputt }"));
|
||||||
|
|
||||||
|
Assert.Contains("noch kein gültiges Stunden-JSON", error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestPlanAsync_Backendfehler_BewahrtOriginalantwortFuerRettung()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
{"error":"Kein gültiges JSON.","rawResponse":"Vorspann\n{kaputt}"}
|
||||||
|
""";
|
||||||
|
var service = BuildWithResponse(HttpStatusCode.BadGateway, body);
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<AiBackendException>(() =>
|
||||||
|
service.RequestPlanAsync(new Unit(), "", "token"));
|
||||||
|
|
||||||
|
Assert.Equal("Vorspann\n{kaputt}", error.RawResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RequestPlanAsync_Typfehler_BewahrtEingebetteteOriginalantwortFuerRettung()
|
||||||
|
{
|
||||||
|
const string body = """
|
||||||
|
{"lessons":[{"topic":"Test","date":"kein Datum"}],"rawResponse":"DIE ORIGINALANTWORT"}
|
||||||
|
""";
|
||||||
|
var service = BuildWithResponse(HttpStatusCode.OK, body);
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<AiBackendException>(() =>
|
||||||
|
service.RequestPlanAsync(new Unit(), "", "token"));
|
||||||
|
|
||||||
|
Assert.Equal("DIE ORIGINALANTWORT", error.RawResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rettungsdialog_ImportiertMarkierteStundeAlsNeu()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup();
|
||||||
|
var unit = new Unit { GroupId = group.Id, Title = "T" };
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]),
|
||||||
|
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||||
|
var vm = new AiResponseRescueDialogViewModel(service, lessons, unit, "Rohantwort");
|
||||||
|
|
||||||
|
vm.ParseSelection("""{"topic":"Gerettete Stunde","phases":[]}""");
|
||||||
|
vm.ImportAsNewCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.True(vm.Result);
|
||||||
|
Assert.Equal("Gerettete Stunde", Assert.Single(lessons.GetByUnit(unit.Id)).Topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AiPlanningService BuildWithResponse(HttpStatusCode status, string body)
|
||||||
|
{
|
||||||
|
var http = new HttpClient(new StaticResponseHandler(status, body))
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri("https://example.invalid/"),
|
||||||
|
};
|
||||||
|
return new AiPlanningService(http, new FakeLessons(), new FakeGroups([]), new FakeSubjects([]),
|
||||||
|
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StaticResponseHandler(HttpStatusCode status, string body) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken) => Task.FromResult(new HttpResponseMessage(status)
|
||||||
|
{
|
||||||
|
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildContext_FuelltGruppenUndFachKontext()
|
public void BuildContext_FuelltGruppenUndFachKontext()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,7 +10,14 @@ 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, 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>
|
/// <summary>
|
||||||
/// Der KI-Wire-Vertrag verwendet deutsches Datumsformat (siehe ai-backend/plan.php Systemprompt,
|
/// 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)
|
private static async Task<AiBackendException> BuildRequestFailedExceptionAsync(HttpResponseMessage resp)
|
||||||
{
|
{
|
||||||
string? backendReason = null;
|
string? backendReason = null;
|
||||||
|
string? rawResponse = null;
|
||||||
try
|
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;
|
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. */ }
|
catch { /* Antwortkörper war kein valides {"error": "..."}-JSON - Fallback unten greift. */ }
|
||||||
|
|
||||||
return new AiBackendException(backendReason is null
|
return new AiBackendException(backendReason is null
|
||||||
? "Die Anfrage an den KI-Dienst ist fehlgeschlagen."
|
? "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)
|
public async Task<string> LoginAsync(string username, string password)
|
||||||
@@ -352,14 +362,64 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
|||||||
if (!resp.IsSuccessStatusCode)
|
if (!resp.IsSuccessStatusCode)
|
||||||
throw await BuildRequestFailedExceptionAsync(resp);
|
throw await BuildRequestFailedExceptionAsync(resp);
|
||||||
|
|
||||||
|
var responseText = await resp.Content.ReadAsStringAsync();
|
||||||
try
|
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.");
|
return result ?? throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is not AiBackendException)
|
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 LoginResult { public string Token { get; set; } = ""; }
|
||||||
private class BalanceResult { public decimal BalanceUsd { 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 string _errorMessage = "";
|
||||||
[ObservableProperty] private bool _hasResults;
|
[ObservableProperty] private bool _hasResults;
|
||||||
[ObservableProperty] private string? _summary;
|
[ObservableProperty] private string? _summary;
|
||||||
|
[ObservableProperty] private string? _rawResponse;
|
||||||
|
|
||||||
public string UnitSummary { get; }
|
public string UnitSummary { get; }
|
||||||
public ObservableCollection<AiLessonReviewItem> ReviewItems { get; } = [];
|
public ObservableCollection<AiLessonReviewItem> ReviewItems { get; } = [];
|
||||||
public bool Result { get; private set; }
|
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,
|
/// 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
|
/// 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()
|
? ReviewItems.Where(i => i.Accepted).Select(i => i.Source).ToList()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
ErrorMessage = ""; IsBusy = true;
|
ErrorMessage = ""; RawResponse = null; IsBusy = true;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = await _aiPlanning.RequestPlanAsync(_unit, Instruction, token, AllowModifyingExisting,
|
var response = await _aiPlanning.RequestPlanAsync(_unit, Instruction, token, AllowModifyingExisting,
|
||||||
@@ -1565,7 +1571,11 @@ public partial class AiAssistDialogViewModel : ObservableObject
|
|||||||
Summary = response.Summary;
|
Summary = response.Summary;
|
||||||
HasResults = true;
|
HasResults = true;
|
||||||
}
|
}
|
||||||
catch (AiBackendException ex) { ErrorMessage = ex.Message; }
|
catch (AiBackendException ex)
|
||||||
|
{
|
||||||
|
ErrorMessage = ex.Message;
|
||||||
|
RawResponse = ex.RawResponse;
|
||||||
|
}
|
||||||
finally { IsBusy = false; }
|
finally { IsBusy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1579,6 +1589,132 @@ public partial class AiAssistDialogViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand] private void Cancel() => Result = false;
|
[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) ────────────
|
// ── 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"/>
|
<TextBlock Text="Anfrage läuft…" FontSize="12" Opacity="0.6"/>
|
||||||
<ProgressBar IsIndeterminate="True" Height="4"/>
|
<ProgressBar IsIndeterminate="True" Height="4"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Text="{Binding ErrorMessage}" Foreground="Red" FontSize="12" TextWrapping="Wrap"
|
<StackPanel Spacing="8" IsVisible="{Binding ErrorMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
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>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,22 @@ public partial class AiAssistDialog : Window
|
|||||||
await vm.SendCommand.ExecuteAsync(null);
|
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)
|
private void OnApply(object? s, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is AiAssistDialogViewModel vm && vm.ApplyCommand.CanExecute(null))
|
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);
|
||||||
|
}
|
||||||
+2
-2
@@ -69,11 +69,11 @@ function ai_backend_authenticate(PDO $pdo): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Einheitliche Fehlerantwort als JSON, beendet danach das Skript. */
|
/** Einheitliche Fehlerantwort als JSON, beendet danach das Skript. */
|
||||||
function ai_backend_fail(int $httpStatus, string $message): never
|
function ai_backend_fail(int $httpStatus, string $message, array $details = []): never
|
||||||
{
|
{
|
||||||
http_response_code($httpStatus);
|
http_response_code($httpStatus);
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
echo json_encode(['error' => $message]);
|
echo json_encode(['error' => $message] + $details, JSON_INVALID_UTF8_SUBSTITUTE);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-2
@@ -162,8 +162,17 @@ $result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userC
|
|||||||
// Erst NACH der Abrechnung validieren: die Token wurden real verbraucht, das wird auch dann
|
// Erst NACH der Abrechnung validieren: die Token wurden real verbraucht, das wird auch dann
|
||||||
// verrechnet, wenn die KI kein valides JSON geliefert hat (siehe Planungsdokument).
|
// verrechnet, wenn die KI kein valides JSON geliefert hat (siehe Planungsdokument).
|
||||||
$parsed = ai_backend_decode_json_response($result['content']);
|
$parsed = ai_backend_decode_json_response($result['content']);
|
||||||
if (!is_array($parsed) || !isset($parsed['lessons'])) {
|
if (!is_array($parsed) || !isset($parsed['lessons']) || !is_array($parsed['lessons'])) {
|
||||||
ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.');
|
// Die bezahlte Modellantwort nicht wegwerfen: Der Desktop-Client kann sie in einem
|
||||||
|
// Rettungsdialog vollständig anzeigen und die Lehrkraft daraus gültiges JSON markieren bzw.
|
||||||
|
// von Hand korrigieren lassen. Nur dieser Planungsendpunkt bietet einen manuellen Import an.
|
||||||
|
ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.', [
|
||||||
|
'rawResponse' => $result['content'],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auch bei syntaktisch gültigem JSON kann erst der streng typisierte Desktop-Client einen
|
||||||
|
// Feldfehler entdecken (z.B. ein unlesbares Datum). Deshalb reist die Originalantwort bis zum
|
||||||
|
// Client mit; dort wird sie nach erfolgreicher Verarbeitung sofort wieder verworfen.
|
||||||
|
$parsed['rawResponse'] = $result['content'];
|
||||||
echo json_encode($parsed);
|
echo json_encode($parsed);
|
||||||
|
|||||||
Reference in New Issue
Block a user