@@ -1,12 +1,34 @@
using System.ComponentModel ;
using System.Text ;
using LehrerApp.Core.Interfaces ;
using LehrerApp.Core.Models ;
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 )
/// <summary>MCP-Tools rund um Unterrichtseinheiten (<see cref="Unit"/>) und Einzelstunden
/// (<see cref="Lesson"/>) — Phase 1 (get_lesson_plans) und Phase 3 (siehe Planungsdokument).
///
/// Bewusst kleinteilig statt eines einzelnen "update_lesson", das die komplette Stunde inkl.
/// Verlaufsplan als ein großes JSON-Objekt tauscht: <see cref="Lesson.Phases"/> ist zwar technisch
/// eine eingebettete Liste im selben LiteDB-Dokument (keine echte Sub-Collection, also kein
/// Zeilen-Locking auf DB-Ebene) — kleinteilige Tools verkürzen aber das Zeitfenster zwischen Lesen
/// und Schreiben je Operation drastisch (ein Tool-Aufruf ändert nur eine Phase, nicht die ganze
/// Stunde) und liefern einen für den Bestätigungsdialog tatsächlich lesbaren Diff statt eines
/// kompletten Objekt-Dumps. Ein Tool, das die ganze Stunde überschreibt, ist bewusst NICHT
/// vorgesehen; wo es fehlt, ist die Kombination aus update_lesson (Metadaten) +
/// add/update/remove_lesson_phase (je eine Phase) der vorgesehene Weg.</summary>
public class LessonPlanTools (
IUnitRepository units , ILessonRepository lessons , IGroupRepository groups ,
IAttachmentStorage attachments , IMcpConfirmationService confirmation )
{
/// <summary>Deckelt die Antwortgröße von "download_lesson_attachment" (Base64 bläht ca. um
/// Faktor 1,33 auf). Kleiner als <see cref="IAttachmentStorage.MaxSizeBytes"/> (App-weites
/// Limit), damit ein einzelner MCP-Tool-Aufruf nicht unnötig groß wird — siehe Planungsdokument
/// zum offenen Punkt "Ressourcen statt Inline-Base64 für große Dateien".</summary>
public const long MaxInlineAttachmentBytes = 3 * 1024 * 1024 ;
// ── Lesen ────────────────────────────────────────────────────────────────────────────────
[Description("Listet Unterrichtseinheiten und -stunden einer Lerngruppe; die Einzelstunden werden auf den angegebenen Zeitraum gefiltert.")]
public LessonPlanResultDto GetLessonPlans (
[Description("Lerngruppen-ID.")] Guid groupId ,
@@ -17,10 +39,227 @@ public class LessonPlanTools(IUnitRepository units, ILessonRepository lessons)
. 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 ( ) ) )
. Select ( ToDto )
. ToList ( ) ;
return new LessonPlanResultDto ( unitDtos , lessonDtos ) ;
}
[Description("Lädt den Inhalt eines an eine Einzelstunde angehängten Materials (z.B. Arbeitsblatt) Base64-kodiert herunter. Für die storageId siehe get_lesson_plans.")]
public AttachmentContentDto DownloadLessonAttachment (
[Description("ID der Einzelstunde.")] Guid lessonId ,
[Description("Speicher-ID des Anhangs, aus get_lesson_plans.")] string storageId )
{
var lesson = lessons . GetById ( lessonId ) ? ? throw new InvalidOperationException ( "Unbekannte Stunden-ID." ) ;
var attachment = lesson . Attachments . FirstOrDefault ( a = > a . StorageId = = storageId )
? ? throw new InvalidOperationException ( "Kein Anhang mit dieser Speicher-ID an dieser Stunde." ) ;
if ( attachment . SizeBytes > MaxInlineAttachmentBytes )
throw new InvalidOperationException (
$"Anhang ist mit {attachment.SizeBytes / 1024 / 1024} MB zu groß für eine Inline-Antwort (Limit {MaxInlineAttachmentBytes / 1024 / 1024} MB)." ) ;
using var stream = attachments . OpenRead ( storageId )
? ? throw new InvalidOperationException ( "Anhang-Inhalt nicht auffindbar (Speicher inkonsistent)." ) ;
using var buffer = new MemoryStream ( ) ;
stream . CopyTo ( buffer ) ;
return new AttachmentContentDto ( attachment . FileName , attachment . SizeBytes , Convert . ToBase64String ( buffer . ToArray ( ) ) ) ;
}
// ── Unterrichtseinheiten (Unit) ──────────────────────────────────────────────────────────
[Description("Legt eine neue Unterrichtseinheit an. Muss der Nutzer erst in einem Dialog in LehrerApp bestätigen.")]
public async Task < WriteResultDto > CreateUnit (
[Description("Lerngruppen-ID.")] Guid groupId ,
[Description("Titel der Einheit.")] string title ,
[Description("Optionales Startdatum, Format YYYY-MM-DD.")] DateOnly ? startDate = null ,
[Description("Optionales Enddatum, Format YYYY-MM-DD.")] DateOnly ? endDate = null ,
CancellationToken ct = default )
{
var group = groups . GetById ( groupId ) ;
if ( group is null ) return new WriteResultDto ( false , null , "Unbekannte Gruppen-ID." ) ;
var message = $"Neue Unterrichtseinheit „{title}“ für {group.Name} anlegen?" +
( startDate is not null | | endDate is not null
? $"\nZeitraum: {startDate:dd.MM.yyyy} – {endDate:dd.MM.yyyy}" : "" ) ;
if ( ! await confirmation . ConfirmAsync ( "Unterrichtseinheit anlegen?" , message , ct ) )
return new WriteResultDto ( false , null , "Vom Nutzer abgelehnt oder nicht bestätigt." ) ;
var unit = new Unit { GroupId = groupId , Title = title , StartDate = startDate , EndDate = endDate } ;
units . Save ( unit ) ;
return new WriteResultDto ( true , unit . Id , "Unterrichtseinheit gespeichert." ) ;
}
[Description("Ändert Titel/Zeitraum/Status einer bestehenden Unterrichtseinheit. Nur angegebene Felder werden geändert.")]
public async Task < WriteResultDto > UpdateUnit (
[Description("ID der Unterrichtseinheit.")] Guid unitId ,
[Description("Neuer Titel. Unverändert lassen: weglassen.")] string? title = null ,
[Description("Neues Startdatum, Format YYYY-MM-DD. Unverändert lassen: weglassen.")] DateOnly ? startDate = null ,
[Description("Neues Enddatum, Format YYYY-MM-DD. Unverändert lassen: weglassen.")] DateOnly ? endDate = null ,
[Description("Neuer Status: Planned, Active oder Completed. Unverändert lassen: weglassen.")] UnitStatus ? status = null ,
CancellationToken ct = default )
{
var unit = units . GetById ( unitId ) ;
if ( unit is null ) return new WriteResultDto ( false , null , "Unbekannte Einheiten-ID." ) ;
var changes = new StringBuilder ( ) ;
if ( title is not null & & title ! = unit . Title ) { changes . AppendLine ( $"Titel: „{unit.Title}“ → „{title}“" ) ; unit . Title = title ; }
if ( startDate is not null & & startDate ! = unit . StartDate ) { changes . AppendLine ( $"Start: {unit.StartDate:dd.MM.yyyy} → {startDate:dd.MM.yyyy}" ) ; unit . StartDate = startDate ; }
if ( endDate is not null & & endDate ! = unit . EndDate ) { changes . AppendLine ( $"Ende: {unit.EndDate:dd.MM.yyyy} → {endDate:dd.MM.yyyy}" ) ; unit . EndDate = endDate ; }
if ( status is not null & & status ! = unit . Status ) { changes . AppendLine ( $"Status: {unit.Status} → {status}" ) ; unit . Status = status . Value ; }
if ( changes . Length = = 0 )
return new WriteResultDto ( true , unit . Id , "Keine Änderung nötig." ) ;
if ( ! await confirmation . ConfirmAsync ( "Unterrichtseinheit ändern?" , $"„{unit.Title}“\n{changes}" . TrimEnd ( ) , ct ) )
return new WriteResultDto ( false , null , "Vom Nutzer abgelehnt oder nicht bestätigt." ) ;
units . Save ( unit ) ;
return new WriteResultDto ( true , unit . Id , "Unterrichtseinheit gespeichert." ) ;
}
// ── Einzelstunden (Lesson) — Metadaten ───────────────────────────────────────────────────
[Description("Legt eine neue Einzelstunde ohne Verlaufsplan-Phasen an. Phasen danach einzeln über add_lesson_phase hinzufügen. Muss der Nutzer erst bestätigen.")]
public async Task < WriteResultDto > CreateLesson (
[Description("ID der übergeordneten Unterrichtseinheit.")] Guid unitId ,
[Description("Datum, Format YYYY-MM-DD.")] DateOnly date ,
[Description("Thema der Stunde.")] string topic ,
[Description("Optionale Stundennummer im Tagesraster.")] int? lessonNumber = null ,
[Description("Optionaler Stundenbeginn, Format HH:mm.")] TimeOnly ? startTime = null ,
CancellationToken ct = default )
{
var unit = units . GetById ( unitId ) ;
if ( unit is null ) return new WriteResultDto ( false , null , "Unbekannte Einheiten-ID." ) ;
var message = $"Neue Stunde „{topic}“ am {date:dd.MM.yyyy} in Einheit „{unit.Title}“ anlegen?" ;
if ( ! await confirmation . ConfirmAsync ( "Einzelstunde anlegen?" , message , ct ) )
return new WriteResultDto ( false , null , "Vom Nutzer abgelehnt oder nicht bestätigt." ) ;
var lesson = new Lesson
{
UnitId = unitId ,
GroupId = unit . GroupId ,
Date = date ,
Topic = topic ,
LessonNumber = lessonNumber ,
StartTime = startTime ,
} ;
lessons . Save ( lesson ) ;
return new WriteResultDto ( true , lesson . Id , "Einzelstunde gespeichert." ) ;
}
[Description("Ändert Metadaten einer bestehenden Einzelstunde (Thema, Hausaufgabe, Status, Beginn, Stundennummer) — der Verlaufsplan (Phasen) bleibt unverändert. Nur angegebene Felder werden geändert.")]
public async Task < WriteResultDto > UpdateLesson (
[Description("ID der Einzelstunde.")] Guid lessonId ,
[Description("Neues Thema. Unverändert lassen: weglassen.")] string? topic = null ,
[Description("Neue Hausaufgabe. Unverändert lassen: weglassen.")] string? homework = null ,
[Description("Neuer Status: Planned, Conducted, Draft oder Ready. Unverändert lassen: weglassen.")] LessonStatus ? status = null ,
[Description("Neuer Stundenbeginn, Format HH:mm. Unverändert lassen: weglassen.")] TimeOnly ? startTime = null ,
[Description("Neue Stundennummer. Unverändert lassen: weglassen.")] int? lessonNumber = null ,
CancellationToken ct = default )
{
var lesson = lessons . GetById ( lessonId ) ;
if ( lesson is null ) return new WriteResultDto ( false , null , "Unbekannte Stunden-ID." ) ;
var changes = new StringBuilder ( ) ;
if ( topic is not null & & topic ! = lesson . Topic ) { changes . AppendLine ( $"Thema: „{lesson.Topic}“ → „{topic}“" ) ; lesson . Topic = topic ; }
if ( homework is not null & & homework ! = lesson . Homework ) { changes . AppendLine ( $"Hausaufgabe: „{lesson.Homework}“ → „{homework}“" ) ; lesson . Homework = homework ; }
if ( status is not null & & status ! = lesson . Status ) { changes . AppendLine ( $"Status: {lesson.Status} → {status}" ) ; lesson . Status = status . Value ; }
if ( startTime is not null & & startTime ! = lesson . StartTime ) { changes . AppendLine ( $"Beginn: {lesson.StartTime:HH\\:mm} → {startTime:HH\\:mm}" ) ; lesson . StartTime = startTime ; }
if ( lessonNumber is not null & & lessonNumber ! = lesson . LessonNumber ) { changes . AppendLine ( $"Nr.: {lesson.LessonNumber} → {lessonNumber}" ) ; lesson . LessonNumber = lessonNumber ; }
if ( changes . Length = = 0 )
return new WriteResultDto ( true , lesson . Id , "Keine Änderung nötig." ) ;
if ( ! await confirmation . ConfirmAsync ( "Einzelstunde ändern?" , $"„{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy}\n{changes}" . TrimEnd ( ) , ct ) )
return new WriteResultDto ( false , null , "Vom Nutzer abgelehnt oder nicht bestätigt." ) ;
lessons . Save ( lesson ) ;
return new WriteResultDto ( true , lesson . Id , "Einzelstunde gespeichert." ) ;
}
// ── Einzelstunden (Lesson) — Verlaufsplan-Phasen ─────────────────────────────────────────
[Description("Fügt einer Einzelstunde eine neue Verlaufsplan-Phase hinzu (ans Ende). Muss der Nutzer erst bestätigen.")]
public async Task < WriteResultDto > AddLessonPhase (
[Description("ID der Einzelstunde.")] Guid lessonId ,
[Description("Name der Phase, z.B. \"Einstieg\", \"Erarbeitung\".")] string name ,
[Description("Dauer in Minuten.")] int durationMinutes ,
[Description("Tätigkeit/Sozialform.")] string activity = "" ,
[Description("Material.")] string material = "" ,
[Description("Kurzsymbol, z.B. \"AB001->S\".")] string shorthand = "" ,
CancellationToken ct = default )
{
var lesson = lessons . GetById ( lessonId ) ;
if ( lesson is null ) return new WriteResultDto ( false , null , "Unbekannte Stunden-ID." ) ;
var message = $"Neue Phase „{name}“ ({durationMinutes} Min.) zu „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} hinzufügen?" ;
if ( ! await confirmation . ConfirmAsync ( "Phase hinzufügen?" , message , ct ) )
return new WriteResultDto ( false , null , "Vom Nutzer abgelehnt oder nicht bestätigt." ) ;
var phase = new LessonPhaseStep
{
Name = name , DurationMinutes = durationMinutes , Activity = activity ,
Material = material , Shorthand = shorthand ,
} ;
lesson . Phases . Add ( phase ) ;
lessons . Save ( lesson ) ;
return new WriteResultDto ( true , phase . Id , "Phase hinzugefügt." ) ;
}
[Description("Ändert eine bestehende Verlaufsplan-Phase einer Einzelstunde. Nur angegebene Felder werden geändert.")]
public async Task < WriteResultDto > UpdateLessonPhase (
[Description("ID der Einzelstunde.")] Guid lessonId ,
[Description("ID der Phase, aus get_lesson_plans.")] Guid phaseId ,
[Description("Neuer Name. Unverändert lassen: weglassen.")] string? name = null ,
[Description("Neue Dauer in Minuten. Unverändert lassen: weglassen.")] int? durationMinutes = null ,
[Description("Neue Tätigkeit. Unverändert lassen: weglassen.")] string? activity = null ,
[Description("Neues Material. Unverändert lassen: weglassen.")] string? material = null ,
[Description("Neues Kurzsymbol. Unverändert lassen: weglassen.")] string? shorthand = null ,
CancellationToken ct = default )
{
var lesson = lessons . GetById ( lessonId ) ;
if ( lesson is null ) return new WriteResultDto ( false , null , "Unbekannte Stunden-ID." ) ;
var phase = lesson . Phases . FirstOrDefault ( p = > p . Id = = phaseId ) ;
if ( phase is null ) return new WriteResultDto ( false , null , "Unbekannte Phasen-ID an dieser Stunde." ) ;
var changes = new StringBuilder ( ) ;
if ( name is not null & & name ! = phase . Name ) { changes . AppendLine ( $"Name: „{phase.Name}“ → „{name}“" ) ; phase . Name = name ; }
if ( durationMinutes is not null & & durationMinutes ! = phase . DurationMinutes ) { changes . AppendLine ( $"Dauer: {phase.DurationMinutes} → {durationMinutes} Min." ) ; phase . DurationMinutes = durationMinutes . Value ; }
if ( activity is not null & & activity ! = phase . Activity ) { changes . AppendLine ( $"Tätigkeit: „{phase.Activity}“ → „{activity}“" ) ; phase . Activity = activity ; }
if ( material is not null & & material ! = phase . Material ) { changes . AppendLine ( $"Material: „{phase.Material}“ → „{material}“" ) ; phase . Material = material ; }
if ( shorthand is not null & & shorthand ! = phase . Shorthand ) { changes . AppendLine ( $"Kürzel: „{phase.Shorthand}“ → „{shorthand}“" ) ; phase . Shorthand = shorthand ; }
if ( changes . Length = = 0 )
return new WriteResultDto ( true , phase . Id , "Keine Änderung nötig." ) ;
if ( ! await confirmation . ConfirmAsync ( "Phase ändern?" , $"Phase „{phase.Name}“ in „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy}\n{changes}" . TrimEnd ( ) , ct ) )
return new WriteResultDto ( false , null , "Vom Nutzer abgelehnt oder nicht bestätigt." ) ;
lessons . Save ( lesson ) ;
return new WriteResultDto ( true , phase . Id , "Phase gespeichert." ) ;
}
[Description("Entfernt eine Verlaufsplan-Phase aus einer Einzelstunde. Muss der Nutzer erst bestätigen.")]
public async Task < WriteResultDto > RemoveLessonPhase (
[Description("ID der Einzelstunde.")] Guid lessonId ,
[Description("ID der Phase, aus get_lesson_plans.")] Guid phaseId ,
CancellationToken ct = default )
{
var lesson = lessons . GetById ( lessonId ) ;
if ( lesson is null ) return new WriteResultDto ( false , null , "Unbekannte Stunden-ID." ) ;
var phase = lesson . Phases . FirstOrDefault ( p = > p . Id = = phaseId ) ;
if ( phase is null ) return new WriteResultDto ( false , null , "Unbekannte Phasen-ID an dieser Stunde." ) ;
var message = $"Phase „{phase.Name}“ ({phase.DurationMinutes} Min.) aus „{lesson.Topic}“ am {lesson.Date:dd.MM.yyyy} entfernen?" ;
if ( ! await confirmation . ConfirmAsync ( "Phase entfernen?" , message , ct ) )
return new WriteResultDto ( false , null , "Vom Nutzer abgelehnt oder nicht bestätigt." ) ;
lesson . Phases . Remove ( phase ) ;
lessons . Save ( lesson ) ;
return new WriteResultDto ( true , phase . Id , "Phase entfernt." ) ;
}
private static LessonDto ToDto ( Lesson l ) = > new (
l . Id , l . UnitId , l . GroupId , l . Date , l . LessonNumber , l . Topic , l . Homework , l . Status ,
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 ( ) ) ;
}