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:
@@ -217,11 +217,14 @@ public static class AppBootstrapper
|
||||
|
||||
// ── MCP-Server (lokal, Phase 1 – siehe Planungsdokument, optional per Opt-in) ─────────
|
||||
services.AddSingleton(_ => new McpSettingsService(appData));
|
||||
services.AddSingleton<IMcpConfirmationService, AvaloniaMcpConfirmationService>();
|
||||
services.AddSingleton<StudentTools>();
|
||||
services.AddSingleton<ExamTools>();
|
||||
services.AddSingleton<GradeTools>();
|
||||
services.AddSingleton<ScheduleTools>();
|
||||
services.AddSingleton<TimeEntryTools>();
|
||||
services.AddSingleton<LessonPlanTools>();
|
||||
services.AddSingleton<GroupMembershipTools>();
|
||||
services.AddSingleton<McpServerHostedService>();
|
||||
|
||||
// ── 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;
|
||||
|
||||
/// <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
|
||||
/// LehrerApp.McpBridge-Instanz) als eigene MCP-Session über <see cref="StreamServerTransport"/> —
|
||||
/// ein <see cref="NamedPipeServerStream"/> ist ein normaler <see cref="Stream"/> und kann direkt
|
||||
@@ -30,11 +30,14 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
||||
public McpServerHostedService(
|
||||
McpSettingsService settings, AppLogger logger,
|
||||
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
||||
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools)
|
||||
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
||||
GroupMembershipTools groupMembershipTools)
|
||||
{
|
||||
_settings = settings;
|
||||
_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
|
||||
@@ -109,11 +112,12 @@ public sealed class McpServerHostedService : IAsyncDisposable
|
||||
|
||||
private static McpServerOptions BuildServerOptions(
|
||||
StudentTools studentTools, ExamTools examTools, GradeTools gradeTools,
|
||||
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools)
|
||||
ScheduleTools scheduleTools, TimeEntryTools timeEntryTools, LessonPlanTools lessonPlanTools,
|
||||
GroupMembershipTools groupMembershipTools)
|
||||
{
|
||||
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
|
||||
{
|
||||
@@ -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.");
|
||||
AddTool(examTools.GetExams, "get_exams",
|
||||
AddReadTool(examTools.GetExams, "get_exams",
|
||||
"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.");
|
||||
AddTool(scheduleTools.GetSchedule, "get_schedule",
|
||||
AddReadTool(scheduleTools.GetSchedule, "get_schedule",
|
||||
"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.");
|
||||
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(
|
||||
toolCollection.Select(t => t.ProtocolTool.Name).OrderBy(n => n)
|
||||
.SequenceEqual(McpToolScope.AllowedReadTools.OrderBy(n => n)),
|
||||
"Registrierte MCP-Tools weichen von McpToolScope.AllowedReadTools ab.");
|
||||
.SequenceEqual(McpToolScope.AllowedReadTools.Concat(McpToolScope.AllowedWriteTools).OrderBy(n => n)),
|
||||
"Registrierte MCP-Tools weichen von McpToolScope ab.");
|
||||
|
||||
return new McpServerOptions
|
||||
{
|
||||
|
||||
@@ -17,5 +17,15 @@ public static class McpToolScope
|
||||
"get_grades",
|
||||
"get_schedule",
|
||||
"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(
|
||||
Guid Id, Guid? TaskId, string Category, Guid? GroupId, DateOnly Date,
|
||||
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 LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
|
||||
/// <summary>MCP-Read-Tool "get_grades" (Phase 1, siehe Planungsdokument).</summary>
|
||||
public class GradeTools(IGradeRepository grades)
|
||||
/// <summary>MCP-Tools "get_grades" (Phase 1) und "create_grade_entry" (Phase 2), siehe
|
||||
/// Planungsdokument.</summary>
|
||||
public class GradeTools(IGradeRepository grades, IStudentRepository students, IMcpConfirmationService confirmation)
|
||||
{
|
||||
[Description("Listet Noten einer Lerngruppe, optional gefiltert auf einen einzelnen Schüler.")]
|
||||
public List<GradeDto> GetGrades(
|
||||
@@ -15,4 +17,54 @@ public class GradeTools(IGradeRepository grades)
|
||||
return list.Select(g => new GradeDto(
|
||||
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 LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Desktop.Services.Mcp.Tools;
|
||||
|
||||
/// <summary>MCP-Read-Tool "get_time_entries" (Phase 1, siehe Planungsdokument). Der Zeitraum ist
|
||||
/// Pflicht (nicht optional), damit eine unbedachte Anfrage nicht die gesamte Zeiterfassungshistorie
|
||||
/// zurückgibt.</summary>
|
||||
public class TimeEntryTools(ITimeEntryRepository timeEntries)
|
||||
/// <summary>MCP-Tools "get_time_entries" (Phase 1) und "create_time_entry" (Phase 2), siehe
|
||||
/// Planungsdokument. Der Zeitraum bei "get_time_entries" ist Pflicht (nicht optional), damit eine
|
||||
/// unbedachte Anfrage nicht die gesamte Zeiterfassungshistorie zurückgibt.</summary>
|
||||
public class TimeEntryTools(ITimeEntryRepository timeEntries, IGroupRepository groups, IMcpConfirmationService confirmation)
|
||||
{
|
||||
[Description("Listet eigene Zeiterfassungs-Einträge in einem Datumsbereich.")]
|
||||
public List<TimeEntryDto> GetTimeEntries(
|
||||
@@ -15,4 +16,34 @@ public class TimeEntryTools(ITimeEntryRepository timeEntries)
|
||||
timeEntries.GetByDateRange(from, to).Select(t => new TimeEntryDto(
|
||||
t.Id, t.TaskId, t.Category, t.GroupId, t.Date, t.StartTime, t.EndTime,
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user