diff --git a/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs b/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs index be58ba3..a9dca31 100644 --- a/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs +++ b/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs @@ -14,6 +14,12 @@ public class AiPlanningRequest // Systemprompt des Backends durchgesetzt als auch client-seitig in ApplyResponse defensiv // geprüft — die KI-Antwort wird dafür nicht blind vertraut. public bool AllowModifyingExistingLessons { get; set; } = true; + // Gesetzt, wenn die Anfrage aus dem Editor einer einzelnen Stunde heraus gestartet wurde + // (statt aus der Einheiten-Übersicht): die KI soll dann AUSSCHLIESSLICH diese eine Stunde + // bearbeiten, keine neuen Stunden vorschlagen und keine andere bestehende Stunde anfassen. + // Wie AllowModifyingExistingLessons sowohl im Systemprompt erbeten als auch client-seitig + // hart durchgesetzt (siehe AiAssistDialogViewModel.Send/AiPlanningService.ApplyResponse). + public Guid? FocusLessonId { get; set; } } public class AiUnitContext diff --git a/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs b/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs index 191ff92..088a494 100644 --- a/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs +++ b/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs @@ -317,6 +317,36 @@ public sealed class AiPlanningServiceTests Assert.Equal("Neue Stunde bleibt erlaubt", lesson.Topic); } + /// Fokus-Modus (4.5.22, KI-Unterstützung aus dem Stunden-Editor heraus): auch wenn die KI die + /// Anweisung ignoriert und weitere/andere Stunden zurückgibt, darf clientseitig nur die + /// angefragte Fokus-Stunde übernommen werden. + [Fact] + public void ApplyResponse_FocusLessonId_VerwirftAlleAnderenLessons() + { + var group = new LearningGroup(); + var unit = new Unit { GroupId = group.Id, Title = "T" }; + var focus = new Lesson { UnitId = unit.Id, GroupId = group.Id, Topic = "Fokus-Stunde" }; + var other = new Lesson { UnitId = unit.Id, GroupId = group.Id, Topic = "Andere Stunde" }; + var lessons = new FakeLessons(); + lessons.Add(focus); + lessons.Add(other); + + var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]), + new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); + + var accepted = new List + { + new() { Id = focus.Id, Topic = "Fokus-Stunde, geändert" }, + new() { Id = other.Id, Topic = "Sollte verworfen werden" }, + new() { Id = null, Topic = "Neue Stunde sollte verworfen werden" }, + }; + var result = service.ApplyResponse(unit, accepted, focusLessonId: focus.Id); + + var lesson = Assert.Single(result); + Assert.Equal(focus.Id, lesson.Id); + Assert.Equal("Fokus-Stunde, geändert", lesson.Topic); + } + [Fact] public void ApplyResponse_NullId_WirdAlsNeueLessonBehandelt() { diff --git a/LehrerApp.Desktop/Services/AiPlanningService.cs b/LehrerApp.Desktop/Services/AiPlanningService.cs index 6aa64fa..58182c4 100644 --- a/LehrerApp.Desktop/Services/AiPlanningService.cs +++ b/LehrerApp.Desktop/Services/AiPlanningService.cs @@ -299,13 +299,14 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, } public async Task RequestPlanAsync(Unit unit, string instruction, string token, - bool allowModifyingExistingLessons = true, List? draftOverrides = null) + bool allowModifyingExistingLessons = true, List? draftOverrides = null, Guid? focusLessonId = null) { var request = new AiPlanningRequest { Instruction = instruction, Unit = BuildContext(unit, instruction, draftOverrides), AllowModifyingExistingLessons = allowModifyingExistingLessons, + FocusLessonId = focusLessonId, }; using var req = new HttpRequestMessage(HttpMethod.Post, "plan.php") @@ -388,9 +389,12 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, /// interpretiert, sondern immer als neue Lesson behandelt (Anti-Halluzinations-Absicherung). /// Ist false, werden Änderungen an bestehenden /// Lessons zusätzlich hart verworfen (nicht nur per Systemprompt an die KI erbeten) — die - /// Einschränkung wird also nicht blind der KI-Antwort überlassen. + /// Einschränkung wird also nicht blind der KI-Antwort überlassen. Ist + /// gesetzt (Anfrage aus dem Editor einer einzelnen Stunde + /// heraus), wird ebenso hart jede Lesson mit abweichender oder fehlender Id verworfen. /// - public List ApplyResponse(Unit unit, List acceptedLessons, bool allowModifyingExistingLessons = true) + public List ApplyResponse(Unit unit, List acceptedLessons, + bool allowModifyingExistingLessons = true, Guid? focusLessonId = null) { var pathIdsByName = altPaths.GetAll().ToDictionary(p => p.Name, p => p.Id); var existingLessons = lessons.GetByUnit(unit.Id).ToDictionary(l => l.Id); @@ -398,6 +402,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, var result = new List(); foreach (var ai in acceptedLessons) { + if (focusLessonId is { } focus && ai.Id != focus) continue; var isUpdate = ai.Id is { } id && existingLessons.ContainsKey(id); if (isUpdate && !allowModifyingExistingLessons) continue; result.Add(new Lesson diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs index 469a0e7..9c813c5 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs @@ -631,6 +631,18 @@ public partial class LessonDialogViewModel : ObservableObject public string DialogTitle => _editingLesson is null ? "Neue Stunde anlegen" : "Stunde bearbeiten"; public string SaveButtonText => _editingLesson is null ? "Anlegen" : "Speichern"; + // Für die KI-Unterstützung mit Fokus auf genau diese Stunde (4.5.22) — nur für bereits + // gespeicherte Stunden sinnvoll, eine gerade erst angelegte, noch ungespeicherte Stunde hat + // keine echte Id, auf die sich die KI beziehen könnte. + public Guid UnitId => _unitId; + public Lesson? EditingLesson => _editingLesson; + public bool CanAiAssist => _editingLesson is not null; + + /// Vom Code-Behind nach einer über die KI angewendeten Änderung aufgerufen: der Dialog schließt + /// sich danach mit Result != null, damit die aufrufende Liste neu lädt — die eigenen, jetzt + /// veralteten Feldwerte dieses Fensters werden NICHT mehr über die KI-Änderung gespeichert. + public void MarkAppliedExternally(Lesson updated) => Result = updated; + public LessonDialogViewModel(ILessonRepository lessons, IShorthandCodeRepository shorthandCodes, IAlternativeLessonPathRepository alternativePaths, ITimetableSlotRepository timetableSlots, PeriodScheduleService periodSchedule, Guid unitId, Guid groupId, @@ -1156,6 +1168,7 @@ public partial class AiAssistDialogViewModel : ObservableObject private readonly AiSettingsService _aiSettings; private readonly ILessonRepository _lessons; private readonly Unit _unit; + private readonly Lesson? _focusLesson; [ObservableProperty] private string _instruction = ""; [ObservableProperty] private bool _allowModifyingExisting = true; @@ -1168,10 +1181,22 @@ public partial class AiAssistDialogViewModel : ObservableObject public ObservableCollection ReviewItems { get; } = []; public bool Result { get; private set; } + /// 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 + /// Stunde bearbeiten, der Umfangs-Umschalter macht in diesem Modus keinen Sinn und wird + /// ausgeblendet (siehe AiAssistDialog.axaml). + public bool IsFocusMode => _focusLesson is not null; + public string? FocusLabel => _focusLesson is null ? null + : $"Fokus: nur diese Stunde — „{_focusLesson.Topic}“ ({_focusLesson.Date:dd.MM.yyyy})"; + public AiAssistDialogViewModel(AiPlanningService aiPlanning, AiSettingsService aiSettings, - ILessonRepository lessons, Unit unit) + ILessonRepository lessons, Unit unit, Lesson? focusLesson = null) { _aiPlanning = aiPlanning; _aiSettings = aiSettings; _lessons = lessons; _unit = unit; + _focusLesson = focusLesson; + // Ohne Änderungserlaubnis gäbe es im Fokus-Modus nichts, was die KI überhaupt vorschlagen + // dürfte (neue Stunden sind hier ja ausdrücklich nicht das Ziel) — deshalb erzwungen an. + if (IsFocusMode) AllowModifyingExisting = true; var lessonCount = lessons.GetByUnit(unit.Id).Count; UnitSummary = $"Einheit: {unit.Title} — {lessonCount} Stunde(n)"; } @@ -1196,11 +1221,18 @@ public partial class AiAssistDialogViewModel : ObservableObject ErrorMessage = ""; IsBusy = true; try { - var response = await _aiPlanning.RequestPlanAsync(_unit, Instruction, token, AllowModifyingExisting, draftOverrides); + var response = await _aiPlanning.RequestPlanAsync(_unit, Instruction, token, AllowModifyingExisting, + draftOverrides, _focusLesson?.Id); var existingLessons = _lessons.GetByUnit(_unit.Id).ToDictionary(l => l.Id); + // Im Fokus-Modus hart auf die eine angefragte Stunde beschränken, statt der KI-Antwort + // zu vertrauen — dieselbe Absicherung wie beim Umfangs-Umschalter unten. + var lessonsToShow = IsFocusMode + ? response.Lessons.Where(l => l.Id == _focusLesson!.Id).ToList() + : response.Lessons; + ReviewItems.Clear(); - foreach (var l in response.Lessons) + foreach (var l in lessonsToShow) { var isExisting = l.Id is { } id && existingLessons.ContainsKey(id); // Falls der Modus Änderungen an bestehenden Stunden verbietet, aber die KI die @@ -1226,7 +1258,7 @@ public partial class AiAssistDialogViewModel : ObservableObject private void Apply() { var accepted = ReviewItems.Where(i => i.Accepted).Select(i => i.Source).ToList(); - foreach (var lesson in _aiPlanning.ApplyResponse(_unit, accepted, AllowModifyingExisting)) + foreach (var lesson in _aiPlanning.ApplyResponse(_unit, accepted, AllowModifyingExisting, _focusLesson?.Id)) _lessons.Save(lesson); Result = true; } diff --git a/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml b/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml index 56f750e..8bd8f34 100644 --- a/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml @@ -12,6 +12,8 @@ + diff --git a/LehrerApp.Desktop/Views/Groups/LessonDialog.axaml b/LehrerApp.Desktop/Views/Groups/LessonDialog.axaml index be3067f..4ef729f 100644 --- a/LehrerApp.Desktop/Views/Groups/LessonDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/LessonDialog.axaml @@ -133,9 +133,12 @@ - +