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:
@@ -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