feat: MCP-Tools für Fach-/Kompetenzverwaltung + Kompetenzzuordnung zu Lessons (Nutzer-Nachtrag)

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 20:01:23 +02:00
co-authored by Claude Sonnet 5
parent 19aa302487
commit 3f8813df51
13 changed files with 671 additions and 21 deletions
@@ -285,6 +285,10 @@ public interface ISubjectRepository
public interface ICompetencyDomainRepository public interface ICompetencyDomainRepository
{ {
List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel); List<CompetencyDomain> 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<CompetencyDomain> GetBySubject(Guid subjectId);
CompetencyDomain? GetById(Guid id); CompetencyDomain? GetById(Guid id);
void Save(CompetencyDomain domain); void Save(CompetencyDomain domain);
void Delete(Guid id); void Delete(Guid id);
+5
View File
@@ -63,6 +63,11 @@ public class Lesson : IHasAttachments
/// wenn die Hausaufgabe absichtlich nicht mehr kontrolliert wird). /// wenn die Hausaufgabe absichtlich nicht mehr kontrolliert wird).
public bool HomeworkCheckDismissed { get; set; } public bool HomeworkCheckDismissed { get; set; }
public string? Reflection { get; set; } public string? Reflection { get; set; }
/// Kompetenzcodes (<see cref="CompetencyItem.Code"/>) aus dem Katalog, die dieser Stunde als
/// Ganzes zugeordnet sind — analog zu <see cref="Unit.Competencies"/>, aber auf Stundenebene statt
/// Einheitenebene. Bewusst noch keine Verknüpfung je einzelner <see cref="LessonPhaseStep"/>
/// (siehe TODO.md 4.5.8, weiterhin offen); diese Liste deckt nur die gröbere Zuordnung ab.
public List<string> Competencies { get; set; } = [];
public LessonStatus Status { get; set; } = LessonStatus.Planned; public LessonStatus Status { get; set; } = LessonStatus.Planned;
/// Material/Arbeitsblätter sowie fachspezifische Anhänge (z.B. Experiment- und /// Material/Arbeitsblätter sowie fachspezifische Anhänge (z.B. Experiment- und
/// Gefährdungsbeurteilungs-Dokumente im Chemieunterricht) — dieselbe Anhang-Infrastruktur wie /// Gefährdungsbeurteilungs-Dokumente im Chemieunterricht) — dieselbe Anhang-Infrastruktur wie
@@ -917,6 +917,11 @@ public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRep
.Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel) .Find(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
.OrderBy(d => d.SortOrder) .OrderBy(d => d.SortOrder)
.ToList(); .ToList();
public List<CompetencyDomain> 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 CompetencyDomain? GetById(Guid id) => db.CompetencyDomains.FindById(id);
public void Save(CompetencyDomain d) public void Save(CompetencyDomain d)
{ {
+15 -3
View File
@@ -373,9 +373,18 @@ public class FakeSubjects(List<Subject> all) : ISubjectRepository
{ {
public List<Subject> GetAll() => all; public List<Subject> GetAll() => all;
public Subject? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id); public Subject? GetById(Guid id) => all.FirstOrDefault(s => s.Id == id);
public Subject? GetByName(string name) => all.FirstOrDefault(s => s.Name == name); public Subject? GetByName(string name) => all.FirstOrDefault(s =>
public void Save(Subject subject) { } string.Equals(s.Name.Trim(), name.Trim(), StringComparison.OrdinalIgnoreCase));
public void Delete(Guid id) { } 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 public class FakeCompetencyDomains : ICompetencyDomainRepository
@@ -385,6 +394,9 @@ public class FakeCompetencyDomains : ICompetencyDomainRepository
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
_all.Where(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel) _all.Where(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
.OrderBy(d => d.SortOrder).ToList(); .OrderBy(d => d.SortOrder).ToList();
public List<CompetencyDomain> 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 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 Save(CompetencyDomain domain) { _all.RemoveAll(d => d.Id == domain.Id); _all.Add(domain); }
public void Delete(Guid id) => _all.RemoveAll(d => d.Id == id); public void Delete(Guid id) => _all.RemoveAll(d => d.Id == id);
+246 -8
View File
@@ -15,9 +15,9 @@ public sealed class McpToolsTests
Assert.Equal( Assert.Equal(
new[] new[]
{ {
"download_lesson_attachment", "get_exams", "get_grades", "get_lesson_plans", "download_lesson_attachment", "get_competency_catalog", "get_exams", "get_grades",
"get_schedule", "get_students", "get_time_entries", "list_letter_templates", "get_lesson_plans", "get_schedule", "get_students", "get_subjects", "get_time_entries",
"render_letter", "list_letter_templates", "render_letter",
}, },
McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal)); McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal));
} }
@@ -28,17 +28,22 @@ public sealed class McpToolsTests
Assert.Equal( Assert.Equal(
new[] new[]
{ {
"add_lesson_attachment", "add_lesson_phase", "create_grade_entry", "create_lesson", "add_competency_item", "add_lesson_attachment", "add_lesson_competency", "add_lesson_phase",
"create_time_entry", "create_unit", "move_lesson", "remove_lesson_phase", "create_competency_domain", "create_grade_entry", "create_lesson", "create_subject",
"update_lesson", "update_lesson_phase", "update_student_group_assignment", "update_unit", "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)); McpToolScope.AllowedWriteTools.OrderBy(n => n, StringComparer.Ordinal));
} }
[Fact] [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] [Fact]
@@ -639,4 +644,237 @@ public sealed class McpToolsTests
Assert.False(result.Applied); Assert.False(result.Applied);
Assert.Equal(0, confirmation.CallCount); 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);
}
} }
+1
View File
@@ -226,6 +226,7 @@ public static class AppBootstrapper
services.AddSingleton<LessonPlanTools>(); services.AddSingleton<LessonPlanTools>();
services.AddSingleton<GroupMembershipTools>(); services.AddSingleton<GroupMembershipTools>();
services.AddSingleton<LetterTemplateTools>(); services.AddSingleton<LetterTemplateTools>();
services.AddSingleton<CompetencyTools>();
services.AddSingleton<McpServerHostedService>(); services.AddSingleton<McpServerHostedService>();
services.AddSingleton<McpClientRegistrationService>(); services.AddSingleton<McpClientRegistrationService>();
@@ -31,13 +31,14 @@ public sealed class McpServerHostedService : IAsyncDisposable
McpSettingsService settings, AppLogger logger, McpSettingsService settings, AppLogger logger,
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools, StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools, ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools) GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools,
CompetencyTools competencyTools)
{ {
_settings = settings; _settings = settings;
_logger = logger; _logger = logger;
_serverOptions = BuildServerOptions( _serverOptions = BuildServerOptions(
studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools, studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools,
groupMembershipTools, letterTemplateTools); groupMembershipTools, letterTemplateTools, competencyTools);
} }
/// <summary>Setzt die Pipe-Server-Accept-Loop auf, falls aktiviert. Ohne Wirkung, falls /// <summary>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( private static McpServerOptions BuildServerOptions(
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools, StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools, ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools) GroupMembershipTools groupMembershipTools, LetterTemplateTools letterTemplateTools,
CompetencyTools competencyTools)
{ {
var toolCollection = new McpServerPrimitiveCollection<McpServerTool>(); var toolCollection = new McpServerPrimitiveCollection<McpServerTool>();
@@ -171,6 +173,10 @@ public sealed class McpServerHostedService : IAsyncDisposable
"Listet importierte Elternbrief-Vorlagen mit ihren Platzhaltern."); "Listet importierte Elternbrief-Vorlagen mit ihren Platzhaltern.");
AddReadTool(letterTemplateTools.RenderLetter, "render_letter", AddReadTool(letterTemplateTools.RenderLetter, "render_letter",
"Erzeugt einen Elternbrief aus einer Vorlage für einen Schüler als Base64-PDF."); "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", AddWriteTool(timeEntryTools.CreateTimeEntry, "create_time_entry",
"Schlägt einen neuen Zeiterfassungs-Eintrag vor (Bestätigung durch den Nutzer nötig)."); "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)."); "Fügt einer Einzelstunde ein neues Material als Base64-kodierten Anhang hinzu (Bestätigung durch den Nutzer nötig).");
AddWriteTool(lessonPlanTools.MoveLesson, "move_lesson", 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)."); "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", AddDestructiveWriteTool(lessonPlanTools.DeleteLesson, "delete_lesson",
"Löscht eine Einzelstunde endgültig, ohne Papierkorb (Bestätigung durch den Nutzer nötig)."); "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( System.Diagnostics.Debug.Assert(
toolCollection.Select(t => t.ProtocolTool.Name).OrderBy(n => n) toolCollection.Select(t => t.ProtocolTool.Name).OrderBy(n => n)
+19 -5
View File
@@ -21,6 +21,8 @@ public static class McpToolScope
"download_lesson_attachment", "download_lesson_attachment",
"list_letter_templates", "list_letter_templates",
"render_letter", "render_letter",
"get_subjects",
"get_competency_catalog",
]; ];
/// <summary>Write-Tools (Phase 2+3) — jeder Aufruf läuft über <see cref="IMcpConfirmationService"/>, /// <summary>Write-Tools (Phase 2+3) — jeder Aufruf läuft über <see cref="IMcpConfirmationService"/>,
@@ -42,15 +44,27 @@ public static class McpToolScope
"remove_lesson_phase", "remove_lesson_phase",
"add_lesson_attachment", "add_lesson_attachment",
"move_lesson", "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",
]; ];
/// <summary>Löschende Write-Tools — eine bewusste, gezielte Ausnahme von der sonst geltenden /// <summary>Löschende Write-Tools — ursprünglich eine bewusste, gezielte Ausnahme von der sonst
/// "v1 ohne Lösch-Tools"-Regel (siehe Planungsdokument), nicht deren Aufhebung. Getrennt von /// geltenden "v1 ohne Lösch-Tools"-Regel für "delete_lesson" (siehe Planungsdokument), inzwischen
/// <see cref="AllowedWriteTools"/> aufgeführt, damit diese Ausnahme beim Lesen sofort auffällt. /// (Nutzer-Nachtrag) um die endgültigen Löschungen für Fächer/Kompetenzbereiche erweitert — beide
/// <see cref="McpServerHostedService"/> registriert diese Tools zusätzlich mit /// ohne Papierkorb, wie delete_lesson. Getrennt von <see cref="AllowedWriteTools"/> aufgeführt,
/// <c>Destructive = true</c>.</summary> /// damit diese Ausnahme beim Lesen sofort auffällt. <see cref="McpServerHostedService"/>
/// registriert diese Tools zusätzlich mit <c>Destructive = true</c>.</summary>
public static readonly IReadOnlyCollection<string> AllowedDestructiveWriteTools = public static readonly IReadOnlyCollection<string> AllowedDestructiveWriteTools =
[ [
"delete_lesson", "delete_lesson",
"delete_subject",
"delete_competency_domain",
]; ];
} }
@@ -0,0 +1,252 @@
using System.ComponentModel;
using System.Text;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>MCP-Tools für die Fach- und Kompetenzkatalog-Verwaltung (Nutzer-Nachtrag zu 4.5.28ff.):
/// <see cref="Subject"/> und <see cref="CompetencyDomain"/>/<see cref="CompetencyItem"/> vollständig
/// anlegen/ändern/löschen. Die Zuordnung von Kompetenzcodes zu einzelnen Stunden lebt bewusst in
/// <see cref="LessonPlanTools"/> (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.</summary>
public class CompetencyTools(
ISubjectRepository subjects, ICompetencyDomainRepository domains, IMcpConfirmationService confirmation)
{
// ── Lesen ────────────────────────────────────────────────────────────────────────────────
[Description("Listet alle Fächer.")]
public List<SubjectDto> 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<CompetencyDomainDto> 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<WriteResultDto> 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<WriteResultDto> 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<WriteResultDto> 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<WriteResultDto> 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<WriteResultDto> 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<WriteResultDto> 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<WriteResultDto> 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<WriteResultDto> 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<WriteResultDto> 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());
}
+9 -1
View File
@@ -30,7 +30,7 @@ public record LessonAttachmentDto(string StorageId, string FileName, long SizeBy
public record LessonDto( public record LessonDto(
Guid Id, Guid UnitId, Guid GroupId, DateOnly Date, int? LessonNumber, string Topic, Guid Id, Guid UnitId, Guid GroupId, DateOnly Date, int? LessonNumber, string Topic,
string? Homework, LessonStatus Status, List<LessonPhaseDto> Phases, string? Homework, LessonStatus Status, List<string> Competencies, List<LessonPhaseDto> Phases,
List<LessonAttachmentDto> Attachments); List<LessonAttachmentDto> Attachments);
/// <summary>Ergebnis von "download_lesson_attachment": Inhalt Base64-kodiert, weil MCP-Tool-Antworten /// <summary>Ergebnis von "download_lesson_attachment": Inhalt Base64-kodiert, weil MCP-Tool-Antworten
@@ -45,6 +45,14 @@ public record UnitDto(
public record LessonPlanResultDto(List<UnitDto> Units, List<LessonDto> Lessons); public record LessonPlanResultDto(List<UnitDto> Units, List<LessonDto> 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<CompetencyItemDto> Items);
public record GroupMembershipDto( public record GroupMembershipDto(
Guid Id, Guid StudentId, Guid GroupId, MembershipPeriod Period, Guid Id, Guid StudentId, Guid GroupId, MembershipPeriod Period,
DateOnly? JoinedAt, DateOnly? LeftAt, Niveau? Niveau); DateOnly? JoinedAt, DateOnly? LeftAt, Niveau? Niveau);
@@ -300,6 +300,54 @@ public class LessonPlanTools(
return new WriteResultDto(true, phase.Id, "Phase entfernt."); 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<WriteResultDto> 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<WriteResultDto> 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 ─────────────────────────────────────── // ── 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.")] [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( 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.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()); l.Attachments.Select(a => new LessonAttachmentDto(a.StorageId, a.FileName, a.SizeBytes)).ToList());
} }
@@ -181,6 +181,8 @@ public sealed class CompetencyCatalogImportServiceTests
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
Current.Where(x => x.SubjectId == subjectId && x.GradeLevel == gradeLevel).ToList(); Current.Where(x => x.SubjectId == subjectId && x.GradeLevel == gradeLevel).ToList();
public List<CompetencyDomain> GetBySubject(Guid subjectId) =>
Current.Where(x => x.SubjectId == subjectId).ToList();
public CompetencyDomain? GetById(Guid id) => Current.FirstOrDefault(x => x.Id == id); public CompetencyDomain? GetById(Guid id) => Current.FirstOrDefault(x => x.Id == id);
public void Save(CompetencyDomain domain) => Current.Add(domain); public void Save(CompetencyDomain domain) => Current.Add(domain);
+33
View File
@@ -2723,6 +2723,39 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
[McpToolsTests.cs](LehrerApp.Desktop.Tests/McpToolsTests.cs) (belegt den [McpToolsTests.cs](LehrerApp.Desktop.Tests/McpToolsTests.cs) (belegt den
`[CallerMemberName]`-Mechanismus ohne echtes UI). `[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<string>`, 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, **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 dass 5.2 wie ursprünglich beschrieben eine zweite, parallele Fehlzeiten-Erfassung neben dem
bereits bestehenden Anwesenheits-Tracking aus Kapitel 3 (`ParticipationEntry.Attendance`, bereits bestehenden Anwesenheits-Tracking aus Kapitel 3 (`ParticipationEntry.Attendance`,