From 3f8813df51a51d5c568498773e3e9821ad21e7af Mon Sep 17 00:00:00 2001 From: Baddi86 Date: Sat, 12 Sep 2026 20:01:23 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20MCP-Tools=20f=C3=BCr=20Fach-/Kompetenzv?= =?UTF-8?q?erwaltung=20+=20Kompetenzzuordnung=20zu=20Lessons=20(Nutzer-Nac?= =?UTF-8?q?htrag)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue CompetencyTools.cs: create/update/delete_subject, create/update/delete_competency_domain, add/update/remove_competency_item, get_subjects, get_competency_catalog. delete_subject und delete_competency_domain als weitere Destructive-Ausnahmen neben delete_lesson (kein Papierkorb). Lesson bekommt ein neues Competencies-Feld (analog zu Unit.Competencies) sowie add_lesson_competency/remove_lesson_competency in LessonPlanTools.cs. ICompetencyDomainRepository um GetBySubject() erweitert, um den Katalog eines Fachs ohne bekannte Klassenstufe abzufragen. Co-Authored-By: Claude Sonnet 5 --- LehrerApp.Core/Interfaces/IRepositories.cs | 4 + LehrerApp.Core/Models/Planning.cs | 5 + .../Repositories/AllRepositories.cs | 5 + LehrerApp.Desktop.Tests/Fakes.cs | 18 +- LehrerApp.Desktop.Tests/McpToolsTests.cs | 254 +++++++++++++++++- LehrerApp.Desktop/AppBootstrapper.cs | 1 + .../Services/Mcp/McpServerHostedService.cs | 34 ++- .../Services/Mcp/McpToolScope.cs | 24 +- .../Services/Mcp/Tools/CompetencyTools.cs | 252 +++++++++++++++++ LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs | 10 +- .../Services/Mcp/Tools/LessonPlanTools.cs | 50 +++- .../CompetencyCatalogImportServiceTests.cs | 2 + TODO.md | 33 +++ 13 files changed, 671 insertions(+), 21 deletions(-) create mode 100644 LehrerApp.Desktop/Services/Mcp/Tools/CompetencyTools.cs diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index 2689e50..ea953f7 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -285,6 +285,10 @@ public interface ISubjectRepository public interface ICompetencyDomainRepository { List GetBySubjectAndGrade(Guid subjectId, int gradeLevel); + // Über alle Klassenstufen eines Fachs hinweg - für einen Überblick über den gesamten Katalog + // eines Fachs (z.B. MCP-Tool get_competency_catalog ohne gradeLevel-Filter), ohne dass der + // Aufrufer erst jede vorkommende Klassenstufe einzeln erraten/abfragen müsste. + List GetBySubject(Guid subjectId); CompetencyDomain? GetById(Guid id); void Save(CompetencyDomain domain); void Delete(Guid id); diff --git a/LehrerApp.Core/Models/Planning.cs b/LehrerApp.Core/Models/Planning.cs index b418758..38bdfab 100644 --- a/LehrerApp.Core/Models/Planning.cs +++ b/LehrerApp.Core/Models/Planning.cs @@ -63,6 +63,11 @@ public class Lesson : IHasAttachments /// wenn die Hausaufgabe absichtlich nicht mehr kontrolliert wird). public bool HomeworkCheckDismissed { get; set; } public string? Reflection { get; set; } + /// Kompetenzcodes () aus dem Katalog, die dieser Stunde als + /// Ganzes zugeordnet sind — analog zu , aber auf Stundenebene statt + /// Einheitenebene. Bewusst noch keine Verknüpfung je einzelner + /// (siehe TODO.md 4.5.8, weiterhin offen); diese Liste deckt nur die gröbere Zuordnung ab. + public List Competencies { get; set; } = []; public LessonStatus Status { get; set; } = LessonStatus.Planned; /// Material/Arbeitsblätter sowie fachspezifische Anhänge (z.B. Experiment- und /// Gefährdungsbeurteilungs-Dokumente im Chemieunterricht) — dieselbe Anhang-Infrastruktur wie diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 9427fc4..a61ee29 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -917,6 +917,11 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep .Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel) .OrderBy(d => d.SortOrder) .ToList(); + public List GetBySubject(Guid subjectId) => + db.CompetencyDomains + .Find(d => d.SubjectId == subjectId) + .OrderBy(d => d.GradeLevel).ThenBy(d => d.SortOrder) + .ToList(); public CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id); public void Save(CompetencyDomain d) { diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index 06d8dc9..7d6489e 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -373,9 +373,18 @@ public class FakeSubjects(List all) : ISubjectRepository { public List GetAll() => all; public Subject? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id); - public Subject? GetByName(string name) => all.FirstOrDefault(s => s.Name == name); - public void Save(Subject subject) { } - public void Delete(Guid id) { } + public Subject? GetByName(string name) => all.FirstOrDefault(s => + string.Equals(s.Name.Trim(), name.Trim(), StringComparison.OrdinalIgnoreCase)); + public void Save(Subject subject) + { + if (string.IsNullOrWhiteSpace(subject.Name)) throw new ArgumentException("Der Fachname darf nicht leer sein."); + var duplicate = GetByName(subject.Name); + if (duplicate is not null && duplicate.Id != subject.Id) + throw new InvalidOperationException("Ein Fach mit diesem Namen existiert bereits."); + all.RemoveAll(s => s.Id == subject.Id); + all.Add(subject); + } + public void Delete(Guid id) => all.RemoveAll(s => s.Id == id); } public class FakeCompetencyDomains : ICompetencyDomainRepository @@ -385,6 +394,9 @@ public class FakeCompetencyDomains : ICompetencyDomainRepository public List GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => _all.Where(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel) .OrderBy(d => d.SortOrder).ToList(); + public List GetBySubject(Guid subjectId) => + _all.Where(d => d.SubjectId == subjectId) + .OrderBy(d => d.GradeLevel).ThenBy(d => d.SortOrder).ToList(); public CompetencyDomain? GetById(Guid id) => _all.FirstOrDefault(d => d.Id == id); public void Save(CompetencyDomain domain) { _all.RemoveAll(d => d.Id == domain.Id); _all.Add(domain); } public void Delete(Guid id) => _all.RemoveAll(d => d.Id == id); diff --git a/LehrerApp.Desktop.Tests/McpToolsTests.cs b/LehrerApp.Desktop.Tests/McpToolsTests.cs index f8217f1..e65684d 100644 --- a/LehrerApp.Desktop.Tests/McpToolsTests.cs +++ b/LehrerApp.Desktop.Tests/McpToolsTests.cs @@ -15,9 +15,9 @@ public sealed class McpToolsTests Assert.Equal( new[] { - "download_lesson_attachment", "get_exams", "get_grades", "get_lesson_plans", - "get_schedule", "get_students", "get_time_entries", "list_letter_templates", - "render_letter", + "download_lesson_attachment", "get_competency_catalog", "get_exams", "get_grades", + "get_lesson_plans", "get_schedule", "get_students", "get_subjects", "get_time_entries", + "list_letter_templates", "render_letter", }, McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal)); } @@ -28,17 +28,22 @@ public sealed class McpToolsTests Assert.Equal( new[] { - "add_lesson_attachment", "add_lesson_phase", "create_grade_entry", "create_lesson", - "create_time_entry", "create_unit", "move_lesson", "remove_lesson_phase", - "update_lesson", "update_lesson_phase", "update_student_group_assignment", "update_unit", + "add_competency_item", "add_lesson_attachment", "add_lesson_competency", "add_lesson_phase", + "create_competency_domain", "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", + "update_competency_item", "update_lesson", "update_lesson_phase", "update_student_group_assignment", + "update_subject", "update_unit", }, McpToolScope.AllowedWriteTools.OrderBy(n => n, StringComparer.Ordinal)); } [Fact] - public void AllowedDestructiveWriteTools_EnthaeltNurDeleteLesson() + public void AllowedDestructiveWriteTools_EnthaeltDeleteLessonSubjectUndCompetencyDomain() { - Assert.Equal(["delete_lesson"], McpToolScope.AllowedDestructiveWriteTools); + Assert.Equal( + new[] { "delete_competency_domain", "delete_lesson", "delete_subject" }, + McpToolScope.AllowedDestructiveWriteTools.OrderBy(n => n, StringComparer.Ordinal)); } [Fact] @@ -639,4 +644,237 @@ public sealed class McpToolsTests Assert.False(result.Applied); Assert.Equal(0, confirmation.CallCount); } + + // ── LessonPlanTools — Kompetenzzuordnung ──────────────────────────────────────────────────── + + [Fact] + public async Task AddLessonCompetency_NutzerBestaetigt_OrdnetCodeZu() + { + var lesson = new Lesson { Topic = "Brechung" }; + var lessons = new FakeLessons(); + lessons.Add(lesson); + var tool = BuildLessonPlanTools(lessons: lessons); + + var result = await tool.AddLessonCompetency(lesson.Id, "PH.9.1"); + + Assert.True(result.Applied); + Assert.Contains("PH.9.1", lessons.GetById(lesson.Id)!.Competencies); + } + + [Fact] + public async Task AddLessonCompetency_BereitsZugeordnet_FragtNichtNochmalNach() + { + var lesson = new Lesson { Topic = "Brechung" }; + lesson.Competencies.Add("PH.9.1"); + var lessons = new FakeLessons(); + lessons.Add(lesson); + var confirmation = new FakeMcpConfirmation(); + var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation); + + var result = await tool.AddLessonCompetency(lesson.Id, "PH.9.1"); + + Assert.True(result.Applied); + Assert.Equal(0, confirmation.CallCount); + Assert.Single(lessons.GetById(lesson.Id)!.Competencies); + } + + [Fact] + public async Task RemoveLessonCompetency_NutzerBestaetigt_EntferntCode() + { + var lesson = new Lesson { Topic = "Brechung" }; + lesson.Competencies.Add("PH.9.1"); + var lessons = new FakeLessons(); + lessons.Add(lesson); + var tool = BuildLessonPlanTools(lessons: lessons); + + var result = await tool.RemoveLessonCompetency(lesson.Id, "PH.9.1"); + + Assert.True(result.Applied); + Assert.Empty(lessons.GetById(lesson.Id)!.Competencies); + } + + [Fact] + public async Task RemoveLessonCompetency_NichtZugeordnet_LiefertFehlerOhneNachfrage() + { + var lesson = new Lesson { Topic = "Brechung" }; + var lessons = new FakeLessons(); + lessons.Add(lesson); + var confirmation = new FakeMcpConfirmation(); + var tool = BuildLessonPlanTools(lessons: lessons, confirmation: confirmation); + + var result = await tool.RemoveLessonCompetency(lesson.Id, "PH.9.1"); + + Assert.False(result.Applied); + Assert.Equal(0, confirmation.CallCount); + } + + // ── CompetencyTools ────────────────────────────────────────────────────────────────────── + + [Fact] + public void GetSubjects_LiefertAlleFaecher() + { + var subject = new Subject { Name = "Mathematik", ShortName = "Ma" }; + var tool = new CompetencyTools(new FakeSubjects([subject]), new FakeCompetencyDomains(), new FakeMcpConfirmation()); + + var dto = Assert.Single(tool.GetSubjects()); + + Assert.Equal("Mathematik", dto.Name); + } + + [Fact] + public async Task CreateSubject_NutzerBestaetigt_SpeichertFach() + { + var subjects = new FakeSubjects([]); + var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation()); + + var result = await tool.CreateSubject("Mathematik", "Ma"); + + Assert.True(result.Applied); + var subject = Assert.Single(subjects.GetAll()); + Assert.Equal("Mathematik", subject.Name); + } + + [Fact] + public async Task CreateSubject_NameBereitsVergeben_LiefertFehler() + { + var subjects = new FakeSubjects([new Subject { Name = "Mathematik" }]); + var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation()); + + var result = await tool.CreateSubject("Mathematik"); + + Assert.False(result.Applied); + Assert.Single(subjects.GetAll()); + } + + [Fact] + public async Task UpdateSubject_AendertNurAngegebeneFelder() + { + var subject = new Subject { Name = "Mathematik", ShortName = "Ma" }; + var subjects = new FakeSubjects([subject]); + var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation()); + + var result = await tool.UpdateSubject(subject.Id, shortName: "M"); + + Assert.True(result.Applied); + var updated = subjects.GetById(subject.Id)!; + Assert.Equal("Mathematik", updated.Name); + Assert.Equal("M", updated.ShortName); + } + + [Fact] + public async Task DeleteSubject_NutzerBestaetigt_LoeschtFach() + { + var subject = new Subject { Name = "Mathematik" }; + var subjects = new FakeSubjects([subject]); + var tool = new CompetencyTools(subjects, new FakeCompetencyDomains(), new FakeMcpConfirmation()); + + var result = await tool.DeleteSubject(subject.Id); + + Assert.True(result.Applied); + Assert.Empty(subjects.GetAll()); + } + + [Fact] + public async Task DeleteSubject_UnbekannteId_LiefertFehlerOhneNachfrage() + { + var confirmation = new FakeMcpConfirmation(); + var tool = new CompetencyTools(new FakeSubjects([]), new FakeCompetencyDomains(), confirmation); + + var result = await tool.DeleteSubject(Guid.NewGuid()); + + Assert.False(result.Applied); + Assert.Equal(0, confirmation.CallCount); + } + + [Fact] + public void GetCompetencyCatalog_OhneKlassenstufe_LiefertAlleKlassenstufenDesFachs() + { + var subjectId = Guid.NewGuid(); + var domains = new FakeCompetencyDomains(); + domains.Add(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 5, Name = "Zahlen" }); + domains.Add(new CompetencyDomain { SubjectId = subjectId, GradeLevel = 9, Name = "Funktionen" }); + var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation()); + + var result = tool.GetCompetencyCatalog(subjectId); + + Assert.Equal(2, result.Count); + } + + [Fact] + public async Task CreateCompetencyDomain_NutzerBestaetigt_SpeichertBereich() + { + var subject = new Subject { Name = "Mathematik" }; + var domains = new FakeCompetencyDomains(); + var tool = new CompetencyTools(new FakeSubjects([subject]), domains, new FakeMcpConfirmation()); + + var result = await tool.CreateCompetencyDomain(subject.Id, 9, "Funktionen"); + + Assert.True(result.Applied); + var domain = Assert.Single(domains.GetBySubjectAndGrade(subject.Id, 9)); + Assert.Equal("Funktionen", domain.Name); + } + + [Fact] + public async Task DeleteCompetencyDomain_NutzerBestaetigt_LoeschtBereichMitItems() + { + var domain = new CompetencyDomain { Name = "Funktionen" }; + domain.Items.Add(new CompetencyItem { Code = "M.9.1", Description = "..." }); + var domains = new FakeCompetencyDomains(); + domains.Add(domain); + var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation()); + + var result = await tool.DeleteCompetencyDomain(domain.Id); + + Assert.True(result.Applied); + Assert.Null(domains.GetById(domain.Id)); + } + + [Fact] + public async Task AddCompetencyItem_NutzerBestaetigt_HaengtItemAn() + { + var domain = new CompetencyDomain { Name = "Funktionen" }; + var domains = new FakeCompetencyDomains(); + domains.Add(domain); + var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation()); + + var result = await tool.AddCompetencyItem(domain.Id, "M.9.1", "lineare Funktionen erkennen"); + + Assert.True(result.Applied); + var item = Assert.Single(domains.GetById(domain.Id)!.Items); + Assert.Equal("M.9.1", item.Code); + } + + [Fact] + public async Task UpdateCompetencyItem_AendertNurAngegebeneFelder() + { + var item = new CompetencyItem { Code = "M.9.1", Description = "alt" }; + var domain = new CompetencyDomain { Name = "Funktionen" }; + domain.Items.Add(item); + var domains = new FakeCompetencyDomains(); + domains.Add(domain); + var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation()); + + var result = await tool.UpdateCompetencyItem(domain.Id, item.Id, description: "neu"); + + Assert.True(result.Applied); + var updated = domains.GetById(domain.Id)!.Items.Single(); + Assert.Equal("M.9.1", updated.Code); + Assert.Equal("neu", updated.Description); + } + + [Fact] + public async Task RemoveCompetencyItem_NutzerBestaetigt_EntferntItem() + { + var item = new CompetencyItem { Code = "M.9.1", Description = "..." }; + var domain = new CompetencyDomain { Name = "Funktionen" }; + domain.Items.Add(item); + var domains = new FakeCompetencyDomains(); + domains.Add(domain); + var tool = new CompetencyTools(new FakeSubjects([]), domains, new FakeMcpConfirmation()); + + var result = await tool.RemoveCompetencyItem(domain.Id, item.Id); + + Assert.True(result.Applied); + Assert.Empty(domains.GetById(domain.Id)!.Items); + } } diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index d8464c8..34aa871 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -226,6 +226,7 @@ public static class AppBootstrapper 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 045cd1e..b639713 100644 --- a/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs +++ b/LehrerApp.Desktop/Services/Mcp/McpServerHostedService.cs @@ -31,13 +31,14 @@ public sealed class McpServerHostedService : IAsyncDisposable McpSettingsService settings, AppLogger logger, StudentTools studentTools, ExamTools examTools, GradeTools gradeTools, ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools, - GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools) + GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools, + CompetencyTools competencyTools) { _settings = settings; _logger = logger; _serverOptions = BuildServerOptions( studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools, - groupMembershipTools, letterTemplateTools); + groupMembershipTools, letterTemplateTools, competencyTools); } /// Setzt die Pipe-Server-Accept-Loop auf, falls aktiviert. Ohne Wirkung, falls @@ -113,7 +114,8 @@ public sealed class McpServerHostedService : IAsyncDisposable private static McpServerOptions BuildServerOptions( StudentTools studentTools, ExamTools examTools, GradeTools gradeTools, ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools, - GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools) + GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools, + CompetencyTools competencyTools) { var toolCollection = new McpServerPrimitiveCollection(); @@ -171,6 +173,10 @@ public sealed class McpServerHostedService : IAsyncDisposable "Listet importierte Elternbrief-Vorlagen mit ihren Platzhaltern."); AddReadTool(letterTemplateTools.RenderLetter, "render_letter", "Erzeugt einen Elternbrief aus einer Vorlage für einen Schüler als Base64-PDF."); + AddReadTool(competencyTools.GetSubjects, "get_subjects", + "Listet alle Fächer."); + AddReadTool(competencyTools.GetCompetencyCatalog, "get_competency_catalog", + "Listet den Kompetenzkatalog eines Fachs, optional gefiltert auf eine Klassenstufe."); AddWriteTool(timeEntryTools.CreateTimeEntry, "create_time_entry", "Schlägt einen neuen Zeiterfassungs-Eintrag vor (Bestätigung durch den Nutzer nötig)."); @@ -196,9 +202,31 @@ public sealed class McpServerHostedService : IAsyncDisposable "Fügt einer Einzelstunde ein neues Material als Base64-kodierten Anhang hinzu (Bestätigung durch den Nutzer nötig)."); AddWriteTool(lessonPlanTools.MoveLesson, "move_lesson", "Verschiebt eine Einzelstunde auf ein neues Datum, optional mit Mitverschieben späterer Stunden derselben Einheit (Bestätigung durch den Nutzer nötig)."); + AddWriteTool(lessonPlanTools.AddLessonCompetency, "add_lesson_competency", + "Ordnet einer Einzelstunde einen Kompetenzcode zu (Bestätigung durch den Nutzer nötig)."); + AddWriteTool(lessonPlanTools.RemoveLessonCompetency, "remove_lesson_competency", + "Entfernt einen Kompetenzcode von einer Einzelstunde (Bestätigung durch den Nutzer nötig)."); + AddWriteTool(competencyTools.CreateSubject, "create_subject", + "Legt ein neues Fach an (Bestätigung durch den Nutzer nötig)."); + AddWriteTool(competencyTools.UpdateSubject, "update_subject", + "Ändert Name/Kurzform eines Fachs (Bestätigung durch den Nutzer nötig)."); + AddWriteTool(competencyTools.CreateCompetencyDomain, "create_competency_domain", + "Legt einen neuen Kompetenzbereich für ein Fach/eine Klassenstufe an (Bestätigung durch den Nutzer nötig)."); + AddWriteTool(competencyTools.UpdateCompetencyDomain, "update_competency_domain", + "Ändert Name/Kürzel eines Kompetenzbereichs (Bestätigung durch den Nutzer nötig)."); + AddWriteTool(competencyTools.AddCompetencyItem, "add_competency_item", + "Fügt einem Kompetenzbereich eine Einzelkompetenz hinzu (Bestätigung durch den Nutzer nötig)."); + AddWriteTool(competencyTools.UpdateCompetencyItem, "update_competency_item", + "Ändert Code/Beschreibung einer Einzelkompetenz (Bestätigung durch den Nutzer nötig)."); + AddWriteTool(competencyTools.RemoveCompetencyItem, "remove_competency_item", + "Entfernt eine Einzelkompetenz aus einem Kompetenzbereich (Bestätigung durch den Nutzer nötig)."); AddDestructiveWriteTool(lessonPlanTools.DeleteLesson, "delete_lesson", "Löscht eine Einzelstunde endgültig, ohne Papierkorb (Bestätigung durch den Nutzer nötig)."); + AddDestructiveWriteTool(competencyTools.DeleteSubject, "delete_subject", + "Löscht ein Fach endgültig, ohne Papierkorb (Bestätigung durch den Nutzer nötig)."); + AddDestructiveWriteTool(competencyTools.DeleteCompetencyDomain, "delete_competency_domain", + "Löscht einen Kompetenzbereich endgültig samt Einzelkompetenzen, ohne Papierkorb (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 ddf1735..b866324 100644 --- a/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs +++ b/LehrerApp.Desktop/Services/Mcp/McpToolScope.cs @@ -21,6 +21,8 @@ public static class McpToolScope "download_lesson_attachment", "list_letter_templates", "render_letter", + "get_subjects", + "get_competency_catalog", ]; /// Write-Tools (Phase 2+3) — jeder Aufruf läuft über , @@ -42,15 +44,27 @@ public static class McpToolScope "remove_lesson_phase", "add_lesson_attachment", "move_lesson", + "add_lesson_competency", + "remove_lesson_competency", + "create_subject", + "update_subject", + "create_competency_domain", + "update_competency_domain", + "add_competency_item", + "update_competency_item", + "remove_competency_item", ]; - /// Löschende Write-Tools — eine bewusste, gezielte Ausnahme von der sonst geltenden - /// "v1 ohne Lösch-Tools"-Regel (siehe Planungsdokument), nicht deren Aufhebung. Getrennt von - /// aufgeführt, damit diese Ausnahme beim Lesen sofort auffällt. - /// registriert diese Tools zusätzlich mit - /// Destructive = true. + /// Löschende Write-Tools — ursprünglich eine bewusste, gezielte Ausnahme von der sonst + /// geltenden "v1 ohne Lösch-Tools"-Regel für "delete_lesson" (siehe Planungsdokument), inzwischen + /// (Nutzer-Nachtrag) um die endgültigen Löschungen für Fächer/Kompetenzbereiche erweitert — beide + /// ohne Papierkorb, wie delete_lesson. Getrennt von aufgeführt, + /// damit diese Ausnahme beim Lesen sofort auffällt. + /// registriert diese Tools zusätzlich mit Destructive = true. public static readonly IReadOnlyCollection AllowedDestructiveWriteTools = [ "delete_lesson", + "delete_subject", + "delete_competency_domain", ]; } diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/CompetencyTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/CompetencyTools.cs new file mode 100644 index 0000000..3f9f878 --- /dev/null +++ b/LehrerApp.Desktop/Services/Mcp/Tools/CompetencyTools.cs @@ -0,0 +1,252 @@ +using System.ComponentModel; +using System.Text; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; + +namespace LehrerApp.Desktop.Services.Mcp.Tools; + +/// MCP-Tools für die Fach- und Kompetenzkatalog-Verwaltung (Nutzer-Nachtrag zu 4.5.28ff.): +/// und / vollständig +/// anlegen/ändern/löschen. Die Zuordnung von Kompetenzcodes zu einzelnen Stunden lebt bewusst in +/// (AddLessonCompetency/RemoveLessonCompetency), nicht hier - sie +/// gehört fachlich zur Stundenverwaltung, nicht zum Katalog selbst. +/// +/// "delete_subject" und "delete_competency_domain" sind wie "delete_lesson" (LessonPlanTools) +/// endgültige Löschungen ohne Papierkorb (die Repository-Implementierungen löschen direkt in der +/// LiteDB-Collection statt über MoveToTrash) - deshalb als Destructive registriert (siehe +/// McpServerHostedService) und mit entsprechend deutlicher Bestätigungsnachricht. +public class CompetencyTools( + ISubjectRepository subjects, ICompetencyDomainRepository domains, IMcpConfirmationService confirmation) +{ + // ── Lesen ──────────────────────────────────────────────────────────────────────────────── + + [Description("Listet alle Fächer.")] + public List GetSubjects() => + subjects.GetAll().Select(s => new SubjectDto(s.Id, s.Name, s.ShortName)).ToList(); + + [Description("Listet den Kompetenzkatalog (Bereiche mit Einzelkompetenzen) eines Fachs, optional gefiltert auf eine Klassenstufe.")] + public List GetCompetencyCatalog( + [Description("Fach-ID, aus get_subjects.")] Guid subjectId, + [Description("Optionale Klassenstufe. Weglassen: alle Klassenstufen dieses Fachs.")] int? gradeLevel = null) + { + var result = gradeLevel is not null + ? domains.GetBySubjectAndGrade(subjectId, gradeLevel.Value) + : domains.GetBySubject(subjectId); + return result.Select(ToDto).ToList(); + } + + // ── Fächer (Subject) ───────────────────────────────────────────────────────────────────── + + [Description("Legt ein neues Fach an. Muss der Nutzer erst bestätigen.")] + public async Task CreateSubject( + [Description("Name des Fachs, z.B. \"Mathematik\".")] string name, + [Description("Kurzform, z.B. \"Ma\".")] string shortName = "", + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(name)) return new WriteResultDto(false, null, "Fachname darf nicht leer sein."); + + var message = $"Neues Fach „{name.Trim()}“" + + (string.IsNullOrWhiteSpace(shortName) ? "" : $" ({shortName.Trim()})") + " anlegen?"; + if (!await confirmation.ConfirmAsync("Fach anlegen?", message, ct)) + return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); + + var subject = new Subject { Name = name, ShortName = shortName }; + try { subjects.Save(subject); } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return new WriteResultDto(false, null, ex.Message); + } + return new WriteResultDto(true, subject.Id, "Fach gespeichert."); + } + + [Description("Ändert Name/Kurzform eines bestehenden Fachs. Nur angegebene Felder werden geändert.")] + public async Task UpdateSubject( + [Description("Fach-ID.")] Guid subjectId, + [Description("Neuer Name. Unverändert lassen: weglassen.")] string? name = null, + [Description("Neue Kurzform. Unverändert lassen: weglassen.")] string? shortName = null, + CancellationToken ct = default) + { + var subject = subjects.GetById(subjectId); + if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID."); + + var changes = new StringBuilder(); + if (name is not null && name != subject.Name) { changes.AppendLine($"Name: „{subject.Name}“ → „{name}“"); subject.Name = name; } + if (shortName is not null && shortName != subject.ShortName) { changes.AppendLine($"Kurzform: „{subject.ShortName}“ → „{shortName}“"); subject.ShortName = shortName; } + + if (changes.Length == 0) + return new WriteResultDto(true, subject.Id, "Keine Änderung nötig."); + + if (!await confirmation.ConfirmAsync("Fach ändern?", $"„{subject.Name}“\n{changes}".TrimEnd(), ct)) + return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); + + try { subjects.Save(subject); } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + return new WriteResultDto(false, null, ex.Message); + } + return new WriteResultDto(true, subject.Id, "Fach gespeichert."); + } + + [Description("Löscht ein Fach endgültig. Schlägt fehl, wenn noch eine Lerngruppe oder ein Kompetenzkatalog dieses Fach referenziert. Kein Papierkorb - nicht rückgängig zu machen. Muss der Nutzer erst bestätigen.")] + public async Task DeleteSubject( + [Description("Fach-ID.")] Guid subjectId, + CancellationToken ct = default) + { + var subject = subjects.GetById(subjectId); + if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID."); + + var message = $"Das Fach „{subject.Name}“ wird endgültig gelöscht. Das kann NICHT rückgängig gemacht werden (kein Papierkorb für Fächer)."; + if (!await confirmation.ConfirmAsync("Fach endgültig löschen?", message, ct)) + return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); + + try { subjects.Delete(subjectId); } + catch (InvalidOperationException ex) + { + return new WriteResultDto(false, null, ex.Message); + } + return new WriteResultDto(true, subjectId, "Fach gelöscht."); + } + + // ── Kompetenzbereiche (CompetencyDomain) ──────────────────────────────────────────────── + + [Description("Legt einen neuen Kompetenzbereich (z.B. \"Zahlen und Operationen\") für ein Fach und eine Klassenstufe an, zunächst ohne Einzelkompetenzen. Muss der Nutzer erst bestätigen.")] + public async Task CreateCompetencyDomain( + [Description("Fach-ID, aus get_subjects.")] Guid subjectId, + [Description("Klassenstufe.")] int gradeLevel, + [Description("Name des Bereichs.")] string name, + [Description("Optionales Kürzel, z.B. \"ZO\".")] string code = "", + CancellationToken ct = default) + { + var subject = subjects.GetById(subjectId); + if (subject is null) return new WriteResultDto(false, null, "Unbekannte Fach-ID."); + if (string.IsNullOrWhiteSpace(name)) return new WriteResultDto(false, null, "Bereichsname darf nicht leer sein."); + + var sortOrder = domains.GetBySubjectAndGrade(subjectId, gradeLevel).Count; + var message = $"Neuen Kompetenzbereich „{name.Trim()}“ für {subject.Name}, Klasse {gradeLevel}, anlegen?"; + if (!await confirmation.ConfirmAsync("Kompetenzbereich anlegen?", message, ct)) + return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); + + var domain = new CompetencyDomain + { + SubjectId = subjectId, GradeLevel = gradeLevel, Name = name, Code = code, SortOrder = sortOrder, + }; + domains.Save(domain); + return new WriteResultDto(true, domain.Id, "Kompetenzbereich gespeichert."); + } + + [Description("Ändert Name/Kürzel eines bestehenden Kompetenzbereichs. Nur angegebene Felder werden geändert.")] + public async Task UpdateCompetencyDomain( + [Description("ID des Kompetenzbereichs, aus get_competency_catalog.")] Guid domainId, + [Description("Neuer Name. Unverändert lassen: weglassen.")] string? name = null, + [Description("Neues Kürzel. Unverändert lassen: weglassen.")] string? code = null, + CancellationToken ct = default) + { + var domain = domains.GetById(domainId); + if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID."); + + var changes = new StringBuilder(); + if (name is not null && name != domain.Name) { changes.AppendLine($"Name: „{domain.Name}“ → „{name}“"); domain.Name = name; } + if (code is not null && code != domain.Code) { changes.AppendLine($"Kürzel: „{domain.Code}“ → „{code}“"); domain.Code = code; } + + if (changes.Length == 0) + return new WriteResultDto(true, domain.Id, "Keine Änderung nötig."); + + if (!await confirmation.ConfirmAsync("Kompetenzbereich ändern?", $"„{domain.Name}“\n{changes}".TrimEnd(), ct)) + return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); + + domains.Save(domain); + return new WriteResultDto(true, domain.Id, "Kompetenzbereich gespeichert."); + } + + [Description("Löscht einen Kompetenzbereich endgültig, inklusive aller darin enthaltenen Einzelkompetenzen. Bereits vergebene Kompetenzcodes an Einheiten/Stunden/Klausuraufgaben bleiben als Freitext bestehen, gelten aber danach als \"nicht im Katalog\". Kein Papierkorb - nicht rückgängig zu machen. Muss der Nutzer erst bestätigen.")] + public async Task DeleteCompetencyDomain( + [Description("ID des Kompetenzbereichs.")] Guid domainId, + CancellationToken ct = default) + { + var domain = domains.GetById(domainId); + if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID."); + + var message = $"Der Kompetenzbereich „{domain.Name}“ mit {domain.Items.Count} Einzelkompetenz(en) wird endgültig gelöscht. " + + "Das kann NICHT rückgängig gemacht werden (kein Papierkorb für Kompetenzbereiche)."; + if (!await confirmation.ConfirmAsync("Kompetenzbereich endgültig löschen?", message, ct)) + return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); + + domains.Delete(domainId); + return new WriteResultDto(true, domainId, "Kompetenzbereich gelöscht."); + } + + // ── Einzelkompetenzen (CompetencyItem) ────────────────────────────────────────────────── + + [Description("Fügt einem Kompetenzbereich eine neue Einzelkompetenz hinzu (ans Ende). Muss der Nutzer erst bestätigen.")] + public async Task AddCompetencyItem( + [Description("ID des Kompetenzbereichs, aus get_competency_catalog.")] Guid domainId, + [Description("Kompetenzcode, z.B. \"M.5.1\".")] string code, + [Description("Beschreibung der Kompetenz.")] string description, + CancellationToken ct = default) + { + var domain = domains.GetById(domainId); + if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID."); + if (string.IsNullOrWhiteSpace(description)) return new WriteResultDto(false, null, "Beschreibung darf nicht leer sein."); + + var message = $"Neue Kompetenz „{code}“ zu Bereich „{domain.Name}“ hinzufügen?\n{description}"; + if (!await confirmation.ConfirmAsync("Kompetenz hinzufügen?", message, ct)) + return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); + + var item = new CompetencyItem { Code = code.Trim(), Description = description.Trim(), SortOrder = domain.Items.Count }; + domain.Items.Add(item); + domains.Save(domain); + return new WriteResultDto(true, item.Id, "Kompetenz hinzugefügt."); + } + + [Description("Ändert Code/Beschreibung einer bestehenden Einzelkompetenz. Nur angegebene Felder werden geändert.")] + public async Task UpdateCompetencyItem( + [Description("ID des Kompetenzbereichs.")] Guid domainId, + [Description("ID der Einzelkompetenz, aus get_competency_catalog.")] Guid itemId, + [Description("Neuer Kompetenzcode. Unverändert lassen: weglassen.")] string? code = null, + [Description("Neue Beschreibung. Unverändert lassen: weglassen.")] string? description = null, + CancellationToken ct = default) + { + var domain = domains.GetById(domainId); + if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID."); + var item = domain.Items.FirstOrDefault(i => i.Id == itemId); + if (item is null) return new WriteResultDto(false, null, "Unbekannte Kompetenz-ID in diesem Bereich."); + + var changes = new StringBuilder(); + if (code is not null && code != item.Code) { changes.AppendLine($"Code: „{item.Code}“ → „{code}“"); item.Code = code; } + if (description is not null && description != item.Description) { changes.AppendLine($"Beschreibung: „{item.Description}“ → „{description}“"); item.Description = description; } + + if (changes.Length == 0) + return new WriteResultDto(true, item.Id, "Keine Änderung nötig."); + + if (!await confirmation.ConfirmAsync("Kompetenz ändern?", $"Kompetenz „{item.Code}“ in Bereich „{domain.Name}“\n{changes}".TrimEnd(), ct)) + return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); + + domains.Save(domain); + return new WriteResultDto(true, item.Id, "Kompetenz gespeichert."); + } + + [Description("Entfernt eine Einzelkompetenz aus einem Kompetenzbereich. Muss der Nutzer erst bestätigen.")] + public async Task RemoveCompetencyItem( + [Description("ID des Kompetenzbereichs.")] Guid domainId, + [Description("ID der Einzelkompetenz, aus get_competency_catalog.")] Guid itemId, + CancellationToken ct = default) + { + var domain = domains.GetById(domainId); + if (domain is null) return new WriteResultDto(false, null, "Unbekannte Bereichs-ID."); + var item = domain.Items.FirstOrDefault(i => i.Id == itemId); + if (item is null) return new WriteResultDto(false, null, "Unbekannte Kompetenz-ID in diesem Bereich."); + + var message = $"Kompetenz „{item.Code}“ ({item.Description}) aus Bereich „{domain.Name}“ entfernen?"; + if (!await confirmation.ConfirmAsync("Kompetenz entfernen?", message, ct)) + return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt."); + + domain.Items.Remove(item); + domains.Save(domain); + return new WriteResultDto(true, item.Id, "Kompetenz entfernt."); + } + + private static CompetencyDomainDto ToDto(CompetencyDomain d) => new( + d.Id, d.SubjectId, d.GradeLevel, d.Name, d.Code, d.SortOrder, + d.Items.OrderBy(i => i.SortOrder) + .Select(i => new CompetencyItemDto(i.Id, i.Code, i.Description, i.SortOrder)).ToList()); +} diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs b/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs index 97829fb..ff5673f 100644 --- a/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs +++ b/LehrerApp.Desktop/Services/Mcp/Tools/Dto.cs @@ -30,7 +30,7 @@ public record LessonAttachmentDto(string StorageId, string FileName, long SizeBy 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 Competencies, List Phases, List Attachments); /// Ergebnis von "download_lesson_attachment": Inhalt Base64-kodiert, weil MCP-Tool-Antworten @@ -45,6 +45,14 @@ public record UnitDto( public record LessonPlanResultDto(List Units, List Lessons); +public record SubjectDto(Guid Id, string Name, string ShortName); + +public record CompetencyItemDto(Guid Id, string Code, string Description, int SortOrder); + +public record CompetencyDomainDto( + Guid Id, Guid SubjectId, int GradeLevel, string Name, string Code, int SortOrder, + List Items); + public record GroupMembershipDto( Guid Id, Guid StudentId, Guid GroupId, MembershipPeriod Period, DateOnly? JoinedAt, DateOnly? LeftAt, Niveau? Niveau); diff --git a/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs b/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs index d971a15..d2ad82c 100644 --- a/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs +++ b/LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs @@ -300,6 +300,54 @@ public class LessonPlanTools( return new WriteResultDto(true, phase.Id, "Phase entfernt."); } + // ── Einzelstunden (Lesson) — Kompetenzzuordnung ────────────────────────────────────────── + // Auf Stundenebene (Lesson.Competencies), noch nicht je Phase - siehe TODO.md 4.5.8. Getrennte + // add/remove-Tools statt eines "set_lesson_competencies", das die ganze Liste ersetzt: dieselbe + // Race-Condition-Überlegung wie bei den Verlaufsplan-Phasen (add/update/remove_lesson_phase) - + // ein Tool-Aufruf ändert nur einen Code, kein "letzter Schreiber gewinnt" über die ganze Liste. + + [Description("Ordnet einer Einzelstunde einen Kompetenzcode aus dem Katalog zu (siehe get_competency_catalog). Muss der Nutzer erst bestätigen.")] + public async Task AddLessonCompetency( + [Description("ID der Einzelstunde.")] Guid lessonId, + [Description("Kompetenzcode, z.B. \"M.5.1\".")] string code, + CancellationToken ct = default) + { + var lesson = lessons.GetById(lessonId); + if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID."); + code = code.Trim(); + if (code.Length == 0) return new WriteResultDto(false, null, "Leerer Kompetenzcode."); + if (lesson.Competencies.Contains(code, StringComparer.OrdinalIgnoreCase)) + return new WriteResultDto(true, lesson.Id, "Kompetenzcode ist bereits zugeordnet."); + + var message = $"Kompetenz „{code}“ zu „{lesson.Topic}“ am {lesson.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."); + + lesson.Competencies.Add(code); + lessons.Save(lesson); + return new WriteResultDto(true, lesson.Id, "Kompetenz zugeordnet."); + } + + [Description("Entfernt einen Kompetenzcode von einer Einzelstunde. Muss der Nutzer erst bestätigen.")] + public async Task RemoveLessonCompetency( + [Description("ID der Einzelstunde.")] Guid lessonId, + [Description("Kompetenzcode, wie in get_lesson_plans hinterlegt.")] string code, + CancellationToken ct = default) + { + var lesson = lessons.GetById(lessonId); + if (lesson is null) return new WriteResultDto(false, null, "Unbekannte Stunden-ID."); + var existing = lesson.Competencies.FirstOrDefault(c => string.Equals(c, code, StringComparison.OrdinalIgnoreCase)); + if (existing is null) return new WriteResultDto(false, null, "Dieser Kompetenzcode ist dieser Stunde nicht zugeordnet."); + + var message = $"Kompetenz „{existing}“ von „{lesson.Topic}“ am {lesson.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."); + + lesson.Competencies.Remove(existing); + lessons.Save(lesson); + return new WriteResultDto(true, lesson.Id, "Kompetenz entfernt."); + } + // ── Einzelstunden (Lesson) — Verschieben & Löschen ─────────────────────────────────────── [Description("Verschiebt eine Einzelstunde auf ein neues Datum (und optional eine neue Stundennummer). Mit shiftFollowingLessons=true verschieben sich alle noch nicht durchgeführten, späteren Stunden derselben Einheit um denselben Tages-Versatz mit — so lässt sich eine Lücke für eine neue Stunde öffnen: diese Stunde auf den Termin der übernächsten verschieben (mit shiftFollowingLessons), dann create_lesson auf das dadurch freigewordene ursprüngliche Datum. Muss der Nutzer erst bestätigen.")] @@ -361,7 +409,7 @@ public class LessonPlanTools( } private static LessonDto ToDto(Lesson l) => new( - l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.Status, + l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.Status, l.Competencies, 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/LehrerApp.Tests/CompetencyCatalogImportServiceTests.cs b/LehrerApp.Tests/CompetencyCatalogImportServiceTests.cs index 39e0341..f12c287 100644 --- a/LehrerApp.Tests/CompetencyCatalogImportServiceTests.cs +++ b/LehrerApp.Tests/CompetencyCatalogImportServiceTests.cs @@ -181,6 +181,8 @@ public sealed class CompetencyCatalogImportServiceTests public List GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => Current.Where(x => x.SubjectId == subjectId && x.GradeLevel == gradeLevel).ToList(); + public List GetBySubject(Guid subjectId) => + Current.Where(x => x.SubjectId == subjectId).ToList(); public CompetencyDomain? GetById(Guid id) => Current.FirstOrDefault(x => x.Id == id); public void Save(CompetencyDomain domain) => Current.Add(domain); diff --git a/TODO.md b/TODO.md index 4b63742..c0e6129 100644 --- a/TODO.md +++ b/TODO.md @@ -2723,6 +2723,39 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`: [McpToolsTests.cs](LehrerApp.Desktop.Tests/McpToolsTests.cs) (belegt den `[CallerMemberName]`-Mechanismus ohne echtes UI). +- [x] **4.5.33** Fach-/Kompetenzkatalog-Verwaltung + Kompetenzzuordnung zu Einzelstunden über MCP + (2026-09-12, Nutzer-Nachtrag): Nutzer möchte einem KI-Agenten die volle Verwaltung der + Fächer (`Subject`) und des Kompetenzkatalogs (`CompetencyDomain`/`CompetencyItem`, siehe + Kompetenzen-Tab in den Einstellungen, Kapitel 8) überlassen — anlegen, ändern, löschen, "einfach + alles was dazugehört" — sowie das Zuordnen von Kompetenzcodes zu einzelnen Stunden. + - Neue [CompetencyTools.cs](LehrerApp.Desktop/Services/Mcp/Tools/CompetencyTools.cs): + `get_subjects`/`get_competency_catalog` (Read), `create_subject`/`update_subject`/ + `create_competency_domain`/`update_competency_domain`/`add_competency_item`/ + `update_competency_item`/`remove_competency_item` (Write, je mit Bestätigung), sowie + `delete_subject`/`delete_competency_domain` als **weitere, gezielte Ausnahmen** von der + "v1 ohne Lösch-Tools"-Regel neben `delete_lesson` (4.5.31) — beide Repositories löschen + endgültig ohne Papierkorb, deshalb `Destructive = true` und Aufnahme in + `McpToolScope.AllowedDestructiveWriteTools` statt der normalen Write-Liste. + `delete_subject` nutzt die bereits bestehende Verwendungsprüfung in + `SubjectRepository.Delete` (schlägt fehl, wenn noch eine Lerngruppe oder ein + Kompetenzkatalog das Fach referenziert) — keine neue Validierung nötig. + - `ICompetencyDomainRepository` um `GetBySubject(Guid)` erweitert (alle Klassenstufen eines + Fachs), da bisher jede Katalog-Abfrage im gesamten Code eine bekannte `gradeLevel` verlangte + (`GetBySubjectAndGrade`) — für `get_competency_catalog` ohne Klassenstufen-Filter fehlte + diese Möglichkeit. `CompetencyDomainRepository`/`FakeCompetencyDomains` entsprechend ergänzt. + - **Kompetenzzuordnung zu Lessons:** `Lesson` bekommt ein neues Feld `Competencies` + (`List`, analog zu `Unit.Competencies`) sowie zwei neue Tools in + [LessonPlanTools.cs](LehrerApp.Desktop/Services/Mcp/Tools/LessonPlanTools.cs): + `add_lesson_competency`/`remove_lesson_competency`. Bewusst granular statt eines + "set_lesson_competencies", das die ganze Liste ersetzt — dieselbe Race-Condition-Überlegung + wie bei den Verlaufsplan-Phasen (4.5.28). Bewusst auf **Stundenebene**, nicht je Phase — die + in TODO.md 4.5.8 beschriebene feinere Zuordnung je Verlaufsplan-Phase bleibt offen und ist + von dieser Änderung unabhängig; `Lesson.Competencies` ist ein eigenständiges, gröberes Feld. + - `FakeSubjects` (Tests) von No-Op-Stub auf echtes In-Memory-CRUD (inkl. Namens-Dubletten-Prüfung + analog zu `SubjectRepository.Save`) umgestellt, da bisher kein Test tatsächlich über MCP + schrieb und ein reiner No-Op genügte. + - 16 neue Tests in [McpToolsTests.cs](LehrerApp.Desktop.Tests/McpToolsTests.cs) (jetzt 57). + **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`,