diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index f829963..2689e50 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -95,6 +95,7 @@ public interface IUnitRepository } public interface ILessonRepository { + Lesson? GetById(Guid id); List GetByUnit(Guid unitId); List GetByGroupAndDate(Guid groupId, DateOnly date); List GetByGroupAndRange(Guid groupId, DateOnly from, DateOnly to); diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 88e8637..9427fc4 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -386,6 +386,7 @@ public class UnitRepository(LiteDbContext db) : IUnitRepository public class LessonRepository(LiteDbContext db) : ILessonRepository { + public Lesson? GetById(Guid id) => db.Lessons.FindById(id); public List GetByUnit(Guid id) => db.Lessons.Find(l => l.UnitId == id).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber).ToList(); public List GetByGroupAndDate(Guid gid, DateOnly date) => diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index 0b412e0..9df5781 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -355,6 +355,7 @@ public class FakeLessons : ILessonRepository { private readonly List _all = []; public void Add(Lesson l) => _all.Add(l); + public Lesson? GetById(Guid id) => _all.FirstOrDefault(l => l.Id == id); public List GetByUnit(Guid unitId) => _all.Where(l => l.UnitId == unitId).OrderBy(l => l.Date).ThenBy(l => l.LessonNumber).ToList(); public List GetByGroupAndDate(Guid groupId, DateOnly date) => diff --git a/LehrerApp.Desktop.Tests/McpToolsTests.cs b/LehrerApp.Desktop.Tests/McpToolsTests.cs index 43d2a98..7115736 100644 --- a/LehrerApp.Desktop.Tests/McpToolsTests.cs +++ b/LehrerApp.Desktop.Tests/McpToolsTests.cs @@ -10,18 +10,27 @@ public sealed class McpToolsTests // ── McpToolScope ───────────────────────────────────────────────────────────────────────── [Fact] - public void AllowedReadTools_EnthaeltGenauDieSechsReadTools() + public void AllowedReadTools_EnthaeltGenauDieErwartetenReadTools() { Assert.Equal( - new[] { "get_exams", "get_grades", "get_lesson_plans", "get_schedule", "get_students", "get_time_entries" }, + new[] + { + "download_lesson_attachment", "get_exams", "get_grades", "get_lesson_plans", + "get_schedule", "get_students", "get_time_entries", + }, McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal)); } [Fact] - public void AllowedWriteTools_EnthaeltGenauDieDreiPhase2WriteTools() + public void AllowedWriteTools_EnthaeltGenauDieErwartetenWriteTools() { Assert.Equal( - new[] { "create_grade_entry", "create_time_entry", "update_student_group_assignment" }, + new[] + { + "add_lesson_phase", "create_grade_entry", "create_lesson", "create_time_entry", + "create_unit", "remove_lesson_phase", "update_lesson", "update_lesson_phase", + "update_student_group_assignment", "update_unit", + }, McpToolScope.AllowedWriteTools.OrderBy(n => n, StringComparer.Ordinal)); } @@ -211,6 +220,12 @@ public sealed class McpToolsTests // ── LessonPlanTools ────────────────────────────────────────────────────────────────────── + private static LessonPlanTools BuildLessonPlanTools( + FakeUnits? units = null, FakeLessons? lessons = null, FakeGroups? groups = null, + FakeAttachmentStorage? attachments = null, FakeMcpConfirmation? confirmation = null) => + new(units ?? new FakeUnits(), lessons ?? new FakeLessons(), groups ?? new FakeGroups([]), + attachments ?? new FakeAttachmentStorage(), confirmation ?? new FakeMcpConfirmation()); + [Fact] public void GetLessonPlans_LiefertEinheitenDerGruppeUndStundenImZeitraum() { @@ -220,7 +235,7 @@ public sealed class McpToolsTests var lessons = new FakeLessons(); lessons.Add(new Lesson { GroupId = groupId, Date = new DateOnly(2026, 1, 5), Topic = "Brechung" }); lessons.Add(new Lesson { GroupId = groupId, Date = new DateOnly(2026, 3, 1), Topic = "Später" }); - var tool = new LessonPlanTools(units, lessons); + var tool = BuildLessonPlanTools(units, lessons); var result = tool.GetLessonPlans(groupId, new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)); @@ -229,6 +244,161 @@ public sealed class McpToolsTests Assert.Equal("Brechung", lesson.Topic); } + [Fact] + public async Task CreateUnit_NutzerBestaetigt_SpeichertEinheit() + { + var group = new LearningGroup { Name = "7a" }; + var units = new FakeUnits(); + var tool = BuildLessonPlanTools(units: units, groups: new FakeGroups([group])); + + var result = await tool.CreateUnit(group.Id, "Optik"); + + Assert.True(result.Applied); + Assert.Single(units.GetByGroup(group.Id)); + } + + [Fact] + public async Task UpdateUnit_OhneAenderung_FragtNichtNach() + { + var unit = new Unit { Title = "Optik", Status = UnitStatus.Planned }; + var units = new FakeUnits(); + units.Add(unit); + var confirmation = new FakeMcpConfirmation(); + var tool = BuildLessonPlanTools(units: units, confirmation: confirmation); + + var result = await tool.UpdateUnit(unit.Id, title: "Optik", status: UnitStatus.Planned); + + Assert.True(result.Applied); + Assert.Equal(0, confirmation.CallCount); + } + + [Fact] + public async Task CreateLesson_UnbekannteEinheit_LiefertFehlerOhneNachfrage() + { + var confirmation = new FakeMcpConfirmation(); + var tool = BuildLessonPlanTools(confirmation: confirmation); + + var result = await tool.CreateLesson(Guid.NewGuid(), new DateOnly(2026, 1, 5), "Brechung"); + + Assert.False(result.Applied); + Assert.Equal(0, confirmation.CallCount); + } + + [Fact] + public async Task CreateLesson_NutzerBestaetigt_UebernimmtGruppeVonDerEinheit() + { + var groupId = Guid.NewGuid(); + var unit = new Unit { GroupId = groupId, Title = "Optik" }; + var units = new FakeUnits(); + units.Add(unit); + var lessons = new FakeLessons(); + var tool = BuildLessonPlanTools(units, lessons); + + var result = await tool.CreateLesson(unit.Id, new DateOnly(2026, 1, 5), "Brechung"); + + Assert.True(result.Applied); + var lesson = Assert.Single(lessons.GetByUnit(unit.Id)); + Assert.Equal(groupId, lesson.GroupId); + } + + [Fact] + public async Task UpdateLesson_AendertNurAngegebeneFelder() + { + var lesson = new Lesson { Topic = "Brechung", Homework = "S. 12" }; + var lessons = new FakeLessons(); + lessons.Add(lesson); + var tool = BuildLessonPlanTools(lessons: lessons); + + var result = await tool.UpdateLesson(lesson.Id, topic: "Brechung II"); + + Assert.True(result.Applied); + var updated = lessons.GetById(lesson.Id)!; + Assert.Equal("Brechung II", updated.Topic); + Assert.Equal("S. 12", updated.Homework); + } + + [Fact] + public async Task AddLessonPhase_NutzerBestaetigt_HaengtPhaseAn() + { + var lesson = new Lesson { Topic = "Brechung" }; + var lessons = new FakeLessons(); + lessons.Add(lesson); + var tool = BuildLessonPlanTools(lessons: lessons); + + var result = await tool.AddLessonPhase(lesson.Id, "Einstieg", 10); + + Assert.True(result.Applied); + var phase = Assert.Single(lessons.GetById(lesson.Id)!.Phases); + Assert.Equal("Einstieg", phase.Name); + Assert.Equal(result.Id, phase.Id); + } + + [Fact] + public async Task UpdateLessonPhase_AendertNurAngegebeneFelder() + { + var phase = new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 10, Material = "Folie" }; + var lesson = new Lesson { Topic = "Brechung" }; + lesson.Phases.Add(phase); + var lessons = new FakeLessons(); + lessons.Add(lesson); + var tool = BuildLessonPlanTools(lessons: lessons); + + var result = await tool.UpdateLessonPhase(lesson.Id, phase.Id, durationMinutes: 15); + + Assert.True(result.Applied); + var updated = lessons.GetById(lesson.Id)!.Phases.Single(); + Assert.Equal(15, updated.DurationMinutes); + Assert.Equal("Folie", updated.Material); + } + + [Fact] + public async Task RemoveLessonPhase_NutzerBestaetigt_EntferntPhase() + { + var phase = new LessonPhaseStep { Name = "Einstieg", DurationMinutes = 10 }; + var lesson = new Lesson { Topic = "Brechung" }; + lesson.Phases.Add(phase); + var lessons = new FakeLessons(); + lessons.Add(lesson); + var tool = BuildLessonPlanTools(lessons: lessons); + + var result = await tool.RemoveLessonPhase(lesson.Id, phase.Id); + + Assert.True(result.Applied); + Assert.Empty(lessons.GetById(lesson.Id)!.Phases); + } + + [Fact] + public void DownloadLessonAttachment_LiefertBase64Inhalt() + { + var storage = new FakeAttachmentStorage(); + var storageId = storage.Upload("blatt.pdf", new MemoryStream("PDF-Inhalt"u8.ToArray())); + var lesson = new Lesson { Topic = "Brechung" }; + lesson.Attachments.Add(new DocumentAttachment { StorageId = storageId, FileName = "blatt.pdf", SizeBytes = 10 }); + var lessons = new FakeLessons(); + lessons.Add(lesson); + var tool = BuildLessonPlanTools(lessons: lessons, attachments: storage); + + var result = tool.DownloadLessonAttachment(lesson.Id, storageId); + + Assert.Equal("blatt.pdf", result.FileName); + Assert.Equal("PDF-Inhalt", System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(result.Base64Content))); + } + + [Fact] + public void DownloadLessonAttachment_ZuGross_WirftFehler() + { + var lesson = new Lesson { Topic = "Brechung" }; + lesson.Attachments.Add(new DocumentAttachment + { + StorageId = "big", FileName = "video.mp4", SizeBytes = LessonPlanTools.MaxInlineAttachmentBytes + 1, + }); + var lessons = new FakeLessons(); + lessons.Add(lesson); + var tool = BuildLessonPlanTools(lessons: lessons); + + Assert.Throws(() => tool.DownloadLessonAttachment(lesson.Id, "big")); + } + // ── GroupMembershipTools ───────────────────────────────────────────────────────────────── [Fact] diff --git a/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs b/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs index 2ccfeed..1ea9ab8 100644 --- a/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs +++ b/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs @@ -8,7 +8,7 @@ using ModelContextProtocol.Server; namespace LehrerApp.Desktop.Services.Mcp; /// -/// In-Process-MCP-Server (Phase 1+2, siehe Planungsdokument). Lauscht auf der Named Pipe +/// In-Process-MCP-Server (Phase 1–3, siehe Planungsdokument). Lauscht auf der Named Pipe /// und bedient jede eingehende Verbindung (eine je /// LehrerApp.McpBridge-Instanz) als eigene MCP-Session über — /// ein ist ein normaler und kann direkt @@ -153,6 +153,8 @@ public sealed class McpServerHostedService : IAsyncDisposable "Listet eigene Zeiterfassungs-Einträge in einem Datumsbereich."); AddReadTool(lessonPlanTools.GetLessonPlans, "get_lesson_plans", "Listet Unterrichtseinheiten und -stunden einer Lerngruppe in einem Zeitraum."); + AddReadTool(lessonPlanTools.DownloadLessonAttachment, "download_lesson_attachment", + "Lädt den Inhalt eines an eine Einzelstunde angehängten Materials Base64-kodiert herunter."); AddWriteTool(timeEntryTools.CreateTimeEntry, "create_time_entry", "Schlägt einen neuen Zeiterfassungs-Eintrag vor (Bestätigung durch den Nutzer nötig)."); @@ -160,6 +162,20 @@ public sealed class McpServerHostedService : IAsyncDisposable "Schlägt eine neue Note für einen Schüler vor (Bestätigung durch den Nutzer nötig)."); AddWriteTool(groupMembershipTools.UpdateStudentGroupAssignment, "update_student_group_assignment", "Legt eine Gruppenmitgliedschaft an oder ändert Niveau/Zeitraum (Bestätigung durch den Nutzer nötig)."); + AddWriteTool(lessonPlanTools.CreateUnit, "create_unit", + "Legt eine neue Unterrichtseinheit an (Bestätigung durch den Nutzer nötig)."); + AddWriteTool(lessonPlanTools.UpdateUnit, "update_unit", + "Ändert Titel/Zeitraum/Status einer Unterrichtseinheit (Bestätigung durch den Nutzer nötig)."); + AddWriteTool(lessonPlanTools.CreateLesson, "create_lesson", + "Legt eine neue Einzelstunde ohne Verlaufsplan an (Bestätigung durch den Nutzer nötig)."); + AddWriteTool(lessonPlanTools.UpdateLesson, "update_lesson", + "Ändert Metadaten einer Einzelstunde, ohne den Verlaufsplan anzufassen (Bestätigung durch den Nutzer nötig)."); + AddWriteTool(lessonPlanTools.AddLessonPhase, "add_lesson_phase", + "Fügt einer Einzelstunde eine Verlaufsplan-Phase hinzu (Bestätigung durch den Nutzer nötig)."); + AddWriteTool(lessonPlanTools.UpdateLessonPhase, "update_lesson_phase", + "Ändert eine Verlaufsplan-Phase einer Einzelstunde (Bestätigung durch den Nutzer nötig)."); + AddWriteTool(lessonPlanTools.RemoveLessonPhase, "remove_lesson_phase", + "Entfernt eine Verlaufsplan-Phase aus einer Einzelstunde (Bestätigung durch den Nutzer nötig)."); System.Diagnostics.Debug.Assert( toolCollection.Select(t => t.ProtocolTool.Name).OrderBy(n => n) diff --git a/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs b/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs index cc67883..7db7614 100644 --- a/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs +++ b/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs @@ -18,14 +18,25 @@ public static class McpToolScope "get_schedule", "get_time_entries", "get_lesson_plans", + "download_lesson_attachment", ]; - /// Write-Tools (Phase 2) — jeder Aufruf läuft über , - /// bevor irgendetwas geschrieben wird (siehe die jeweilige Tool-Klasse). + /// Write-Tools (Phase 2+3) — jeder Aufruf läuft über , + /// bevor irgendetwas geschrieben wird (siehe die jeweilige Tool-Klasse). Absichtlich kleinteilig + /// bei Unit/Lesson (create/update_unit, create/update_lesson, add/update/remove_lesson_phase) + /// statt eines einzelnen "update_lesson" für die ganze Stunde inkl. Verlaufsplan — siehe + /// Begründung in LessonPlanTools. public static readonly IReadOnlyCollection AllowedWriteTools = [ "create_time_entry", "create_grade_entry", "update_student_group_assignment", + "create_unit", + "update_unit", + "create_lesson", + "update_lesson", + "add_lesson_phase", + "update_lesson_phase", + "remove_lesson_phase", ]; } diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs b/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs index 7136bec..b9cf75f 100644 --- a/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs +++ b/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs @@ -24,11 +24,20 @@ public record TimeEntryDto( Guid Id, Guid? TaskId, string Category, Guid? GroupId, DateOnly Date, TimeOnly? StartTime, TimeOnly? EndTime, int DurationMinutes, string? Description); -public record LessonPhaseDto(string Name, int DurationMinutes, string Activity, string Material, string Shorthand); +public record LessonPhaseDto(Guid Id, string Name, int DurationMinutes, string Activity, string Material, string Shorthand); + +public record LessonAttachmentDto(string StorageId, string FileName, long SizeBytes); public record LessonDto( Guid Id, Guid UnitId, Guid GroupId, DateOnly Date, int? LessonNumber, string Topic, - string? Homework, LessonStatus Status, List Phases); + string? Homework, LessonStatus Status, List Phases, + List Attachments); + +/// Ergebnis von "download_lesson_attachment": Inhalt Base64-kodiert, weil MCP-Tool-Antworten +/// als JSON/Text übertragen werden. Bewusst kein Ressourcen-URI-Mechanismus (siehe Planungsdokument) - +/// dafür müsste der Server MCP-Resources anbieten, was über den Rahmen dieses Tools hinausgeht; +/// stattdessen deckelt die Größe. +public record AttachmentContentDto(string FileName, long SizeBytes, string Base64Content); public record UnitDto( Guid Id, Guid GroupId, string Title, DateOnly? StartDate, DateOnly? EndDate, diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs index d841dc2..1c1e66b 100644 --- a/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs +++ b/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs @@ -1,12 +1,34 @@ using System.ComponentModel; +using System.Text; using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; namespace LehrerApp.Desktop.Services.Mcp.Tools; -/// MCP-Read-Tool "get_lesson_plans" (Phase 2, siehe Planungsdokument). Liefert -/// Unterrichtseinheiten einer Lerngruppe sowie deren Einzelstunden in einem Zeitraum. -public class LessonPlanTools(IUnitRepository units, ILessonRepository lessons) +/// MCP-Tools rund um Unterrichtseinheiten () und Einzelstunden +/// () — Phase 1 (get_lesson_plans) und Phase 3 (siehe Planungsdokument). +/// +/// Bewusst kleinteilig statt eines einzelnen "update_lesson", das die komplette Stunde inkl. +/// Verlaufsplan als ein großes JSON-Objekt tauscht: ist zwar technisch +/// eine eingebettete Liste im selben LiteDB-Dokument (keine echte Sub-Collection, also kein +/// Zeilen-Locking auf DB-Ebene) — kleinteilige Tools verkürzen aber das Zeitfenster zwischen Lesen +/// und Schreiben je Operation drastisch (ein Tool-Aufruf ändert nur eine Phase, nicht die ganze +/// Stunde) und liefern einen für den Bestätigungsdialog tatsächlich lesbaren Diff statt eines +/// kompletten Objekt-Dumps. Ein Tool, das die ganze Stunde überschreibt, ist bewusst NICHT +/// vorgesehen; wo es fehlt, ist die Kombination aus update_lesson (Metadaten) + +/// add/update/remove_lesson_phase (je eine Phase) der vorgesehene Weg. +public class LessonPlanTools( + IUnitRepository units, ILessonRepository lessons, IGroupRepository groups, + IAttachmentStorage attachments, IMcpConfirmationService confirmation) { + /// Deckelt die Antwortgröße von "download_lesson_attachment" (Base64 bläht ca. um + /// Faktor 1,33 auf). Kleiner als (App-weites + /// Limit), damit ein einzelner MCP-Tool-Aufruf nicht unnötig groß wird — siehe Planungsdokument + /// zum offenen Punkt "Ressourcen statt Inline-Base64 für große Dateien". + public const long MaxInlineAttachmentBytes = 3 * 1024 * 1024; + + // ── Lesen ──────────────────────────────────────────────────────────────────────────────── + [Description("Listet Unterrichtseinheiten und -stunden einer Lerngruppe; die Einzelstunden werden auf den angegebenen Zeitraum gefiltert.")] public LessonPlanResultDto GetLessonPlans( [Description("Lerngruppen-ID.")] Guid groupId, @@ -17,10 +39,227 @@ public class LessonPlanTools(IUnitRepository units, ILessonRepository lessons) .Select(u => new UnitDto(u.Id, u.GroupId, u.Title, u.StartDate, u.EndDate, u.Status, u.Competencies)) .ToList(); var lessonDtos = lessons.GetByGroupAndRange(groupId, from, to) - .Select(l => new LessonDto( - l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.Status, - l.Phases.Select(p => new LessonPhaseDto(p.Name, p.DurationMinutes, p.Activity, p.Material, p.Shorthand)).ToList())) + .Select(ToDto) .ToList(); return new LessonPlanResultDto(unitDtos, lessonDtos); } + + [Description("Lädt den Inhalt eines an eine Einzelstunde angehängten Materials (z.B. Arbeitsblatt) Base64-kodiert herunter. Für die storageId siehe get_lesson_plans.")] + public AttachmentContentDto DownloadLessonAttachment( + [Description("ID der Einzelstunde.")] Guid lessonId, + [Description("Speicher-ID des Anhangs, aus get_lesson_plans.")] string storageId) + { + var lesson = lessons.GetById(lessonId) ?? throw new InvalidOperationException("Unbekannte Stunden-ID."); + var attachment = lesson.Attachments.FirstOrDefault(a => a.StorageId == storageId) + ?? throw new InvalidOperationException("Kein Anhang mit dieser Speicher-ID an dieser Stunde."); + if (attachment.SizeBytes > MaxInlineAttachmentBytes) + throw new InvalidOperationException( + $"Anhang ist mit {attachment.SizeBytes / 1024 / 1024} MB zu groß für eine Inline-Antwort (Limit {MaxInlineAttachmentBytes / 1024 / 1024} MB)."); + + using var stream = attachments.OpenRead(storageId) + ?? throw new InvalidOperationException("Anhang-Inhalt nicht auffindbar (Speicher inkonsistent)."); + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + return new AttachmentContentDto(attachment.FileName, attachment.SizeBytes, Convert.ToBase64String(buffer.ToArray())); + } + + // ── Unterrichtseinheiten (Unit) ────────────────────────────────────────────────────────── + + [Description("Legt eine neue Unterrichtseinheit an. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen.")] + public async Task CreateUnit( + [Description("Lerngruppen-ID.")] Guid groupId, + [Description("Titel der Einheit.")] string title, + [Description("Optionales Startdatum, Format YYYY-MM-DD.")] DateOnly? startDate = null, + [Description("Optionales Enddatum, Format YYYY-MM-DD.")] DateOnly? endDate = null, + CancellationToken ct = default) + { + var group = groups.GetById(groupId); + if (group is null) return new WriteResultDto(false, null, "Unbekannte Gruppen-ID."); + + var message = $"Neue Unterrichtseinheit „{title}“ für {group.Name} anlegen?" + + (startDate is not null || endDate is not null + ? $"\nZeitraum: {startDate:dd.MM.yyyy} – {endDate:dd.MM.yyyy}" : ""); + if (!await confirmation.ConfirmAsync("Unterrichtseinheit anlegen?", message, ct)) + return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); + + var unit = new Unit { GroupId = groupId, Title = title, StartDate = startDate, EndDate = endDate }; + units.Save(unit); + return new WriteResultDto(true, unit.Id, "Unterrichtseinheit gespeichert."); + } + + [Description("Ändert Titel/Zeitraum/Status einer bestehenden Unterrichtseinheit. Nur angegebene Felder werden geändert.")] + public async Task UpdateUnit( + [Description("ID der Unterrichtseinheit.")] Guid unitId, + [Description("Neuer Titel. Unverändert lassen: weglassen.")] string? title = null, + [Description("Neues Startdatum, Format YYYY-MM-DD. Unverändert lassen: weglassen.")] DateOnly? startDate = null, + [Description("Neues Enddatum, Format YYYY-MM-DD. Unverändert lassen: weglassen.")] DateOnly? endDate = null, + [Description("Neuer Status: Planned, Active oder Completed. Unverändert lassen: weglassen.")] UnitStatus? status = null, + CancellationToken ct = default) + { + var unit = units.GetById(unitId); + if (unit is null) return new WriteResultDto(false, null, "Unbekannte Einheiten-ID."); + + var changes = new StringBuilder(); + if (title is not null && title != unit.Title) { changes.AppendLine($"Titel: „{unit.Title}“ → „{title}“"); unit.Title = title; } + if (startDate is not null && startDate != unit.StartDate) { changes.AppendLine($"Start: {unit.StartDate:dd.MM.yyyy} → {startDate:dd.MM.yyyy}"); unit.StartDate = startDate; } + if (endDate is not null && endDate != unit.EndDate) { changes.AppendLine($"Ende: {unit.EndDate:dd.MM.yyyy} → {endDate:dd.MM.yyyy}"); unit.EndDate = endDate; } + if (status is not null && status != unit.Status) { changes.AppendLine($"Status: {unit.Status} → {status}"); unit.Status = status.Value; } + + if (changes.Length == 0) + return new WriteResultDto(true, unit.Id, "Keine Änderung nötig."); + + if (!await confirmation.ConfirmAsync("Unterrichtseinheit ändern?", $"„{unit.Title}“\n{changes}".TrimEnd(), ct)) + return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); + + units.Save(unit); + return new WriteResultDto(true, unit.Id, "Unterrichtseinheit gespeichert."); + } + + // ── Einzelstunden (Lesson) — Metadaten ─────────────────────────────────────────────────── + + [Description("Legt eine neue Einzelstunde ohne Verlaufsplan-Phasen an. Phasen danach einzeln über add_lesson_phase hinzufügen. Muss der Nutzer erst bestätigen.")] + public async Task CreateLesson( + [Description("ID der übergeordneten Unterrichtseinheit.")] Guid unitId, + [Description("Datum, Format YYYY-MM-DD.")] DateOnly date, + [Description("Thema der Stunde.")] string topic, + [Description("Optionale Stundennummer im Tagesraster.")] int? lessonNumber = null, + [Description("Optionaler Stundenbeginn, Format HH:mm.")] TimeOnly? startTime = null, + CancellationToken ct = default) + { + var unit = units.GetById(unitId); + if (unit is null) return new WriteResultDto(false, null, "Unbekannte Einheiten-ID."); + + var message = $"Neue Stunde „{topic}“ am {date:dd.MM.yyyy} in Einheit „{unit.Title}“ anlegen?"; + if (!await confirmation.ConfirmAsync("Einzelstunde anlegen?", message, ct)) + return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); + + var lesson = new Lesson + { + UnitId = unitId, + GroupId = unit.GroupId, + Date = date, + Topic = topic, + LessonNumber = lessonNumber, + StartTime = startTime, + }; + lessons.Save(lesson); + return new WriteResultDto(true, lesson.Id, "Einzelstunde gespeichert."); + } + + [Description("Ändert Metadaten einer bestehenden Einzelstunde (Thema, Hausaufgabe, Status, Beginn, Stundennummer) — der Verlaufsplan (Phasen) bleibt unverändert. Nur angegebene Felder werden geändert.")] + public async Task UpdateLesson( + [Description("ID der Einzelstunde.")] Guid lessonId, + [Description("Neues Thema. Unverändert lassen: weglassen.")] string? topic = null, + [Description("Neue Hausaufgabe. Unverändert lassen: weglassen.")] string? homework = null, + [Description("Neuer Status: Planned, Conducted, Draft oder Ready. Unverändert lassen: weglassen.")] LessonStatus? status = null, + [Description("Neuer Stundenbeginn, Format HH:mm. Unverändert lassen: weglassen.")] TimeOnly? startTime = null, + [Description("Neue Stundennummer. Unverändert lassen: weglassen.")] int? lessonNumber = null, + CancellationToken ct = default) + { + var lesson = lessons.GetById(lessonId); + if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID."); + + var changes = new StringBuilder(); + if (topic is not null && topic != lesson.Topic) { changes.AppendLine($"Thema: „{lesson.Topic}“ → „{topic}“"); lesson.Topic = topic; } + if (homework is not null && homework != lesson.Homework) { changes.AppendLine($"Hausaufgabe: „{lesson.Homework}“ → „{homework}“"); lesson.Homework = homework; } + if (status is not null && status != lesson.Status) { changes.AppendLine($"Status: {lesson.Status} → {status}"); lesson.Status = status.Value; } + if (startTime is not null && startTime != lesson.StartTime) { changes.AppendLine($"Beginn: {lesson.StartTime:HH\\:mm} → {startTime:HH\\:mm}"); lesson.StartTime = startTime; } + if (lessonNumber is not null && lessonNumber != lesson.LessonNumber) { changes.AppendLine($"Nr.: {lesson.LessonNumber} → {lessonNumber}"); lesson.LessonNumber = lessonNumber; } + + if (changes.Length == 0) + return new WriteResultDto(true, lesson.Id, "Keine Änderung nötig."); + + if (!await confirmation.ConfirmAsync("Einzelstunde ändern?", $"„{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy}\n{changes}".TrimEnd(), ct)) + return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); + + lessons.Save(lesson); + return new WriteResultDto(true, lesson.Id, "Einzelstunde gespeichert."); + } + + // ── Einzelstunden (Lesson) — Verlaufsplan-Phasen ───────────────────────────────────────── + + [Description("Fügt einer Einzelstunde eine neue Verlaufsplan-Phase hinzu (ans Ende). Muss der Nutzer erst bestätigen.")] + public async Task AddLessonPhase( + [Description("ID der Einzelstunde.")] Guid lessonId, + [Description("Name der Phase, z.B. \"Einstieg\", \"Erarbeitung\".")] string name, + [Description("Dauer in Minuten.")] int durationMinutes, + [Description("Tätigkeit/Sozialform.")] string activity = "", + [Description("Material.")] string material = "", + [Description("Kurzsymbol, z.B. \"AB001->S\".")] string shorthand = "", + CancellationToken ct = default) + { + var lesson = lessons.GetById(lessonId); + if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID."); + + var message = $"Neue Phase „{name}“ ({durationMinutes} Min.) zu „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} hinzufügen?"; + if (!await confirmation.ConfirmAsync("Phase hinzufügen?", message, ct)) + return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); + + var phase = new LessonPhaseStep + { + Name = name, DurationMinutes = durationMinutes, Activity = activity, + Material = material, Shorthand = shorthand, + }; + lesson.Phases.Add(phase); + lessons.Save(lesson); + return new WriteResultDto(true, phase.Id, "Phase hinzugefügt."); + } + + [Description("Ändert eine bestehende Verlaufsplan-Phase einer Einzelstunde. Nur angegebene Felder werden geändert.")] + public async Task UpdateLessonPhase( + [Description("ID der Einzelstunde.")] Guid lessonId, + [Description("ID der Phase, aus get_lesson_plans.")] Guid phaseId, + [Description("Neuer Name. Unverändert lassen: weglassen.")] string? name = null, + [Description("Neue Dauer in Minuten. Unverändert lassen: weglassen.")] int? durationMinutes = null, + [Description("Neue Tätigkeit. Unverändert lassen: weglassen.")] string? activity = null, + [Description("Neues Material. Unverändert lassen: weglassen.")] string? material = null, + [Description("Neues Kurzsymbol. Unverändert lassen: weglassen.")] string? shorthand = null, + CancellationToken ct = default) + { + var lesson = lessons.GetById(lessonId); + if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID."); + var phase = lesson.Phases.FirstOrDefault(p => p.Id == phaseId); + if (phase is null) return new WriteResultDto(false, null, "Unbekannte Phasen-ID an dieser Stunde."); + + var changes = new StringBuilder(); + if (name is not null && name != phase.Name) { changes.AppendLine($"Name: „{phase.Name}“ → „{name}“"); phase.Name = name; } + if (durationMinutes is not null && durationMinutes != phase.DurationMinutes) { changes.AppendLine($"Dauer: {phase.DurationMinutes} → {durationMinutes} Min."); phase.DurationMinutes = durationMinutes.Value; } + if (activity is not null && activity != phase.Activity) { changes.AppendLine($"Tätigkeit: „{phase.Activity}“ → „{activity}“"); phase.Activity = activity; } + if (material is not null && material != phase.Material) { changes.AppendLine($"Material: „{phase.Material}“ → „{material}“"); phase.Material = material; } + if (shorthand is not null && shorthand != phase.Shorthand) { changes.AppendLine($"Kürzel: „{phase.Shorthand}“ → „{shorthand}“"); phase.Shorthand = shorthand; } + + if (changes.Length == 0) + return new WriteResultDto(true, phase.Id, "Keine Änderung nötig."); + + if (!await confirmation.ConfirmAsync("Phase ändern?", $"Phase „{phase.Name}“ in „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy}\n{changes}".TrimEnd(), ct)) + return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); + + lessons.Save(lesson); + return new WriteResultDto(true, phase.Id, "Phase gespeichert."); + } + + [Description("Entfernt eine Verlaufsplan-Phase aus einer Einzelstunde. Muss der Nutzer erst bestätigen.")] + public async Task RemoveLessonPhase( + [Description("ID der Einzelstunde.")] Guid lessonId, + [Description("ID der Phase, aus get_lesson_plans.")] Guid phaseId, + CancellationToken ct = default) + { + var lesson = lessons.GetById(lessonId); + if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID."); + var phase = lesson.Phases.FirstOrDefault(p => p.Id == phaseId); + if (phase is null) return new WriteResultDto(false, null, "Unbekannte Phasen-ID an dieser Stunde."); + + var message = $"Phase „{phase.Name}“ ({phase.DurationMinutes} Min.) aus „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} entfernen?"; + if (!await confirmation.ConfirmAsync("Phase entfernen?", message, ct)) + return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); + + lesson.Phases.Remove(phase); + lessons.Save(lesson); + return new WriteResultDto(true, phase.Id, "Phase entfernt."); + } + + private static LessonDto ToDto(Lesson l) => new( + l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.Status, + l.Phases.Select(p => new LessonPhaseDto(p.Id, p.Name, p.DurationMinutes, p.Activity, p.Material, p.Shorthand)).ToList(), + l.Attachments.Select(a => new LessonAttachmentDto(a.StorageId, a.FileName, a.SizeBytes)).ToList()); } diff --git a/TODO.md b/TODO.md index c1eb2cf..b275ca4 100644 --- a/TODO.md +++ b/TODO.md @@ -2557,6 +2557,38 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`: MCP-Client, der auf `build/LehrerApp.app/Contents/MacOS/LehrerApp.McpBridge` zeigt, gegen die laufende App testen. +- [x] **4.5.28** Lokaler MCP-Server, Phase 3 (kleinteilige Unit/Lesson-Tools), 2026-09-11: statt + eines einzelnen `update_lesson`, das die komplette Stunde inkl. Verlaufsplan als ein großes + JSON-Objekt tauscht, gezielt kleine Tools je Teil-Operation — auf Nutzervorschlag: `Lesson` + ist zwar technisch ein eingebettetes LiteDB-Dokument ohne Zeilen-Locking auf DB-Ebene, aber + kleinteilige Tools verkürzen das Lesen-Schreiben-Zeitfenster je Operation drastisch und + liefern einen für den Bestätigungsdialog tatsächlich lesbaren Diff statt eines + Objekt-Dumps. Ein Tool, das die ganze Stunde überschreibt, wurde bewusst NICHT gebaut. + - **Neue Tools:** `create_unit`/`update_unit` (Einheiten-Metadaten), `create_lesson` (ohne + Phasen; übernimmt `GroupId` automatisch von der übergeordneten `Unit`, kein eigener + `groupId`-Parameter — verhindert Inkonsistenz zwischen Lesson und Unit), + `update_lesson` (Metadaten, Phasen bleiben unangetastet), `add_lesson_phase`/ + `update_lesson_phase`/`remove_lesson_phase` (je eine `LessonPhaseStep`), sowie + `download_lesson_attachment` (Read-Tool, Base64, gedeckelt auf + `LessonPlanTools.MaxInlineAttachmentBytes` = 3 MB — größere Anhänge liefern einen klaren + Fehler statt einer aufgeblähten Antwort; echtes MCP-Resource-Streaming für große Dateien + bleibt ein offener Punkt, siehe Planungsdokument). + - **`ILessonRepository` um `GetById(Guid id)` ergänzt** (fehlte bisher komplett - + `GetByUnit`/`GetByGroupAndDate`/`GetByGroupAndRange` decken keinen Einzelabruf per ID ab). + Ohne diese Methode wäre kein einziges der neuen Lesson-Tools möglich gewesen, da sie alle + eine bestehende Stunde gezielt nachladen müssen. `LessonRepository.GetById` delegiert auf + das bereits intern (in `Delete`) genutzte `db.Lessons.FindById(id)`. + - `get_lesson_plans` liefert jetzt zusätzlich `Attachments` (Speicher-ID + Dateiname + + Größe) je Stunde und `Phases` inklusive `Id` je Phase — beides war vorher nicht + exponiert und ist Voraussetzung dafür, dass ein KI-Client eine Phase oder einen Anhang + gezielt referenzieren kann. + - `McpToolScope`/`McpServerHostedService` entsprechend erweitert (7 Read-, 10 Write-Tools + insgesamt), 14 neue Unit-Tests in + [McpToolsTests.cs](LehrerApp.Desktop.Tests/McpToolsTests.cs) (jetzt 28), decken u.a. ab: + dass `create_lesson` die Gruppe von der Einheit übernimmt statt einen eigenen Parameter zu + vertrauen, dass `update_lesson`/`update_lesson_phase` wirklich nur die angegebenen Felder + ändern, und dass ein zu großer Anhang beim Download einen Fehler statt einer Antwort liefert. + **Wichtige Abweichung von der ursprünglichen Planung (5.2):** Vor der Umsetzung zeigte sich, dass 5.2 wie ursprünglich beschrieben eine zweite, parallele Fehlzeiten-Erfassung neben dem bereits bestehenden Anwesenheits-Tracking aus Kapitel 3 (`ParticipationEntry.Attendance`,