From f703fe7b66542ab08f9871cb00fd749cd13c26a0 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Sun, 16 Aug 2026 21:22:12 +0200 Subject: [PATCH] =?UTF-8?q?KI-Feature=204.5.20:=20Medienvorschlag=20+=20Zw?= =?UTF-8?q?ischenablage-Prompt=20f=C3=BCr=20externe=20Erzeugung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die KI liefert je Verlaufsplan-Phase optional einen kurzen Vorschlag, was ein Medium/Material (z.B. Tafelbild) zeigen sollte (AiPhaseStep.MaterialSuggestion), nur wenn das über das ohnehin genannte Material hinaus einen Mehrwert hätte. Bewusst nicht ins Domänenmodell übernommen (keine Migration nötig) — nur für die Review-Anzeige relevant. AiPlanningService.BuildMaterialPrompt baut daraus rein lokal (kein weiterer KI-Aufruf) einen vollständigen Prompt aus Fach/Stufe/Gruppe, Einheit, Stunde und Phase. Im AiAssistDialog landet er per Klick in der Zwischenablage, zum Einfügen in eine separate Claude-Sitzung für die eigentliche Materialerzeugung. Co-Authored-By: Claude Sonnet 5 --- LehrerApp.Core/AiPlanning/AiPlanningDtos.cs | 6 +++ .../AiPlanningServiceTests.cs | 44 +++++++++++++++++++ .../Services/AiPlanningService.cs | 33 ++++++++++++++ .../ViewModels/Groups/PlanningViewModels.cs | 19 +++++++- .../Views/Groups/AiAssistDialog.axaml | 13 ++++++ .../Views/Groups/AiAssistDialog.axaml.cs | 13 ++++++ TODO.md | 26 +++++++++++ ai-backend/plan.php | 10 ++++- 8 files changed, 161 insertions(+), 3 deletions(-) diff --git a/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs b/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs index 5ab9871..810f456 100644 --- a/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs +++ b/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs @@ -82,6 +82,12 @@ public class AiPhaseStep // Name statt Guid: LLMs erfinden/verändern Guids unzuverlässig, ein Name ist beim Import // gegen den Katalog abgleichbar (kein Treffer -> null = Hauptweg, kein Fehler). public string? AlternativePathName { get; set; } + // Kurzer Vorschlag für ein konkretes Medium/Material zu dieser Phase (z.B. Tafelbild, + // Arbeitsblatt), nur gesetzt, wenn eines wirklich sinnvoll wäre — nicht bei jeder Phase. + // Wird NICHT in Lesson.Phases übernommen (nur für die Review-Anzeige/Prompt-Erzeugung + // relevant, siehe AiPlanningService.BuildMaterialPrompt), daher kein Feld auf + // LessonPhaseStep nötig. + public string? MaterialSuggestion { get; set; } } public class AiPlanningResponse diff --git a/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs b/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs index 2911113..191ff92 100644 --- a/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs +++ b/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs @@ -205,6 +205,50 @@ public sealed class AiPlanningServiceTests Assert.Contains(service.DescribeChanges(existing, proposed), d => d.Contains("Verlaufsplan")); } + [Fact] + public void BuildMaterialPrompt_EnthaeltKontextUndVorschlag() + { + var subject = new Subject { Name = "Chemie" }; + var group = new LearningGroup { Name = "9c", SubjectId = subject.Id, GradeLevel = 9 }; + var unit = new Unit { GroupId = group.Id, Title = "Redoxreaktionen" }; + var lesson = new AiLesson { Topic = "Elektrolyse", Date = new DateOnly(2026, 3, 17), Phases = [] }; + var phase = new AiPhaseStep + { + Name = "Erarbeitung", DurationMinutes = 20, Activity = "Experiment auswerten", + Material = "AB003", MaterialSuggestion = "Tafelbild mit dem Aufbau der Elektrolysezelle.", + }; + + var service = Build(new FakeLessons(), new FakeGroups([group]), new FakeSubjects([subject]), + new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); + + var prompt = service.BuildMaterialPrompt(unit, lesson, phase); + + Assert.Contains("Chemie", prompt); + Assert.Contains("9c", prompt); + Assert.Contains("Redoxreaktionen", prompt); + Assert.Contains("Elektrolyse", prompt); + Assert.Contains("Erarbeitung", prompt); + Assert.Contains("AB003", prompt); + Assert.Contains("Tafelbild mit dem Aufbau der Elektrolysezelle.", prompt); + } + + [Fact] + public void BuildMaterialPrompt_OhneBisherigesMaterial_LaesstZeileWeg() + { + var group = new LearningGroup(); + var unit = new Unit { GroupId = group.Id, Title = "T" }; + var lesson = new AiLesson { Topic = "Stunde", Phases = [] }; + var phase = new AiPhaseStep { Name = "Einstieg", MaterialSuggestion = "Kurzer Impuls per Bild." }; + + var service = Build(new FakeLessons(), new FakeGroups([group]), new FakeSubjects([]), + new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); + + var prompt = service.BuildMaterialPrompt(unit, lesson, phase); + + Assert.DoesNotContain("Bisheriges Material", prompt); + Assert.Contains("Kurzer Impuls per Bild.", prompt); + } + [Fact] public void ApplyResponse_BekannteId_WirdAlsUpdateBehandelt() { diff --git a/LehrerApp.Desktop/Services/AiPlanningService.cs b/LehrerApp.Desktop/Services/AiPlanningService.cs index 9ac93d9..e96f8fb 100644 --- a/LehrerApp.Desktop/Services/AiPlanningService.cs +++ b/LehrerApp.Desktop/Services/AiPlanningService.cs @@ -249,6 +249,39 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, return diffs; } + /// + /// Baut aus Einheit/Stunde/Phase und dem KI-Medienvorschlag () + /// einen vollständigen, eigenständigen Prompt zum Einfügen in eine separate Claude-Sitzung — + /// die Lehrkraft kann sich das eigentliche Material (Tafelbild, Arbeitsblatt, ...) dort selbst + /// erzeugen lassen, ohne dass das über das eigene (kostenpflichtige) KI-Backend laufen muss. + /// Nur der kurze Vorschlagstext kommt von der KI, der restliche Kontext wird rein lokal aus + /// bereits vorhandenen Daten zusammengesetzt. Rein, ohne I/O. + /// + public string BuildMaterialPrompt(Unit unit, AiLesson lesson, AiPhaseStep phase) + { + var group = groups.GetById(unit.GroupId); + var subject = group?.SubjectId is { } subjectId ? subjects.GetById(subjectId) : null; + var dateText = lesson.Date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "noch offen"; + + var lines = new List + { + $"Ich unterrichte {subject?.Name ?? "unbekanntes Fach"}, Klassenstufe {group?.GradeLevel ?? 0}, Gruppe {group?.Name ?? "-"}.", + $"Einheit: „{unit.Title}“", + $"Stunde: „{lesson.Topic}“ (Datum: {dateText})", + $"Phase: „{phase.Name}“, {phase.DurationMinutes} Min., Tätigkeit: {phase.Activity}", + }; + if (!string.IsNullOrWhiteSpace(phase.Material)) + lines.Add($"Bisheriges Material laut Planung: {phase.Material}"); + lines.Add(""); + lines.Add("Empfehlung aus der Unterrichtsplanung, was das Material zeigen/enthalten sollte:"); + lines.Add(phase.MaterialSuggestion ?? ""); + lines.Add(""); + lines.Add("Bitte erstelle mir auf dieser Grundlage ein passendes Material für diese Phase " + + "(z.B. Tafelbild, Arbeitsblatt oder Visualisierung)."); + + return string.Join("\n", lines); + } + /// /// Ersetzt in (dem tatsächlichen Datenbankstand) jede Lesson, deren Id /// auch in vorkommt, durch die Entwurfsversion, und hängt Entwürfe ohne diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs index 62376df..4b445b7 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs @@ -1085,6 +1085,11 @@ public partial class GenerateLessonSeriesDialogViewModel : ObservableObject // ── Dialog: KI-gestützte Planungsunterstützung (4.5.9) ─────────────────────── +/// Ein kopierbarer Prompt für ein von der KI vorgeschlagenes Material/Medium zu einer Phase (siehe +/// AiPlanningService.BuildMaterialPrompt) — gedacht zum Einfügen in eine separate Claude-Sitzung, +/// nicht über das eigene KI-Backend erzeugt. +public record MaterialPromptItem(string PhaseName, string SuggestionText, string PromptText); + /// Eine von der KI vorgeschlagene Stunde in der Prüfliste des Dialogs — angehakt = wird beim /// "Übernehmen" mit übertragen. unterscheidet neu/geändert (siehe /// AiPlanningDtos.cs), steuert hier nur die Anzeige ("Neu"/"Geändert"). @@ -1098,15 +1103,21 @@ public partial class AiLessonReviewItem : ObservableObject /// Aufzählung fürs UI — leer bei neuen Vorschlägen, da es dort nichts zu vergleichen gibt. public string DiffText { get; } + /// Medienvorschläge je Phase dieser Lesson (nur Phasen mit gesetztem + /// AiPhaseStep.MaterialSuggestion) — leer, wenn die KI für keine Phase einen Vorschlag hatte. + public IReadOnlyList MaterialPrompts { get; } + [ObservableProperty] private bool _accepted = true; - public AiLessonReviewItem(AiLesson source, bool isNew, List? fieldDiffs = null) + public AiLessonReviewItem(AiLesson source, bool isNew, List? fieldDiffs = null, + List? materialPrompts = null) { Source = source; IsNew = isNew; var dateText = source.Date?.ToString("dd.MM.yyyy", CultureInfo.InvariantCulture) ?? "kein Datum"; 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 ?? []; } } @@ -1168,7 +1179,11 @@ public partial class AiAssistDialogViewModel : ObservableObject // dem Nutzer eine Auswahl zu zeigen, die ApplyResponse ohnehin verwerfen würde. if (isExisting && !AllowModifyingExisting) continue; var fieldDiffs = isExisting ? _aiPlanning.DescribeChanges(existingLessons[l.Id!.Value], l) : null; - ReviewItems.Add(new AiLessonReviewItem(l, isNew: !isExisting, fieldDiffs)); + var materialPrompts = l.Phases + .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)); } Summary = response.Summary; HasResults = true; diff --git a/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml b/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml index ed243c4..8c0f2da 100644 --- a/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml @@ -25,6 +25,19 @@ + + + + + + +