feat: lokaler MCP-Server, Phase 2 (Write-Tools + Bestätigungsdialog + Lesson-Plans)
Neue Write-Tools create_time_entry, create_grade_entry und update_student_group_assignment schreiben nie direkt: jeder Aufruf zeigt zuerst einen menschenlesbaren Bestätigungsdialog (bestehender ConfirmDialog, über Dispatcher.UIThread aus dem Pipe-Session-Thread angezeigt) und schreibt erst nach Bestätigung, mit 2-Minuten-Timeout gegen eine hängende Session. Zusätzliches Read-Tool get_lesson_plans. create_note bewusst nicht umgesetzt (kollidiert mit dem bestehenden Dokumentations-Ausschluss aus Phase 1), create_lesson_plan/ update_lesson_plan wegen der Modellkomplexität von Lesson zurückgestellt (siehe TODO.md 4.5.26). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ using LehrerApp.Core.Interfaces;
|
|||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
|
using LehrerApp.Desktop.Services.Mcp;
|
||||||
using LehrerApp.Desktop.ViewModels.Settings;
|
using LehrerApp.Desktop.ViewModels.Settings;
|
||||||
using LehrerApp.Sync;
|
using LehrerApp.Sync;
|
||||||
using LehrerApp.Sync.Crypto;
|
using LehrerApp.Sync.Crypto;
|
||||||
@@ -594,3 +595,22 @@ public class FakeReportGrades : IReportGradeRepository
|
|||||||
}
|
}
|
||||||
public void Delete(Guid id) => _all.RemoveAll(r => r.Id == id);
|
public void Delete(Guid id) => _all.RemoveAll(r => r.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Fake für MCP-Write-Tool-Tests (Phase 2): antwortet ohne echtes UI, konfigurierbar über
|
||||||
|
/// <see cref="Response"/>, merkt sich Titel/Nachricht des letzten Aufrufs zur Prüfung, dass der
|
||||||
|
/// Bestätigungstext tatsächlich menschenlesbar ist (kein rohes JSON/GUID-Dump).</summary>
|
||||||
|
public class FakeMcpConfirmation : IMcpConfirmationService
|
||||||
|
{
|
||||||
|
public bool Response { get; set; } = true;
|
||||||
|
public string? LastTitle { get; private set; }
|
||||||
|
public string? LastMessage { get; private set; }
|
||||||
|
public int CallCount { get; private set; }
|
||||||
|
|
||||||
|
public Task<bool> ConfirmAsync(string title, string message, CancellationToken ct)
|
||||||
|
{
|
||||||
|
CallCount++;
|
||||||
|
LastTitle = title;
|
||||||
|
LastMessage = message;
|
||||||
|
return Task.FromResult(Response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,21 +10,31 @@ public sealed class McpToolsTests
|
|||||||
// ── McpToolScope ─────────────────────────────────────────────────────────────────────────
|
// ── McpToolScope ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AllowedReadTools_EnthaeltGenauDieFuenfPhase1Tools()
|
public void AllowedReadTools_EnthaeltGenauDieSechsReadTools()
|
||||||
{
|
{
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
new[] { "get_exams", "get_grades", "get_schedule", "get_students", "get_time_entries" },
|
new[] { "get_exams", "get_grades", "get_lesson_plans", "get_schedule", "get_students", "get_time_entries" },
|
||||||
McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal));
|
McpToolScope.AllowedReadTools.OrderBy(n => n, StringComparer.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AllowedReadTools_EnthaeltKeineDokumentationstypen()
|
public void AllowedWriteTools_EnthaeltGenauDieDreiPhase2WriteTools()
|
||||||
|
{
|
||||||
|
Assert.Equal(
|
||||||
|
new[] { "create_grade_entry", "create_time_entry", "update_student_group_assignment" },
|
||||||
|
McpToolScope.AllowedWriteTools.OrderBy(n => n, StringComparer.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AllowedTools_EnthaeltKeineDokumentationstypen()
|
||||||
{
|
{
|
||||||
// Gesprächsnotizen/Vorfälle/Förderpläne dürfen technisch nie über MCP erreichbar sein
|
// Gesprächsnotizen/Vorfälle/Förderpläne dürfen technisch nie über MCP erreichbar sein
|
||||||
// (siehe Planungsdokument) - die Namenskonvention "documentation"/"vorgang" darf nie auftauchen.
|
// (siehe Planungsdokument) - die Namenskonvention "documentation"/"vorgang" darf nie auftauchen.
|
||||||
Assert.DoesNotContain(McpToolScope.AllowedReadTools, n =>
|
var allNames = McpToolScope.AllowedReadTools.Concat(McpToolScope.AllowedWriteTools);
|
||||||
|
Assert.DoesNotContain(allNames, n =>
|
||||||
n.Contains("documentation", StringComparison.OrdinalIgnoreCase) ||
|
n.Contains("documentation", StringComparison.OrdinalIgnoreCase) ||
|
||||||
n.Contains("vorgang", StringComparison.OrdinalIgnoreCase));
|
n.Contains("vorgang", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
n.Contains("note", StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── StudentTools ─────────────────────────────────────────────────────────────────────────
|
// ── StudentTools ─────────────────────────────────────────────────────────────────────────
|
||||||
@@ -98,13 +108,50 @@ public sealed class McpToolsTests
|
|||||||
var grades = new FakeGrades();
|
var grades = new FakeGrades();
|
||||||
grades.Add(new Grade { GroupId = groupId, StudentId = studentA, Value = "2" });
|
grades.Add(new Grade { GroupId = groupId, StudentId = studentA, Value = "2" });
|
||||||
grades.Add(new Grade { GroupId = groupId, StudentId = studentB, Value = "3" });
|
grades.Add(new Grade { GroupId = groupId, StudentId = studentB, Value = "3" });
|
||||||
var tool = new GradeTools(grades);
|
var tool = new GradeTools(grades, new FakeStudents([]), new FakeMcpConfirmation());
|
||||||
|
|
||||||
var dto = Assert.Single(tool.GetGrades(groupId, studentA));
|
var dto = Assert.Single(tool.GetGrades(groupId, studentA));
|
||||||
|
|
||||||
Assert.Equal(studentA, dto.StudentId);
|
Assert.Equal(studentA, dto.StudentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateGradeEntry_NutzerBestaetigt_SpeichertNote()
|
||||||
|
{
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
|
||||||
|
var grades = new FakeGrades();
|
||||||
|
var confirmation = new FakeMcpConfirmation { Response = true };
|
||||||
|
var tool = new GradeTools(grades, new FakeStudents([student]), confirmation);
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var result = await tool.CreateGradeEntry(
|
||||||
|
student.Id, groupId, GradeCategory.Oral, "2+", new DateOnly(2026, 1, 10));
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.NotNull(result.Id);
|
||||||
|
Assert.Single(grades.GetByGroup(groupId));
|
||||||
|
// Bestätigungstext muss für einen Menschen lesbar sein (Name statt bloßer GUID).
|
||||||
|
Assert.Contains("Anna", confirmation.LastMessage);
|
||||||
|
Assert.DoesNotContain(student.Id.ToString(), confirmation.LastMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateGradeEntry_NutzerLehntAb_SpeichertNichts()
|
||||||
|
{
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
|
||||||
|
var grades = new FakeGrades();
|
||||||
|
var confirmation = new FakeMcpConfirmation { Response = false };
|
||||||
|
var tool = new GradeTools(grades, new FakeStudents([student]), confirmation);
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
|
||||||
|
var result = await tool.CreateGradeEntry(
|
||||||
|
student.Id, groupId, GradeCategory.Oral, "2+", new DateOnly(2026, 1, 10));
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Empty(grades.GetByGroup(groupId));
|
||||||
|
Assert.Equal(1, confirmation.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
// ── ScheduleTools ────────────────────────────────────────────────────────────────────────
|
// ── ScheduleTools ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -129,10 +176,104 @@ public sealed class McpToolsTests
|
|||||||
var entries = new FakeTimeEntries();
|
var entries = new FakeTimeEntries();
|
||||||
entries.Add(new TimeEntry { Date = new DateOnly(2026, 1, 5), DurationMinutes = 30 });
|
entries.Add(new TimeEntry { Date = new DateOnly(2026, 1, 5), DurationMinutes = 30 });
|
||||||
entries.Add(new TimeEntry { Date = new DateOnly(2026, 2, 1), DurationMinutes = 45 });
|
entries.Add(new TimeEntry { Date = new DateOnly(2026, 2, 1), DurationMinutes = 45 });
|
||||||
var tool = new TimeEntryTools(entries);
|
var tool = new TimeEntryTools(entries, new FakeGroups([]), new FakeMcpConfirmation());
|
||||||
|
|
||||||
var dto = Assert.Single(tool.GetTimeEntries(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)));
|
var dto = Assert.Single(tool.GetTimeEntries(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)));
|
||||||
|
|
||||||
Assert.Equal(30, dto.DurationMinutes);
|
Assert.Equal(30, dto.DurationMinutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateTimeEntry_NutzerBestaetigt_SpeichertEintrag()
|
||||||
|
{
|
||||||
|
var entries = new FakeTimeEntries();
|
||||||
|
var confirmation = new FakeMcpConfirmation { Response = true };
|
||||||
|
var tool = new TimeEntryTools(entries, new FakeGroups([]), confirmation);
|
||||||
|
|
||||||
|
var result = await tool.CreateTimeEntry("Korrektur", new DateOnly(2026, 1, 10), 30);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Single(entries.GetByDateRange(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateTimeEntry_NutzerLehntAb_SpeichertNichts()
|
||||||
|
{
|
||||||
|
var entries = new FakeTimeEntries();
|
||||||
|
var confirmation = new FakeMcpConfirmation { Response = false };
|
||||||
|
var tool = new TimeEntryTools(entries, new FakeGroups([]), confirmation);
|
||||||
|
|
||||||
|
var result = await tool.CreateTimeEntry("Korrektur", new DateOnly(2026, 1, 10), 30);
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Empty(entries.GetByDateRange(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LessonPlanTools ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetLessonPlans_LiefertEinheitenDerGruppeUndStundenImZeitraum()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var units = new FakeUnits();
|
||||||
|
units.Add(new Unit { GroupId = groupId, Title = "Optik" });
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
lessons.Add(new Lesson { GroupId = groupId, Date = new DateOnly(2026, 1, 5), Topic = "Brechung" });
|
||||||
|
lessons.Add(new Lesson { GroupId = groupId, Date = new DateOnly(2026, 3, 1), Topic = "Später" });
|
||||||
|
var tool = new LessonPlanTools(units, lessons);
|
||||||
|
|
||||||
|
var result = tool.GetLessonPlans(groupId, new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31));
|
||||||
|
|
||||||
|
Assert.Single(result.Units);
|
||||||
|
var lesson = Assert.Single(result.Lessons);
|
||||||
|
Assert.Equal("Brechung", lesson.Topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GroupMembershipTools ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateStudentGroupAssignment_KeineBestehendeMitgliedschaft_LegtNeueAn()
|
||||||
|
{
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
|
||||||
|
var group = new LearningGroup { Name = "7a" };
|
||||||
|
var memberships = new FakeMemberships([]);
|
||||||
|
var confirmation = new FakeMcpConfirmation { Response = true };
|
||||||
|
var tool = new GroupMembershipTools(memberships, new FakeStudents([student]), new FakeGroups([group]), confirmation);
|
||||||
|
|
||||||
|
var result = await tool.UpdateStudentGroupAssignment(student.Id, group.Id, niveau: Niveau.E);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
var membership = Assert.Single(memberships.GetByStudent(student.Id));
|
||||||
|
Assert.Equal(Niveau.E, membership.Niveau);
|
||||||
|
Assert.Contains("Anna", confirmation.LastMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateStudentGroupAssignment_BestehendeMitgliedschaftUnveraendert_FragtNichtNochmalNach()
|
||||||
|
{
|
||||||
|
var student = new Student { FirstName = "Anna", LastName = "Aktiv" };
|
||||||
|
var group = new LearningGroup { Name = "7a" };
|
||||||
|
var existing = new GroupMembership { StudentId = student.Id, GroupId = group.Id, Niveau = Niveau.G };
|
||||||
|
var memberships = new FakeMemberships([existing]);
|
||||||
|
var confirmation = new FakeMcpConfirmation { Response = true };
|
||||||
|
var tool = new GroupMembershipTools(memberships, new FakeStudents([student]), new FakeGroups([group]), confirmation);
|
||||||
|
|
||||||
|
var result = await tool.UpdateStudentGroupAssignment(student.Id, group.Id, niveau: Niveau.G);
|
||||||
|
|
||||||
|
Assert.True(result.Applied);
|
||||||
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateStudentGroupAssignment_UnbekannterSchueler_LiefertFehlerOhneNachfrage()
|
||||||
|
{
|
||||||
|
var group = new LearningGroup { Name = "7a" };
|
||||||
|
var confirmation = new FakeMcpConfirmation { Response = true };
|
||||||
|
var tool = new GroupMembershipTools(new FakeMemberships([]), new FakeStudents([]), new FakeGroups([group]), confirmation);
|
||||||
|
|
||||||
|
var result = await tool.UpdateStudentGroupAssignment(Guid.NewGuid(), group.Id);
|
||||||
|
|
||||||
|
Assert.False(result.Applied);
|
||||||
|
Assert.Equal(0, confirmation.CallCount);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,11 +217,14 @@ public static class AppBootstrapper
|
|||||||
|
|
||||||
// ── MCP-Server (lokal, Phase 1 – siehe Planungsdokument, optional per Opt-in) ─────────
|
// ── MCP-Server (lokal, Phase 1 – siehe Planungsdokument, optional per Opt-in) ─────────
|
||||||
services.AddSingleton(_ => new McpSettingsService(appData));
|
services.AddSingleton(_ => new McpSettingsService(appData));
|
||||||
|
services.AddSingleton<IMcpConfirmationService, AvaloniaMcpConfirmationService>();
|
||||||
services.AddSingleton<StudentTools>();
|
services.AddSingleton<StudentTools>();
|
||||||
services.AddSingleton<ExamTools>();
|
services.AddSingleton<ExamTools>();
|
||||||
services.AddSingleton<GradeTools>();
|
services.AddSingleton<GradeTools>();
|
||||||
services.AddSingleton<ScheduleTools>();
|
services.AddSingleton<ScheduleTools>();
|
||||||
services.AddSingleton<TimeEntryTools>();
|
services.AddSingleton<TimeEntryTools>();
|
||||||
|
services.AddSingleton<LessonPlanTools>();
|
||||||
|
services.AddSingleton<GroupMembershipTools>();
|
||||||
services.AddSingleton<McpServerHostedService>();
|
services.AddSingleton<McpServerHostedService>();
|
||||||
|
|
||||||
// ── WebUntis-iCal-Abgleich (optional – nur wenn URL hinterlegt und aktiviert) ─────────
|
// ── WebUntis-iCal-Abgleich (optional – nur wenn URL hinterlegt und aktiviert) ─────────
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using LehrerApp.Desktop.Views.Shared;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Produktive <see cref="IMcpConfirmationService"/>-Implementierung: zeigt den bestehenden
|
||||||
|
/// <see cref="ConfirmDialog"/> (Views/Shared) über dem Hauptfenster an. Der aufrufende Tool-Handler
|
||||||
|
/// läuft auf einem Hintergrund-Thread (MCP-Pipe-Session in <see cref="McpServerHostedService"/>),
|
||||||
|
/// deshalb Marshalling über <see cref="Dispatcher.UIThread"/>.
|
||||||
|
///
|
||||||
|
/// Ohne Reaktion des Nutzers würde die Pipe-Session (und damit der wartende KI-Client) unbegrenzt
|
||||||
|
/// hängen bleiben — nach <see cref="Timeout"/> wird der Dialog automatisch geschlossen und die
|
||||||
|
/// Änderung als abgelehnt gewertet.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AvaloniaMcpConfirmationService : IMcpConfirmationService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
|
public async Task<bool> ConfirmAsync(string title, string message, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Die gesamte Warte-/Timeout-Logik läuft als ein Stück innerhalb des UI-Thread-Callbacks:
|
||||||
|
// Avalonias Dispatcher-Synchronisationskontext sorgt dafür, dass die Fortsetzung nach
|
||||||
|
// "await Task.WhenAny(...)" wieder auf dem UI-Thread läuft, sodass dialog.Close() dort
|
||||||
|
// sicher aufgerufen werden kann.
|
||||||
|
return await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||||
|
{
|
||||||
|
var dialog = new ConfirmDialog
|
||||||
|
{
|
||||||
|
DataContext = new ConfirmDialogInfo { Title = title, Message = message, ConfirmText = "Übernehmen" },
|
||||||
|
};
|
||||||
|
var dialogTask = dialog.ShowDialog<bool>(owner);
|
||||||
|
var timeoutTask = Task.Delay(Timeout, ct);
|
||||||
|
var completed = await Task.WhenAny(dialogTask, timeoutTask);
|
||||||
|
if (completed != dialogTask)
|
||||||
|
{
|
||||||
|
dialog.Close(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return await dialogTask;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace LehrerApp.Desktop.Services.Mcp;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zeigt dem Nutzer eine über MCP vorgeschlagene Änderung an, bevor sie tatsächlich geschrieben
|
||||||
|
/// wird — Sicherheitsmodell aus dem Planungsdokument: "Tool-Aufruf erzeugt einen Vorschlag/Diff,
|
||||||
|
/// der im Avalonia-Client als Bestätigungsdialog angezeigt wird, [...] kein 'silent write' durch
|
||||||
|
/// das Modell." Als Interface gehalten, damit Write-Tool-Tests ohne echtes UI laufen können (siehe
|
||||||
|
/// <see cref="AvaloniaMcpConfirmationService"/> für die produktive Implementierung).
|
||||||
|
/// </summary>
|
||||||
|
public interface IMcpConfirmationService
|
||||||
|
{
|
||||||
|
/// <returns>true, wenn der Nutzer bestätigt hat; false bei Ablehnung, Timeout oder falls kein
|
||||||
|
/// Hauptfenster verfügbar ist (z.B. während des DB-Passwort-Prompts beim Start).</returns>
|
||||||
|
Task<bool> ConfirmAsync(string title, string message, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ using ModelContextProtocol.Server;
|
|||||||
namespace LehrerApp.Desktop.Services.Mcp;
|
namespace LehrerApp.Desktop.Services.Mcp;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// In-Process-MCP-Server (Phase 1, siehe Planungsdokument). Lauscht auf der Named Pipe
|
/// In-Process-MCP-Server (Phase 1+2, siehe Planungsdokument). Lauscht auf der Named Pipe
|
||||||
/// <see cref="McpPipeConstants.PipeName"/> und bedient jede eingehende Verbindung (eine je
|
/// <see cref="McpPipeConstants.PipeName"/> und bedient jede eingehende Verbindung (eine je
|
||||||
/// LehrerApp.McpBridge-Instanz) als eigene MCP-Session über <see cref="StreamServerTransport"/> —
|
/// LehrerApp.McpBridge-Instanz) als eigene MCP-Session über <see cref="StreamServerTransport"/> —
|
||||||
/// ein <see cref="NamedPipeServerStream"/> ist ein normaler <see cref="Stream"/> und kann direkt
|
/// ein <see cref="NamedPipeServerStream"/> ist ein normaler <see cref="Stream"/> und kann direkt
|
||||||
@@ -30,11 +30,14 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
|||||||
public McpServerHostedService(
|
public McpServerHostedService(
|
||||||
McpSettingsService settings, AppLogger logger,
|
McpSettingsService settings, AppLogger logger,
|
||||||
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
||||||
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools)
|
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
||||||
|
GroupMembershipTools groupMembershipTools)
|
||||||
{
|
{
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_serverOptions = BuildServerOptions(studentTools, examTools, gradeTools, scheduleTools, timeEntryTools);
|
_serverOptions = BuildServerOptions(
|
||||||
|
studentTools, examTools, gradeTools, scheduleTools, timeEntryTools, lessonPlanTools,
|
||||||
|
groupMembershipTools);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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
|
||||||
@@ -109,11 +112,12 @@ 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)
|
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
||||||
|
GroupMembershipTools groupMembershipTools)
|
||||||
{
|
{
|
||||||
var toolCollection = new McpServerPrimitiveCollection<McpServerTool>();
|
var toolCollection = new McpServerPrimitiveCollection<McpServerTool>();
|
||||||
|
|
||||||
void AddTool(Delegate handler, string name, string description)
|
void AddReadTool(Delegate handler, string name, string description)
|
||||||
{
|
{
|
||||||
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
|
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
|
||||||
{
|
{
|
||||||
@@ -123,21 +127,44 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
AddTool(studentTools.GetStudents, "get_students",
|
// Write-Tools schreiben nie direkt - jede Handler-Methode ruft selbst erst
|
||||||
|
// IMcpConfirmationService auf (siehe die jeweilige Tool-Klasse). ReadOnly bewusst false,
|
||||||
|
// Destructive bewusst false (keine der Phase-2-Schreiboperationen löscht etwas).
|
||||||
|
void AddWriteTool(Delegate handler, string name, string description)
|
||||||
|
{
|
||||||
|
toolCollection.Add(McpServerTool.Create(handler, new McpServerToolCreateOptions
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Description = description,
|
||||||
|
ReadOnly = false,
|
||||||
|
Destructive = false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
AddReadTool(studentTools.GetStudents, "get_students",
|
||||||
"Listet Schüler, optional gefiltert nach Lerngruppe.");
|
"Listet Schüler, optional gefiltert nach Lerngruppe.");
|
||||||
AddTool(examTools.GetExams, "get_exams",
|
AddReadTool(examTools.GetExams, "get_exams",
|
||||||
"Listet Klausuren, optional gefiltert nach Lerngruppe.");
|
"Listet Klausuren, optional gefiltert nach Lerngruppe.");
|
||||||
AddTool(gradeTools.GetGrades, "get_grades",
|
AddReadTool(gradeTools.GetGrades, "get_grades",
|
||||||
"Listet Noten einer Lerngruppe, optional gefiltert auf einen Schüler.");
|
"Listet Noten einer Lerngruppe, optional gefiltert auf einen Schüler.");
|
||||||
AddTool(scheduleTools.GetSchedule, "get_schedule",
|
AddReadTool(scheduleTools.GetSchedule, "get_schedule",
|
||||||
"Listet Stundenplan-Einträge, optional gefiltert nach Lerngruppe.");
|
"Listet Stundenplan-Einträge, optional gefiltert nach Lerngruppe.");
|
||||||
AddTool(timeEntryTools.GetTimeEntries, "get_time_entries",
|
AddReadTool(timeEntryTools.GetTimeEntries, "get_time_entries",
|
||||||
"Listet eigene Zeiterfassungs-Einträge in einem Datumsbereich.");
|
"Listet eigene Zeiterfassungs-Einträge in einem Datumsbereich.");
|
||||||
|
AddReadTool(lessonPlanTools.GetLessonPlans, "get_lesson_plans",
|
||||||
|
"Listet Unterrichtseinheiten und -stunden einer Lerngruppe in einem Zeitraum.");
|
||||||
|
|
||||||
|
AddWriteTool(timeEntryTools.CreateTimeEntry, "create_time_entry",
|
||||||
|
"Schlägt einen neuen Zeiterfassungs-Eintrag vor (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(gradeTools.CreateGradeEntry, "create_grade_entry",
|
||||||
|
"Schlägt eine neue Note für einen Schüler vor (Bestätigung durch den Nutzer nötig).");
|
||||||
|
AddWriteTool(groupMembershipTools.UpdateStudentGroupAssignment, "update_student_group_assignment",
|
||||||
|
"Legt eine Gruppenmitgliedschaft an oder ändert Niveau/Zeitraum (Bestätigung durch den Nutzer nötig).");
|
||||||
|
|
||||||
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)
|
||||||
.SequenceEqual(McpToolScope.AllowedReadTools.OrderBy(n => n)),
|
.SequenceEqual(McpToolScope.AllowedReadTools.Concat(McpToolScope.AllowedWriteTools).OrderBy(n => n)),
|
||||||
"Registrierte MCP-Tools weichen von McpToolScope.AllowedReadTools ab.");
|
"Registrierte MCP-Tools weichen von McpToolScope ab.");
|
||||||
|
|
||||||
return new McpServerOptions
|
return new McpServerOptions
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,5 +17,15 @@ public static class McpToolScope
|
|||||||
"get_grades",
|
"get_grades",
|
||||||
"get_schedule",
|
"get_schedule",
|
||||||
"get_time_entries",
|
"get_time_entries",
|
||||||
|
"get_lesson_plans",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>Write-Tools (Phase 2) — jeder Aufruf läuft über <see cref="IMcpConfirmationService"/>,
|
||||||
|
/// bevor irgendetwas geschrieben wird (siehe die jeweilige Tool-Klasse).</summary>
|
||||||
|
public static readonly IReadOnlyCollection<string> AllowedWriteTools =
|
||||||
|
[
|
||||||
|
"create_time_entry",
|
||||||
|
"create_grade_entry",
|
||||||
|
"update_student_group_assignment",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,3 +23,23 @@ public record TimetableSlotDto(Guid Id, Guid GroupId, DayOfWeek Weekday, int Per
|
|||||||
public record TimeEntryDto(
|
public record TimeEntryDto(
|
||||||
Guid Id, Guid? TaskId, string Category, Guid? GroupId, DateOnly Date,
|
Guid Id, Guid? TaskId, string Category, Guid? GroupId, DateOnly Date,
|
||||||
TimeOnly? StartTime, TimeOnly? EndTime, int DurationMinutes, string? Description);
|
TimeOnly? StartTime, TimeOnly? EndTime, int DurationMinutes, string? Description);
|
||||||
|
|
||||||
|
public record LessonPhaseDto(string Name, int DurationMinutes, string Activity, string Material, string Shorthand);
|
||||||
|
|
||||||
|
public record LessonDto(
|
||||||
|
Guid Id, Guid UnitId, Guid GroupId, DateOnly Date, int? LessonNumber, string Topic,
|
||||||
|
string? Homework, LessonStatus Status, List<LessonPhaseDto> Phases);
|
||||||
|
|
||||||
|
public record UnitDto(
|
||||||
|
Guid Id, Guid GroupId, string Title, DateOnly? StartDate, DateOnly? EndDate,
|
||||||
|
UnitStatus Status, List<string> Competencies);
|
||||||
|
|
||||||
|
public record LessonPlanResultDto(List<UnitDto> Units, List<LessonDto> Lessons);
|
||||||
|
|
||||||
|
public record GroupMembershipDto(
|
||||||
|
Guid Id, Guid StudentId, Guid GroupId, MembershipPeriod Period,
|
||||||
|
DateOnly? JoinedAt, DateOnly? LeftAt, Niveau? Niveau);
|
||||||
|
|
||||||
|
/// <summary>Ergebnis eines Write-Tools (Phase 2, siehe Planungsdokument): <see cref="Applied"/> ist
|
||||||
|
/// nur dann true, wenn der Nutzer die Änderung im Bestätigungsdialog angenommen hat.</summary>
|
||||||
|
public record WriteResultDto(bool Applied, Guid? Id, string Message);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
|
||||||
/// <summary>MCP-Read-Tool "get_grades" (Phase 1, siehe Planungsdokument).</summary>
|
/// <summary>MCP-Tools "get_grades" (Phase 1) und "create_grade_entry" (Phase 2), siehe
|
||||||
public class GradeTools(IGradeRepository grades)
|
/// Planungsdokument.</summary>
|
||||||
|
public class GradeTools(IGradeRepository grades, IStudentRepository students, IMcpConfirmationService confirmation)
|
||||||
{
|
{
|
||||||
[Description("Listet Noten einer Lerngruppe, optional gefiltert auf einen einzelnen Schüler.")]
|
[Description("Listet Noten einer Lerngruppe, optional gefiltert auf einen einzelnen Schüler.")]
|
||||||
public List<GradeDto> GetGrades(
|
public List<GradeDto> GetGrades(
|
||||||
@@ -15,4 +17,54 @@ public class GradeTools(IGradeRepository grades)
|
|||||||
return list.Select(g => new GradeDto(
|
return list.Select(g => new GradeDto(
|
||||||
g.Id, g.StudentId, g.GroupId, g.Category, g.Value, g.Date, g.Weight, g.Note)).ToList();
|
g.Id, g.StudentId, g.GroupId, g.Category, g.Value, g.Date, g.Weight, g.Note)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Description("Schlägt eine neue Note für einen Schüler vor. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen, bevor sie gespeichert wird.")]
|
||||||
|
public async Task<WriteResultDto> CreateGradeEntry(
|
||||||
|
[Description("Schüler-ID.")] Guid studentId,
|
||||||
|
[Description("Lerngruppen-ID.")] Guid groupId,
|
||||||
|
[Description("Kategorie: Oral, Homework, Participation, Project oder Other.")] GradeCategory category,
|
||||||
|
[Description("Notenwert als Text, z.B. \"2+\" oder \"gut\".")] string value,
|
||||||
|
[Description("Datum, Format YYYY-MM-DD.")] DateOnly date,
|
||||||
|
[Description("Gewichtung, Standard 1.0.")] double weight = 1.0,
|
||||||
|
[Description("Optionale Notiz.")] string? note = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var student = students.GetById(studentId);
|
||||||
|
if (student is null)
|
||||||
|
return new WriteResultDto(false, null, "Unbekannte Schüler-ID.");
|
||||||
|
|
||||||
|
var message =
|
||||||
|
$"Neue Note für {student.FullName}: {GradeCategoryDisplayName(category)} = {value}" +
|
||||||
|
(weight != 1.0 ? $" (Gewichtung {weight:0.##})" : "") +
|
||||||
|
$", am {date:dd.MM.yyyy}" +
|
||||||
|
(string.IsNullOrWhiteSpace(note) ? "" : $"\n„{note}“");
|
||||||
|
|
||||||
|
if (!await confirmation.ConfirmAsync("Note anlegen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
var grade = new Grade
|
||||||
|
{
|
||||||
|
StudentId = studentId,
|
||||||
|
GroupId = groupId,
|
||||||
|
Category = category,
|
||||||
|
Value = value,
|
||||||
|
Date = date,
|
||||||
|
Weight = weight,
|
||||||
|
Note = note,
|
||||||
|
};
|
||||||
|
grades.Save(grade);
|
||||||
|
return new WriteResultDto(true, grade.Id, "Note gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eigene, schlanke Beschriftung statt Wiederverwendung von ViewModels.Groups.GradeCategoryDisplay:
|
||||||
|
// Tool-Klassen unter Services/Mcp sollen nicht von ViewModel-Klassen abhängen.
|
||||||
|
private static string GradeCategoryDisplayName(GradeCategory c) => c switch
|
||||||
|
{
|
||||||
|
GradeCategory.Oral => "Mündlich",
|
||||||
|
GradeCategory.Homework => "Hausaufgaben",
|
||||||
|
GradeCategory.Participation => "Mitarbeit",
|
||||||
|
GradeCategory.Project => "Projekt",
|
||||||
|
GradeCategory.Other => "Sonstiges",
|
||||||
|
_ => c.ToString(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using System.Text;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>MCP-Write-Tool "update_student_group_assignment" (Phase 2, siehe Planungsdokument).
|
||||||
|
/// Legt eine <see cref="GroupMembership"/> an, falls noch keine für Schüler+Gruppe existiert, sonst
|
||||||
|
/// werden nur die übergebenen (nicht-null) Felder überschrieben.</summary>
|
||||||
|
public class GroupMembershipTools(
|
||||||
|
IGroupMembershipRepository memberships, IStudentRepository students, IGroupRepository groups,
|
||||||
|
IMcpConfirmationService confirmation)
|
||||||
|
{
|
||||||
|
[Description("Legt eine Gruppenmitgliedschaft eines Schülers an oder ändert Niveau/Zeitraum einer bestehenden. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen.")]
|
||||||
|
public async Task<WriteResultDto> UpdateStudentGroupAssignment(
|
||||||
|
[Description("Schüler-ID.")] Guid studentId,
|
||||||
|
[Description("Lerngruppen-ID.")] Guid groupId,
|
||||||
|
[Description("Niveau: E, G oder Foerder. Unverändert lassen: weglassen.")] Niveau? niveau = null,
|
||||||
|
[Description("Zeitraum: FullYear, H1Only, H2Only oder Custom. Unverändert lassen: weglassen.")] MembershipPeriod? period = null,
|
||||||
|
[Description("Beitrittsdatum bei Custom-Zeitraum, Format YYYY-MM-DD.")] DateOnly? joinedAt = null,
|
||||||
|
[Description("Austrittsdatum bei Custom-Zeitraum, Format YYYY-MM-DD.")] DateOnly? leftAt = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var student = students.GetById(studentId);
|
||||||
|
var group = groups.GetById(groupId);
|
||||||
|
if (student is null || group is null)
|
||||||
|
return new WriteResultDto(false, null, "Unbekannte Schüler- oder Gruppen-ID.");
|
||||||
|
|
||||||
|
var existing = memberships.GetByStudentAndGroup(studentId, groupId);
|
||||||
|
var target = existing is null
|
||||||
|
? new GroupMembership { StudentId = studentId, GroupId = groupId }
|
||||||
|
: Clone(existing);
|
||||||
|
|
||||||
|
var changes = new StringBuilder();
|
||||||
|
if (niveau is not null && niveau != target.Niveau) { changes.AppendLine($"Niveau: {NiveauName(target.Niveau)} → {NiveauName(niveau)}"); target.Niveau = niveau; }
|
||||||
|
if (period is not null && period != target.Period) { changes.AppendLine($"Zeitraum: {target.Period} → {period}"); target.Period = period.Value; }
|
||||||
|
if (joinedAt is not null && joinedAt != target.JoinedAt) { changes.AppendLine($"Beitritt: {target.JoinedAt:dd.MM.yyyy} → {joinedAt:dd.MM.yyyy}"); target.JoinedAt = joinedAt; }
|
||||||
|
if (leftAt is not null && leftAt != target.LeftAt) { changes.AppendLine($"Austritt: {target.LeftAt:dd.MM.yyyy} → {leftAt:dd.MM.yyyy}"); target.LeftAt = leftAt; }
|
||||||
|
|
||||||
|
if (existing is not null && changes.Length == 0)
|
||||||
|
return new WriteResultDto(true, existing.Id, "Keine Änderung nötig, Mitgliedschaft besteht bereits unverändert.");
|
||||||
|
|
||||||
|
var title = existing is null ? "Gruppenmitgliedschaft anlegen?" : "Gruppenmitgliedschaft ändern?";
|
||||||
|
var message = $"{student.FullName} — {group.Name}" +
|
||||||
|
(existing is null ? "\nNeue Mitgliedschaft anlegen." : "") +
|
||||||
|
(changes.Length > 0 ? "\n" + changes.ToString().TrimEnd() : "");
|
||||||
|
|
||||||
|
if (!await confirmation.ConfirmAsync(title, message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
memberships.Save(target);
|
||||||
|
return new WriteResultDto(true, target.Id, "Gruppenmitgliedschaft gespeichert.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GroupMembership Clone(GroupMembership m) => new()
|
||||||
|
{
|
||||||
|
Id = m.Id, StudentId = m.StudentId, GroupId = m.GroupId, AddedOn = m.AddedOn,
|
||||||
|
Period = m.Period, JoinedAt = m.JoinedAt, LeftAt = m.LeftAt, Niveau = m.Niveau,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string NiveauName(Niveau? n) => n switch
|
||||||
|
{
|
||||||
|
Core.Models.Niveau.E => "E-Niveau",
|
||||||
|
Core.Models.Niveau.G => "G-Niveau",
|
||||||
|
Core.Models.Niveau.Foerder => "Förderniveau",
|
||||||
|
_ => "–",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
|
||||||
|
/// <summary>MCP-Read-Tool "get_lesson_plans" (Phase 2, siehe Planungsdokument). Liefert
|
||||||
|
/// Unterrichtseinheiten einer Lerngruppe sowie deren Einzelstunden in einem Zeitraum.</summary>
|
||||||
|
public class LessonPlanTools(IUnitRepository units, ILessonRepository lessons)
|
||||||
|
{
|
||||||
|
[Description("Listet Unterrichtseinheiten und -stunden einer Lerngruppe; die Einzelstunden werden auf den angegebenen Zeitraum gefiltert.")]
|
||||||
|
public LessonPlanResultDto GetLessonPlans(
|
||||||
|
[Description("Lerngruppen-ID.")] Guid groupId,
|
||||||
|
[Description("Startdatum (einschließlich) für die Einzelstunden, Format YYYY-MM-DD.")] DateOnly from,
|
||||||
|
[Description("Enddatum (einschließlich) für die Einzelstunden, Format YYYY-MM-DD.")] DateOnly to)
|
||||||
|
{
|
||||||
|
var unitDtos = units.GetByGroup(groupId)
|
||||||
|
.Select(u => new UnitDto(u.Id, u.GroupId, u.Title, u.StartDate, u.EndDate, u.Status, u.Competencies))
|
||||||
|
.ToList();
|
||||||
|
var lessonDtos = lessons.GetByGroupAndRange(groupId, from, to)
|
||||||
|
.Select(l => new LessonDto(
|
||||||
|
l.Id, l.UnitId, l.GroupId, l.Date, l.LessonNumber, l.Topic, l.Homework, l.Status,
|
||||||
|
l.Phases.Select(p => new LessonPhaseDto(p.Name, p.DurationMinutes, p.Activity, p.Material, p.Shorthand)).ToList()))
|
||||||
|
.ToList();
|
||||||
|
return new LessonPlanResultDto(unitDtos, lessonDtos);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||||
|
|
||||||
/// <summary>MCP-Read-Tool "get_time_entries" (Phase 1, siehe Planungsdokument). Der Zeitraum ist
|
/// <summary>MCP-Tools "get_time_entries" (Phase 1) und "create_time_entry" (Phase 2), siehe
|
||||||
/// Pflicht (nicht optional), damit eine unbedachte Anfrage nicht die gesamte Zeiterfassungshistorie
|
/// Planungsdokument. Der Zeitraum bei "get_time_entries" ist Pflicht (nicht optional), damit eine
|
||||||
/// zurückgibt.</summary>
|
/// unbedachte Anfrage nicht die gesamte Zeiterfassungshistorie zurückgibt.</summary>
|
||||||
public class TimeEntryTools(ITimeEntryRepository timeEntries)
|
public class TimeEntryTools(ITimeEntryRepository timeEntries, IGroupRepository groups, IMcpConfirmationService confirmation)
|
||||||
{
|
{
|
||||||
[Description("Listet eigene Zeiterfassungs-Einträge in einem Datumsbereich.")]
|
[Description("Listet eigene Zeiterfassungs-Einträge in einem Datumsbereich.")]
|
||||||
public List<TimeEntryDto> GetTimeEntries(
|
public List<TimeEntryDto> GetTimeEntries(
|
||||||
@@ -15,4 +16,34 @@ public class TimeEntryTools(ITimeEntryRepository timeEntries)
|
|||||||
timeEntries.GetByDateRange(from, to).Select(t => new TimeEntryDto(
|
timeEntries.GetByDateRange(from, to).Select(t => new TimeEntryDto(
|
||||||
t.Id, t.TaskId, t.Category, t.GroupId, t.Date, t.StartTime, t.EndTime,
|
t.Id, t.TaskId, t.Category, t.GroupId, t.Date, t.StartTime, t.EndTime,
|
||||||
t.DurationMinutes, t.Description)).ToList();
|
t.DurationMinutes, t.Description)).ToList();
|
||||||
|
|
||||||
|
[Description("Schlägt einen neuen Zeiterfassungs-Eintrag vor. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen, bevor er gespeichert wird.")]
|
||||||
|
public async Task<WriteResultDto> CreateTimeEntry(
|
||||||
|
[Description("Kategorie, z.B. \"Unterricht\", \"Korrektur\", \"Vorbereitung\".")] string category,
|
||||||
|
[Description("Datum, Format YYYY-MM-DD.")] DateOnly date,
|
||||||
|
[Description("Dauer in Minuten.")] int durationMinutes,
|
||||||
|
[Description("Optionale Lerngruppen-ID.")] Guid? groupId = null,
|
||||||
|
[Description("Optionale Beschreibung.")] string? description = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var groupName = groupId is { } gid ? groups.GetById(gid)?.Name : null;
|
||||||
|
var message =
|
||||||
|
$"Neuer Zeiteintrag: {category}, {durationMinutes} Min. am {date:dd.MM.yyyy}" +
|
||||||
|
(groupName is not null ? $", Gruppe {groupName}" : "") +
|
||||||
|
(string.IsNullOrWhiteSpace(description) ? "" : $"\n„{description}“");
|
||||||
|
|
||||||
|
if (!await confirmation.ConfirmAsync("Zeiteintrag anlegen?", message, ct))
|
||||||
|
return new WriteResultDto(false, null, "Vom Nutzer abgelehnt oder nicht bestätigt.");
|
||||||
|
|
||||||
|
var entry = new TimeEntry
|
||||||
|
{
|
||||||
|
Category = category,
|
||||||
|
Date = date,
|
||||||
|
DurationMinutes = durationMinutes,
|
||||||
|
GroupId = groupId,
|
||||||
|
Description = description,
|
||||||
|
};
|
||||||
|
timeEntries.Save(entry);
|
||||||
|
return new WriteResultDto(true, entry.Id, "Zeiteintrag gespeichert.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2493,13 +2493,49 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
|
|||||||
→ `tools/list` → `tools/call get_students` über die reale Named Pipe) bestätigt die volle
|
→ `tools/list` → `tools/call get_students` über die reale Named Pipe) bestätigt die volle
|
||||||
Kette; 9 Unit-Tests für Tool-Filterlogik und die Scope-Allowlist in
|
Kette; 9 Unit-Tests für Tool-Filterlogik und die Scope-Allowlist in
|
||||||
[McpToolsTests.cs](LehrerApp.Desktop.Tests/McpToolsTests.cs).
|
[McpToolsTests.cs](LehrerApp.Desktop.Tests/McpToolsTests.cs).
|
||||||
- **Bewusst zurückgestellt (spätere Phasen):** Write-Tools samt Bestätigungsdialog-UI,
|
- **Bewusst zurückgestellt (spätere Phasen):** Write-Tools samt Bestätigungsdialog-UI
|
||||||
`get_lesson_plans`, Worksheets-Tools (`list_worksheets`/`download_worksheet`/...),
|
(folgt in 4.5.26), `get_lesson_plans` (folgt in 4.5.26), Worksheets-Tools
|
||||||
macOS-Bundle-Signierung der Bridge-Binary, Named-Pipe-basierte
|
(`list_worksheets`/`download_worksheet`/...), macOS-Bundle-Signierung der Bridge-Binary,
|
||||||
Einzelinstanz-Absicherung (die Spec-Idee dazu funktioniert nicht, da die Pipe nur bei
|
Named-Pipe-basierte Einzelinstanz-Absicherung (die Spec-Idee dazu funktioniert nicht, da
|
||||||
aktiviertem Opt-in existiert — LehrerApp hat ohnehin noch keinen
|
die Pipe nur bei aktiviertem Opt-in existiert — LehrerApp hat ohnehin noch keinen
|
||||||
Single-Instance-Mechanismus, unabhängig von MCP).
|
Single-Instance-Mechanismus, unabhängig von MCP).
|
||||||
|
|
||||||
|
- [x] **4.5.26** Lokaler MCP-Server, Phase 2 (Write-Tools + Bestätigungsdialog + Lesson-Plans),
|
||||||
|
2026-09-11, direkte Fortsetzung von 4.5.25.
|
||||||
|
- **Bestätigungsdialog:** `IMcpConfirmationService`/`AvaloniaMcpConfirmationService`
|
||||||
|
([Services/Mcp/AvaloniaMcpConfirmationService.cs](LehrerApp.Desktop/Services/Mcp/AvaloniaMcpConfirmationService.cs))
|
||||||
|
zeigt den bereits vorhandenen `ConfirmDialog` (Views/Shared, bisher nur intern genutzt) über
|
||||||
|
`Dispatcher.UIThread.InvokeAsync` an, obwohl der aufrufende Tool-Handler auf dem
|
||||||
|
Pipe-Session-Hintergrund-Thread läuft — "kein silent write": ein Write-Tool schreibt nie,
|
||||||
|
ohne dass der Nutzer den konkreten (menschenlesbaren, nicht bloß JSON/GUIDs) Vorschlag
|
||||||
|
gesehen und bestätigt hat. 2 Minuten Timeout (schließt den Dialog automatisch und wertet als
|
||||||
|
abgelehnt), damit eine hängende Pipe-Session nicht unbegrenzt den wartenden KI-Client blockiert.
|
||||||
|
Interface gehalten, damit Write-Tool-Tests ohne echtes UI laufen (`FakeMcpConfirmation` in
|
||||||
|
[Fakes.cs](LehrerApp.Desktop.Tests/Fakes.cs)).
|
||||||
|
- **Neue Tools:** `create_time_entry`, `create_grade_entry` (beide Write, mit Bestätigung),
|
||||||
|
`update_student_group_assignment` (Write; legt `GroupMembership` an oder ändert nur
|
||||||
|
Niveau/Zeitraum/Beitritt/Austritt — bei bereits identischem Stand keine erneute Nachfrage),
|
||||||
|
`get_lesson_plans` (Read; `Unit`+`Lesson` einer Gruppe im Zeitraum). `McpToolScope` um
|
||||||
|
`AllowedWriteTools` erweitert, `McpServerHostedService` prüft per `Debug.Assert` weiterhin,
|
||||||
|
dass die tatsächlich registrierten Tools exakt der Allowlist entsprechen.
|
||||||
|
- **Bewusste Abweichung von der Spec:** `create_note` (Spec: "nur für nicht-sensible
|
||||||
|
Notiztypen") wird **nicht** umgesetzt — das einzige existierende Notiz-Modell
|
||||||
|
(`Documentation`, Gesprächsnotizen/Vorfälle/Förderpläne) ist bereits vollständig
|
||||||
|
MCP-ausgeschlossen (4.5.25), ein separates "nicht-sensibles" Notiz-Konzept existiert im
|
||||||
|
Datenmodell nicht und würde eine neue Entität erfinden, nur um die Spec-Zeile zu erfüllen.
|
||||||
|
- **`create_lesson_plan`/`update_lesson_plan`** bewusst weiter zurückgestellt: `Lesson` ist
|
||||||
|
das mit Abstand komplexeste Modell (verschachtelte `Phases`, Anhänge) und verdient einen
|
||||||
|
eigenen Schritt statt in Phase 2 mit reinzurutschen.
|
||||||
|
- **Verifiziert:** End-to-End-Smoke-Test wie in 4.5.25, erweitert um einen echten
|
||||||
|
`create_time_entry`-Aufruf über Bridge → Pipe → `McpServer` → Confirmation-Callback →
|
||||||
|
Repository-Save (mit automatisch bestätigender Test-`IMcpConfirmationService`-Instanz statt
|
||||||
|
echtem Dialog) — bestätigt, dass async Write-Tool-Handler mit `CancellationToken`-Bindung
|
||||||
|
durch das SDK korrekt funktionieren. 18 Unit-Tests in
|
||||||
|
[McpToolsTests.cs](LehrerApp.Desktop.Tests/McpToolsTests.cs) (vorher 9), decken u.a. ab:
|
||||||
|
Bestätigung/Ablehnung je Write-Tool, dass die Bestätigungsnachricht den Schülernamen statt
|
||||||
|
einer rohen GUID enthält, und dass eine unveränderte Gruppenmitgliedschaft keine erneute
|
||||||
|
Nachfrage auslöst.
|
||||||
|
|
||||||
**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`,
|
||||||
|
|||||||
Reference in New Issue
Block a user