diff --git a/LehrerApp.Desktop.Tests/McpToolsTests.cs b/LehrerApp.Desktop.Tests/McpToolsTests.cs index dbd19fc..26a139b 100644 --- a/LehrerApp.Desktop.Tests/McpToolsTests.cs +++ b/LehrerApp.Desktop.Tests/McpToolsTests.cs @@ -17,7 +17,8 @@ public sealed class McpToolsTests { "download_lesson_attachment", "get_competency_catalog", "get_competency_plan_versions", "get_exams", "get_grades", - "get_groups", "get_lesson_plans", "get_named_untis_absence_pattern", "get_schedule", + "get_groups", "get_lesson_plans", "get_named_untis_absence_pattern", + "get_participation_sessions", "get_schedule", "get_students", "get_subjects", "get_time_entries", "get_untis_absence_rows", "get_untis_hub_status", "list_letter_templates", "render_letter", }, @@ -31,10 +32,12 @@ public sealed class McpToolsTests new[] { "add_competency_item", "add_lesson_attachment", "add_lesson_competency", "add_lesson_phase", + "add_session_competency", "apply_untis_absence_status", "create_competency_domain", "create_competency_plan_version", "create_grade_entry", "create_lesson", "create_subject", "create_time_entry", "create_unit", "move_lesson", "remove_competency_item", - "remove_lesson_competency", "remove_lesson_phase", "update_competency_domain", + "remove_lesson_competency", "remove_lesson_phase", "remove_session_competency", + "update_competency_domain", "update_competency_item", "update_competency_plan_version", "update_group_competency_version", "update_lesson", "update_lesson_phase", "update_student_group_assignment", "update_subject", "update_unit", @@ -1026,4 +1029,91 @@ public sealed class McpToolsTests Assert.False(result.Applied); Assert.Equal(0, confirmation.CallCount); } + + // ── ParticipationTools ─────────────────────────────────────────────────────────────────── + + [Fact] + public void GetParticipationSessions_LiefertDatumKommentarUndKompetenzenDerGruppe() + { + var groupId = Guid.NewGuid(); + var item = new CompetencyItem { Code = "K1", Description = "Kompetenz" }; + var session = new ParticipationSession + { + GroupId = groupId, Date = new DateOnly(2026, 1, 12), Comment = "Sitzplan", + Competencies = [new CompetencyReference { ItemId = item.Id, Code = item.Code, Description = item.Description }], + }; + var sessions = new FakeSessions([session]); + var tool = new ParticipationTools(sessions, new FakeCompetencyDomains(), new FakeMcpConfirmation()); + + var result = Assert.Single(tool.GetParticipationSessions(groupId)); + + Assert.Equal(session.Id, result.Id); + Assert.Equal("Sitzplan", result.Comment); + var competency = Assert.Single(result.Competencies); + Assert.Equal(item.Id, competency.ItemId); + Assert.Equal("K1", competency.Code); + } + + [Fact] + public async Task AddSessionCompetency_NutzerBestaetigt_OrdnetKompetenzZu() + { + var session = new ParticipationSession { Date = new DateOnly(2026, 1, 12) }; + var sessions = new FakeSessions([session]); + var domains = new FakeCompetencyDomains(); + var item = new CompetencyItem { Code = "K1", Description = "Kompetenz" }; + domains.Add(new CompetencyDomain { Name = "Bereich", Items = [item] }); + var tool = new ParticipationTools(sessions, domains, new FakeMcpConfirmation()); + + var result = await tool.AddSessionCompetency(session.Id, item.Id); + + Assert.True(result.Applied); + var stored = Assert.Single(sessions.GetById(session.Id)!.Competencies); + Assert.Equal(item.Id, stored.ItemId); + } + + [Fact] + public async Task AddSessionCompetency_UnbekannteId_LiefertFehlerOhneNachfrage() + { + var session = new ParticipationSession { Date = new DateOnly(2026, 1, 12) }; + var sessions = new FakeSessions([session]); + var confirmation = new FakeMcpConfirmation(); + var tool = new ParticipationTools(sessions, new FakeCompetencyDomains(), confirmation); + + var result = await tool.AddSessionCompetency(session.Id, Guid.NewGuid()); + + Assert.False(result.Applied); + Assert.Equal(0, confirmation.CallCount); + } + + [Fact] + public async Task RemoveSessionCompetency_NutzerBestaetigt_EntferntKompetenz() + { + var item = new CompetencyItem { Code = "K1", Description = "Kompetenz" }; + var session = new ParticipationSession + { + Date = new DateOnly(2026, 1, 12), + Competencies = [new CompetencyReference { ItemId = item.Id, Code = item.Code, Description = item.Description }], + }; + var sessions = new FakeSessions([session]); + var tool = new ParticipationTools(sessions, new FakeCompetencyDomains(), new FakeMcpConfirmation()); + + var result = await tool.RemoveSessionCompetency(session.Id, item.Id); + + Assert.True(result.Applied); + Assert.Empty(sessions.GetById(session.Id)!.Competencies); + } + + [Fact] + public async Task RemoveSessionCompetency_NichtZugeordnet_LiefertFehlerOhneNachfrage() + { + var session = new ParticipationSession { Date = new DateOnly(2026, 1, 12) }; + var sessions = new FakeSessions([session]); + var confirmation = new FakeMcpConfirmation(); + var tool = new ParticipationTools(sessions, new FakeCompetencyDomains(), confirmation); + + var result = await tool.RemoveSessionCompetency(session.Id, Guid.NewGuid()); + + Assert.False(result.Applied); + Assert.Equal(0, confirmation.CallCount); + } } diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index fd209f3..7805dbc 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -233,6 +233,7 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs b/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs index 07b1724..578b1f6 100644 --- a/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs +++ b/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs @@ -32,13 +32,15 @@ public sealed class McpServerHostedService : IAsyncDisposable StudentTools studentTools, ExamTools examTools, GradeTools gradeTools, ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools, GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools, - CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools, GroupTools groupTools) + CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools, GroupTools groupTools, + ParticipationTools participationTools) { _settings = settings; _logger = logger; _serverOptions = BuildServerOptions( studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools, - groupMembershipTools, letterTemplateTools, competencyTools, untisComparisonTools, groupTools); + groupMembershipTools, letterTemplateTools, competencyTools, untisComparisonTools, groupTools, + participationTools); } /// Setzt die Pipe-Server-Accept-Loop auf, falls aktiviert. Ohne Wirkung, falls @@ -115,7 +117,8 @@ public sealed class McpServerHostedService : IAsyncDisposable StudentTools studentTools, ExamTools examTools, GradeTools gradeTools, ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools, GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools, - CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools, GroupTools groupTools) + CompetencyTools competencyTools, UntisComparisonTools untisComparisonTools, GroupTools groupTools, + ParticipationTools participationTools) { var toolCollection = new McpServerPrimitiveCollection(); @@ -187,6 +190,8 @@ public sealed class McpServerHostedService : IAsyncDisposable "Listet anonymisierte Fehlzeiten-Diskrepanzen einer Lerngruppe gegenüber WebUntis (keine Schülernamen, nur eine row-id je Zeile)."); AddReadTool(untisComparisonTools.GetNamedUntisAbsencePattern, "get_named_untis_absence_pattern", "Liefert Fehlzeiten MIT Schülername für explizit angegebene Schüler-IDs - Ausnahme von der sonstigen Anonymisierung, erfordert jedes Mal eine gesonderte Nutzerbestätigung."); + AddReadTool(participationTools.GetParticipationSessions, "get_participation_sessions", + "Listet Mitarbeit-Sitzungen einer Lerngruppe (Datum, Kommentar, zugeordnete Kompetenzen), ohne die einzelnen Schülerbewertungen."); AddWriteTool(timeEntryTools.CreateTimeEntry, "create_time_entry", "Schlägt einen neuen Zeiterfassungs-Eintrag vor (Bestätigung durch den Nutzer nötig)."); @@ -236,6 +241,10 @@ public sealed class McpServerHostedService : IAsyncDisposable "Ändert Name/Standardstatus einer Bildungsplan-Version (Bestätigung durch den Nutzer nötig)."); AddWriteTool(groupTools.UpdateGroupCompetencyVersion, "update_group_competency_version", "Setzt oder löscht die Bildungsplan-Version eines Kurses/einer Klasse (Bestätigung durch den Nutzer nötig)."); + AddWriteTool(participationTools.AddSessionCompetency, "add_session_competency", + "Ordnet einer Mitarbeit-Sitzung eine Kompetenz zu (Bestätigung durch den Nutzer nötig)."); + AddWriteTool(participationTools.RemoveSessionCompetency, "remove_session_competency", + "Entfernt eine Kompetenz von einer Mitarbeit-Sitzung (Bestätigung durch den Nutzer nötig)."); AddWriteTool(untisComparisonTools.ApplyUntisAbsenceStatus, "apply_untis_absence_status", "Übernimmt einen Statusvorschlag für eine über get_untis_absence_rows gelieferte row-id (Bestätigung durch den Nutzer nötig, nennt keinen Schülernamen)."); diff --git a/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs b/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs index 8b8964a..83a66f6 100644 --- a/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs +++ b/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs @@ -34,6 +34,7 @@ public static class McpToolScope "get_untis_hub_status", "get_untis_absence_rows", "get_named_untis_absence_pattern", + "get_participation_sessions", ]; /// Write-Tools (Phase 2+3) — jeder Aufruf läuft über , @@ -67,6 +68,8 @@ public static class McpToolScope "create_competency_plan_version", "update_competency_plan_version", "update_group_competency_version", + "add_session_competency", + "remove_session_competency", "apply_untis_absence_status", ]; diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs b/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs index c7e64d0..5691e98 100644 --- a/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs +++ b/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs @@ -39,6 +39,12 @@ public record LessonAttachmentDto(string StorageId, string FileName, long SizeBy /// Zusatzabfrage lesen kann, auch wenn das Katalog-Item inzwischen gelöscht wurde. public record CompetencyReferenceDto(Guid ItemId, string Code, string Description); +/// Mitarbeit-Sitzung (siehe ParticipationTools) - bewusst ohne die einzelnen +/// Schülerbewertungen (personenbezogen, nicht über MCP erreichbar, siehe Klassenkommentar). +public record ParticipationSessionDto( + Guid Id, Guid GroupId, DateOnly Date, Guid? LessonId, string? Comment, + List Competencies); + public record LessonDto( Guid Id, Guid UnitId, Guid GroupId, DateOnly Date, int? LessonNumber, string Topic, string? Homework, string? PlanningIdeas, LessonStatus Status, List Competencies, diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/ParticipationTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/ParticipationTools.cs new file mode 100644 index 0000000..04fd1bb --- /dev/null +++ b/LehrerApp.Desktop/Services/Mcp/Tools/ParticipationTools.cs @@ -0,0 +1,72 @@ +using System.ComponentModel; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; + +namespace LehrerApp.Desktop.Services.Mcp.Tools; + +/// MCP-Tools für Mitarbeit-Sitzungen () - Nutzer-Nachtrag. +/// Bewusst nur die Sitzung selbst (Datum, Kommentar, Kompetenzzuordnung), NICHT die einzelnen +/// Schülerbewertungen (/): das wären +/// personenbezogene Bewertungsdaten je Schüler, analog zu Documentation/Vorgang bewusst nicht über +/// MCP erreichbar (siehe McpToolScope). "add_session_competency"/"remove_session_competency" folgen +/// demselben Muster wie LessonPlanTools.AddLessonCompetency/RemoveLessonCompetency: einzelne +/// Kompetenz statt ganze Liste ersetzen, um kein "letzter Schreiber gewinnt" zu riskieren, und mit +/// Katalog-Validierung der übergebenen itemId. +public class ParticipationTools( + IParticipationSessionRepository sessions, ICompetencyDomainRepository competencyDomains, + IMcpConfirmationService confirmation) +{ + [Description("Listet Mitarbeit-Sitzungen einer Lerngruppe (Datum, Kommentar, zugeordnete Kompetenzen) - ohne die einzelnen Schülerbewertungen.")] + public List GetParticipationSessions( + [Description("Lerngruppen-ID, aus get_groups.")] Guid groupId) => + sessions.GetByGroup(groupId).Select(ToDto).ToList(); + + [Description("Ordnet einer Mitarbeit-Sitzung eine Kompetenz aus dem Katalog zu (siehe get_competency_catalog). Muss der Nutzer erst bestätigen.")] + public async Task AddSessionCompetency( + [Description("ID der Mitarbeit-Sitzung, aus get_participation_sessions.")] Guid sessionId, + [Description("ID der Einzelkompetenz, aus get_competency_catalog.")] Guid itemId, + CancellationToken ct = default) + { + var session = sessions.GetById(sessionId); + if (session is null) return new WriteResultDto(false, null, "Unbekannte Sitzungs-ID."); + var item = FindCompetencyItem(itemId); + if (item is null) return new WriteResultDto(false, null, "Unbekannte Kompetenz-ID."); + if (session.Competencies.Any(c => c.ItemId == itemId)) + return new WriteResultDto(true, session.Id, "Kompetenz ist bereits zugeordnet."); + + var message = $"Kompetenz „{item.Code}“ zur Mitarbeit-Sitzung vom {session.Date:dd.MM.yyyy} hinzufügen?"; + if (!await confirmation.ConfirmAsync("Kompetenz zuordnen?", message, ct)) + return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); + + session.Competencies.Add(new CompetencyReference { ItemId = item.Id, Code = item.Code, Description = item.Description }); + sessions.Save(session); + return new WriteResultDto(true, session.Id, "Kompetenz zugeordnet."); + } + + [Description("Entfernt eine Kompetenz von einer Mitarbeit-Sitzung. Muss der Nutzer erst bestätigen.")] + public async Task RemoveSessionCompetency( + [Description("ID der Mitarbeit-Sitzung.")] Guid sessionId, + [Description("ID der Einzelkompetenz, wie in get_participation_sessions hinterlegt.")] Guid itemId, + CancellationToken ct = default) + { + var session = sessions.GetById(sessionId); + if (session is null) return new WriteResultDto(false, null, "Unbekannte Sitzungs-ID."); + var existing = session.Competencies.FirstOrDefault(c => c.ItemId == itemId); + if (existing is null) return new WriteResultDto(false, null, "Diese Kompetenz ist dieser Sitzung nicht zugeordnet."); + + var message = $"Kompetenz „{existing.Code}“ von der Mitarbeit-Sitzung vom {session.Date:dd.MM.yyyy} entfernen?"; + if (!await confirmation.ConfirmAsync("Kompetenz entfernen?", message, ct)) + return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); + + session.Competencies.Remove(existing); + sessions.Save(session); + return new WriteResultDto(true, session.Id, "Kompetenz entfernt."); + } + + private CompetencyItem? FindCompetencyItem(Guid itemId) => + competencyDomains.GetAll().SelectMany(d => d.Items).FirstOrDefault(i => i.Id == itemId); + + private static ParticipationSessionDto ToDto(ParticipationSession s) => new( + s.Id, s.GroupId, s.Date, s.LessonId, s.Comment, + s.Competencies.Select(c => new CompetencyReferenceDto(c.ItemId, c.Code, c.Description)).ToList()); +} diff --git a/TODO.md b/TODO.md index 914939b..09a3eb8 100644 --- a/TODO.md +++ b/TODO.md @@ -3628,7 +3628,15 @@ alter und neuer Plan parallel, je nachdem, wann ein Kurs/eine Klasse eingeschult `update_group_competency_version` zum Setzen der Version eines Kurses. Bewusst nicht Teil dieses Nachtrags: TODO 16.5 (Kompetenz-Referenzen in Unit/Lesson/ExamTask von -Freitext-`Code` auf stabile `CompetencyItem.Id` umstellen) — unabhängiges, vorbestehendes Thema. +Freitext-`Code` auf stabile `CompetencyItem.Id` umstellen) — unabhängiges, vorbestehendes Thema, +mittlerweile eigenständig umgesetzt (siehe dort). + +- [x] **8.4.5** MCP-Nachtrag: neue `ParticipationTools`-Klasse exponiert Mitarbeit-Sitzungen + (`get_participation_sessions` — Datum, Kommentar, zugeordnete Kompetenzen, bewusst OHNE die + einzelnen Schülerbewertungen, die bleiben personenbezogen und MCP-unerreichbar) sowie + `add_session_competency`/`remove_session_competency` nach demselben Einzel-Zuordnung-statt- + Listen-Ersetzen-Muster wie `LessonPlanTools`. Vorher gab es für Mitarbeit-Sitzungen gar kein + MCP-Tool. ---