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
{
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);
void Save(CompetencyDomain domain);
void Delete(Guid id);
+5
View File
@@ -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 (<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;
/// Material/Arbeitsblätter sowie fachspezifische Anhänge (z.B. Experiment- und
/// 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)
.OrderBy(d => d.SortOrder)
.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 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 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<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
_all.Where(d => d.SubjectId == subjectId && d.GradeLevel == gradeLevel)
.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 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);
+246 -8
View File
@@ -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);
}
}
+1
View File
@@ -226,6 +226,7 @@ public static class AppBootstrapper
services.AddSingleton<LessonPlanTools>();
services.AddSingleton<GroupMembershipTools>();
services.AddSingleton<LetterTemplateTools>();
services.AddSingleton<CompetencyTools>();
services.AddSingleton<McpServerHostedService>();
services.AddSingleton<McpClientRegistrationService>();
@@ -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);
}
/// <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(
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<McpServerTool>();
@@ -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)
+19 -5
View File
@@ -21,6 +21,8 @@ public static class McpToolScope
"download_lesson_attachment",
"list_letter_templates",
"render_letter",
"get_subjects",
"get_competency_catalog",
];
/// <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",
"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",
];
/// <summary>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
/// <see cref="AllowedWriteTools"/> aufgeführt, damit diese Ausnahme beim Lesen sofort auffällt.
/// <see cref="McpServerHostedService"/> registriert diese Tools zusätzlich mit
/// <c>Destructive = true</c>.</summary>
/// <summary>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 <see cref="AllowedWriteTools"/> aufgeführt,
/// 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 =
[
"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(
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);
/// <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 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(
Guid Id, Guid StudentId, Guid GroupId, MembershipPeriod Period,
DateOnly? JoinedAt, DateOnly? LeftAt, Niveau? Niveau);
@@ -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<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 ───────────────────────────────────────
[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());
}
@@ -181,6 +181,8 @@ public sealed class CompetencyCatalogImportServiceTests
public List<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
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 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
`[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,
dass 5.2 wie ursprünglich beschrieben eine zweite, parallele Fehlzeiten-Erfassung neben dem
bereits bestehenden Anwesenheits-Tracking aus Kapitel 3 (`ParticipationEntry.Attendance`,