KI-Feature 4.5.22: KI-Unterstützung direkt aus dem Stunden-Editor starten

Bisher musste man aus dem Stunden-Editor raus, die KI-Hilfe für die ganze
Einheit aufrufen und die gewünschte Stunde in der freien Anweisung erst
benennen. Neuer Button "KI-Unterstützung für diese Stunde" im LessonDialog
öffnet denselben AiAssistDialog im neuen Fokus-Modus.

AiPlanningRequest.FocusLessonId weist die KI im Systemprompt an, sich auf
genau diese Stunde zu beschränken; zusätzlich wie beim Umfangs-Umschalter
client-seitig hart durchgesetzt in Send() und ApplyResponse, statt der
KI-Antwort blind zu vertrauen.

Da "Übernehmen" direkt ins Repository speichert, wären die noch offenen
Feldwerte des ursprünglichen LessonDialog danach veraltet gewesen - der
Dialog schließt sich deshalb nach einer angewendeten KI-Änderung automatisch
mit dem frischen Stand statt ihn mit alten Werten zu überschreiben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 22:48:00 +02:00
co-authored by Claude Sonnet 5
parent 364df378f0
commit 10d82e5e2c
9 changed files with 147 additions and 10 deletions
@@ -299,13 +299,14 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
}
public async Task<AiPlanningResponse> RequestPlanAsync(Unit unit, string instruction, string token,
bool allowModifyingExistingLessons = true, List<AiLesson>? draftOverrides = null)
bool allowModifyingExistingLessons = true, List<AiLesson>? 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 <paramref name="allowModifyingExistingLessons"/> 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
/// <paramref name="focusLessonId"/> gesetzt (Anfrage aus dem Editor einer einzelnen Stunde
/// heraus), wird ebenso hart jede Lesson mit abweichender oder fehlender Id verworfen.
/// </summary>
public List<Lesson> ApplyResponse(Unit unit, List<AiLesson> acceptedLessons, bool allowModifyingExistingLessons = true)
public List<Lesson> ApplyResponse(Unit unit, List<AiLesson> 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<Lesson>();
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
@@ -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<AiLessonReviewItem> 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;
}
@@ -12,6 +12,8 @@
<StackPanel Spacing="14" Margin="0,0,12,0">
<TextBlock Text="KI-Unterstützung" Classes="dialogtitle"/>
<TextBlock Text="{Binding UnitSummary}" FontSize="12" Opacity="0.6"/>
<TextBlock Text="{Binding FocusLabel}" FontSize="12" FontWeight="SemiBold"
IsVisible="{Binding IsFocusMode}"/>
<StackPanel Spacing="10" IsVisible="{Binding HasResults}">
<TextBlock Text="{Binding Summary}" FontSize="12" TextWrapping="Wrap"
@@ -68,7 +70,7 @@
PlaceholderText="z.B. Ergänze zwei weitere Stunden zum Thema Redoxreaktionen mit steigendem Anspruch."
IsEnabled="{Binding !IsBusy}"/>
<CheckBox Content="Auch bestehende Stundeninhalte anpassen" IsChecked="{Binding AllowModifyingExisting}"
IsEnabled="{Binding !IsBusy}"
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding !IsFocusMode}"
ToolTip.Tip="Deaktivieren, um die Einheit nur um neue Stunden zu erweitern, ohne den Inhalt bereits vorhandener Stunden zu verändern."/>
</StackPanel>
@@ -133,9 +133,12 @@
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Grid Grid.Row="1" ColumnDefinitions="*,8,*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="{Binding SaveButtonText}"
<Button Grid.Column="2" Content="🤖 KI-Unterstützung für diese Stunde" HorizontalAlignment="Stretch"
Click="OnAiAssist" IsVisible="{Binding CanAiAssist}"
ToolTip.Tip="Fragt die KI gezielt zu genau dieser Stunde — Speicherstand geht dabei direkt in die Datenbank, nicht über die Felder hier."/>
<Button Grid.Column="4" Content="{Binding SaveButtonText}"
HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid>
</Grid>
@@ -2,6 +2,7 @@ using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Microsoft.Extensions.DependencyInjection;
@@ -38,4 +39,34 @@ public partial class LessonDialog : Window
}
private void OnCancel(object? s, RoutedEventArgs e) => Close(false);
/// KI-Unterstützung mit Fokus auf genau diese (bereits gespeicherte) Stunde, statt den Umweg
/// über die Einheiten-Übersicht nehmen zu müssen (4.5.22, Nutzer-Feedback). Die Übernahme im
/// AiAssistDialog speichert direkt ins Repository — dieser Dialog schließt sich danach mit dem
/// frisch geladenen Stand, statt seine eigenen (jetzt veralteten) Feldwerte zu speichern.
private async void OnAiAssist(object? s, RoutedEventArgs e)
{
if (DataContext is not LessonDialogViewModel vm || vm.EditingLesson is null) return;
var unitRepo = App.Services.GetRequiredService<IUnitRepository>();
var unit = unitRepo.GetById(vm.UnitId);
if (unit is null) return;
var lessonRepo = App.Services.GetRequiredService<ILessonRepository>();
var currentLesson = lessonRepo.GetByUnit(vm.UnitId).FirstOrDefault(l => l.Id == vm.EditingLesson.Id);
if (currentLesson is null) return;
var dialogVm = new AiAssistDialogViewModel(
App.Services.GetRequiredService<AiPlanningService>(),
App.Services.GetRequiredService<AiSettingsService>(),
lessonRepo, unit, focusLesson: currentLesson);
var dialog = new AiAssistDialog { DataContext = dialogVm };
var ok = await dialog.ShowDialog<bool>(this);
if (!ok) return;
var updated = lessonRepo.GetByUnit(vm.UnitId).FirstOrDefault(l => l.Id == vm.EditingLesson.Id);
if (updated is not null) vm.MarkAppliedExternally(updated);
Close(true);
}
}