diff --git a/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs b/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs index 810f456..be58ba3 100644 --- a/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs +++ b/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs @@ -95,3 +95,21 @@ public class AiPlanningResponse public List Lessons { get; set; } = []; public string? Summary { get; set; } } + +/// +/// Wire-Vertrag für den "Schattenfeld"-Endpunkt (4.5.21, ai-backend/explain.php): fragt nur auf +/// Nachfrage einen didaktischen Hintergrund zu einer bereits vorgeschlagenen/geplanten Lesson ab +/// (Warum dieser Aufbau, mögliche Stolpersteine, Differenzierung) — ändert nichts an der Planung, +/// eigener Endpunkt statt Zusatzfeld in jeder plan.php-Antwort, damit nur bei tatsächlicher +/// Nutzung abgerechnet wird. +/// +public class AiExplainRequest +{ + public AiUnitContext Unit { get; set; } = new(); + public AiLesson Lesson { get; set; } = new(); +} + +public class AiExplainResponse +{ + public string Explanation { get; set; } = ""; +} diff --git a/LehrerApp.Desktop.Tests/AiLessonReviewItemTests.cs b/LehrerApp.Desktop.Tests/AiLessonReviewItemTests.cs new file mode 100644 index 0000000..ab3450d --- /dev/null +++ b/LehrerApp.Desktop.Tests/AiLessonReviewItemTests.cs @@ -0,0 +1,52 @@ +using LehrerApp.Core.AiPlanning; +using LehrerApp.Desktop.Services; +using LehrerApp.Desktop.ViewModels.Groups; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +/// Tests für das "Schattenfeld" (4.5.21): didaktischer Hintergrund zu einer KI-Stunde, nur auf +/// Klick nachgeladen. Die eigentliche HTTP-Abfrage (AiPlanningService.RequestExplanationAsync) +/// wird hier per Delegate ersetzt, da RequestExplanationCommand rein davon abhängt. +public sealed class AiLessonReviewItemTests +{ + private static AiLesson Lesson() => new() { Topic = "Elektrolyse", Phases = [] }; + + [Fact] + public async Task RequestExplanation_SetztText_UndVerbirgtButtonDanach() + { + var item = new AiLessonReviewItem(Lesson(), isNew: true, + requestExplanation: _ => Task.FromResult("Didaktischer Hintergrund...")); + + Assert.True(item.ShowExplanationButton); + + await item.RequestExplanationCommand.ExecuteAsync(null); + + Assert.Equal("Didaktischer Hintergrund...", item.Explanation); + Assert.False(item.ShowExplanationButton); + Assert.False(item.IsLoadingExplanation); + Assert.Equal("", item.ExplanationError); + } + + [Fact] + public async Task RequestExplanation_BeiFehler_SetztExplanationErrorUndBehaeltButton() + { + var item = new AiLessonReviewItem(Lesson(), isNew: true, + requestExplanation: _ => throw new AiBackendException("Nicht genügend KI-Guthaben.")); + + await item.RequestExplanationCommand.ExecuteAsync(null); + + Assert.Equal("Nicht genügend KI-Guthaben.", item.ExplanationError); + Assert.Equal("", item.Explanation); + Assert.True(item.ShowExplanationButton); + } + + [Fact] + public void OhneAbfragefunktion_WirdKeinButtonAngeboten() + { + var item = new AiLessonReviewItem(Lesson(), isNew: true); + + Assert.False(item.CanRequestExplanation); + Assert.False(item.ShowExplanationButton); + } +} diff --git a/LehrerApp.Desktop/Services/AiPlanningService.cs b/LehrerApp.Desktop/Services/AiPlanningService.cs index e96f8fb..6aa64fa 100644 --- a/LehrerApp.Desktop/Services/AiPlanningService.cs +++ b/LehrerApp.Desktop/Services/AiPlanningService.cs @@ -339,6 +339,47 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, } } + /// + /// Fragt einen didaktischen Hintergrund zu einer bereits vorgeschlagenen/geplanten Lesson ab + /// (4.5.21 "Schattenfeld") — eigener Endpunkt (ai-backend/explain.php), damit das nur bei + /// tatsächlicher Nutzung abgerechnet wird statt bei jeder plan.php-Antwort mitgeneriert zu + /// werden. Ändert nichts an der Lesson, liefert nur erklärenden Text. + /// + public async Task RequestExplanationAsync(Unit unit, AiLesson lesson, string token) + { + var request = new AiExplainRequest { Unit = BuildContext(unit, ""), Lesson = lesson }; + + using var req = new HttpRequestMessage(HttpMethod.Post, "explain.php") + { + Content = JsonContent.Create(request, options: JsonOptions), + }; + req.Headers.Authorization = new("Bearer", token); + + HttpResponseMessage resp; + try { resp = await http.SendAsync(req); } + catch (HttpRequestException) + { + throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen."); + } + + if (resp.StatusCode == HttpStatusCode.Unauthorized) + throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden."); + if (resp.StatusCode == (HttpStatusCode)402) + throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen."); + if (!resp.IsSuccessStatusCode) + throw new AiBackendException("Die Anfrage an den KI-Dienst ist fehlgeschlagen."); + + try + { + var result = await resp.Content.ReadFromJsonAsync(JsonOptions); + return result?.Explanation ?? 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."); + } + } + /// /// Rein (nur Repository-Lesezugriff für den Alternativpfad-Katalog, kein Schreiben) — testbar /// mit Fakes. Gibt die zu speichernden Lesson-Objekte zurück; der Aufrufer ruft diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs index 4b445b7..469a0e7 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs @@ -1107,10 +1107,25 @@ public partial class AiLessonReviewItem : ObservableObject /// AiPhaseStep.MaterialSuggestion) — leer, wenn die KI für keine Phase einen Vorschlag hatte. public IReadOnlyList MaterialPrompts { get; } + /// Ob für diese Lesson überhaupt ein "Hintergrund erklären"-Button angeboten wird (4.5.21) — + /// false z.B. in Tests/Kontexten ohne verdrahtete Abfragefunktion. + public bool CanRequestExplanation => _requestExplanation is not null; + + /// Button verschwindet, sobald der Hintergrund einmal geladen wurde (Text steht dann da statt + /// des Buttons) — kein Grund, dieselbe kostenpflichtige Anfrage zweimal anzubieten. + public bool ShowExplanationButton => CanRequestExplanation && string.IsNullOrEmpty(Explanation); + + partial void OnExplanationChanged(string value) => OnPropertyChanged(nameof(ShowExplanationButton)); + + private readonly Func>? _requestExplanation; + [ObservableProperty] private bool _accepted = true; + [ObservableProperty] private string _explanation = ""; + [ObservableProperty] private bool _isLoadingExplanation; + [ObservableProperty] private string _explanationError = ""; public AiLessonReviewItem(AiLesson source, bool isNew, List? fieldDiffs = null, - List? materialPrompts = null) + List? materialPrompts = null, Func>? requestExplanation = null) { Source = source; IsNew = isNew; @@ -1118,6 +1133,20 @@ public partial class AiLessonReviewItem : ObservableObject DisplayLabel = isNew ? $"Neu: {source.Topic} ({dateText})" : $"Geändert: {source.Topic} ({dateText})"; DiffText = fieldDiffs is { Count: > 0 } ? string.Join("\n", fieldDiffs.Select(d => "• " + d)) : ""; MaterialPrompts = materialPrompts ?? []; + _requestExplanation = requestExplanation; + } + + /// Holt den didaktischen Hintergrund erst auf Klick nach (4.5.21 "Schattenfeld") — eigener, + /// nur bei tatsächlicher Nutzung abgerechneter Endpunkt statt bei jeder Antwort mitgeneriert. + [RelayCommand] + private async Task RequestExplanation() + { + if (_requestExplanation is null || IsLoadingExplanation) return; + IsLoadingExplanation = true; + ExplanationError = ""; + try { Explanation = await _requestExplanation(Source); } + catch (AiBackendException ex) { ExplanationError = ex.Message; } + finally { IsLoadingExplanation = false; } } } @@ -1183,7 +1212,8 @@ public partial class AiAssistDialogViewModel : ObservableObject .Where(p => !string.IsNullOrWhiteSpace(p.MaterialSuggestion)) .Select(p => new MaterialPromptItem(p.Name, p.MaterialSuggestion!, _aiPlanning.BuildMaterialPrompt(_unit, l, p))) .ToList(); - ReviewItems.Add(new AiLessonReviewItem(l, isNew: !isExisting, fieldDiffs, materialPrompts)); + ReviewItems.Add(new AiLessonReviewItem(l, isNew: !isExisting, fieldDiffs, materialPrompts, + requestExplanation: aiLesson => _aiPlanning.RequestExplanationAsync(_unit, aiLesson, token))); } Summary = response.Summary; HasResults = true; diff --git a/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml b/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml index 8c0f2da..56f750e 100644 --- a/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml @@ -38,6 +38,20 @@ +