using System.ComponentModel; using System.Globalization; using LehrerApp.Core.Interfaces; using LehrerApp.Desktop.Services; using LehrerApp.Templating; namespace LehrerApp.Desktop.Services.Mcp.Tools; /// MCP-Tools "list_letter_templates"/"render_letter" (siehe Planungsdokument, Abschnitt /// "Worksheets"). Die ursprüngliche Spec sah "Arbeitsblätter" mit Fach-/Klassenstufe-Metadaten vor — /// das existiert im Datenmodell nicht. Was tatsächlich existiert: importierte /// .lavorlage-Elternbrief-Vorlagen (), an Schüler+Kontakt /// gebunden, exakt wie im bestehenden "Elternbrief"-Dialog /// (CreateLetterDialogViewModel). Bewusst NICHT umgesetzt: "upload_worksheet"/ /// "update_worksheet" im Sinne von "eine neue Vorlage per KI entwerfen" — das Seitenlayout ist eine /// eigene, positionsbasierte DSL (siehe LehrerApp.Templating/LayoutParser.cs), für die es absichtlich /// einen eigenen visuellen Editor (LehrerApp.TemplateDesigner) gibt; ein LLM würde diese DSL blind /// erzeugen müssen, mit hohem Risiko für unbrauchbare/kaputte Layouts. "render_letter" deckt den /// tatsächlich nützlichen Fall ab: eine bestehende, vom Nutzer gestaltete Vorlage mit Werten füllen. public class LetterTemplateTools( TemplateStore templates, ITemplateLoader loader, ITemplateRenderer renderer, IStudentRepository students, IGroupRepository groups, StudentAttendanceCalendarService? attendanceCalendars = null) { [Description("Listet importierte Elternbrief-Vorlagen mit ihren deklarierten Platzhaltern (Name, Typ, Pflichtfeld, konstant).")] public List ListLetterTemplates() { var result = new List(); foreach (var installed in templates.GetTemplates()) { try { var loaded = templates.Load(installed); result.Add(new LetterTemplateDto(installed.Id, installed.Name, installed.Description, loaded.Manifest.Placeholders .Select(p => new PlaceholderInfoDto(p.Name, p.Type.ToString(), p.Required, p.IsConstant)) .ToList())); } catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { // Beschädigtes/ungültiges Paket - wie TemplateStore.GetTemplates() selbst überspringt // auch diese Auflistung es kommentarlos, statt die ganze Liste scheitern zu lassen. } } return result; } [Description("Erzeugt einen Elternbrief aus einer importierten Vorlage für einen Schüler und liefert ihn als Base64-PDF. Reiner Lesezugriff (kein Datenbank-Schreibzugriff), deshalb ohne Bestätigungsdialog.")] public LetterRenderResultDto RenderLetter( [Description("Vorlagen-ID, aus list_letter_templates.")] string templateId, [Description("Schüler-ID.")] Guid studentId, [Description("Brieftext (Fließtext).")] string letterText, [Description("Name der unterschreibenden Lehrkraft.")] string teacherName, [Description("Optionale Kontakt-ID des Schülers. Ohne Angabe wird der erste gültige Kontakt verwendet.")] Guid? contactId = null, [Description("Optionale Lerngruppen-ID für Group.Name/SchoolYear-Platzhalter.")] Guid? groupId = null, [Description("Briefdatum, Format YYYY-MM-DD. Standard: heute.")] DateOnly? letterDate = null, [Description("Zusätzliche, von der Vorlage deklarierte Platzhalterwerte über die Standardfelder hinaus (Name -> Text/Zahl/Datum als Text).")] Dictionary? extraValues = null) { var student = students.GetById(studentId); if (student is null) return new LetterRenderResultDto(false, "Unbekannte Schüler-ID.", null, null); var installed = templates.GetTemplates().FirstOrDefault(t => t.Id == templateId); if (installed is null) return new LetterRenderResultDto(false, "Unbekannte Vorlagen-ID.", null, null); LoadedTemplate loaded; try { loaded = templates.Load(installed); } catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { return new LetterRenderResultDto(false, $"Vorlage ist ungültig: {ex.Message}", null, null); } var contact = contactId is { } cid ? student.Contacts.FirstOrDefault(c => c.Id == cid) : student.Contacts.FirstOrDefault(c => !c.InvalidSince.HasValue); var group = groupId is { } gid ? groups.GetById(gid) : null; var date = letterDate ?? DateOnly.FromDateTime(DateTime.Today); var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText, teacherName, attendanceCalendars?.Build(student, date)); foreach (var (name, raw) in extraValues ?? []) { var definition = loaded.Manifest.Placeholders.FirstOrDefault(p => p.Name == name); if (definition is null || definition.IsConstant) continue; if (TryConvert(definition.Type, raw) is { } converted) values[name] = converted; } var resolved = TemplateDataResolver.Resolve(loaded.Manifest, values); var validation = loader.Validate(loaded, resolved.ToDictionary(x => x.Key, x => x.Value.Type)); var missing = loaded.Manifest.Placeholders .Where(p => !p.IsConstant && p.Required && (!resolved.TryGetValue(p.Name, out var v) || LetterPlaceholderBuilder.IsEmpty(v))) .Select(p => p.Name).ToList(); if (!validation.IsValid || missing.Count > 0) { var issues = validation.Issues.Select(i => i.Message) .Concat(missing.Select(m => $"Pflichtfeld „{m}“ fehlt.")); return new LetterRenderResultDto(false, "Vorlage kann mit diesen Werten nicht gefüllt werden: " + string.Join(" ", issues), null, null); } byte[] pdf; try { pdf = renderer.RenderToPdf(loaded, new DictionaryDataProvider(resolved)); } catch (Exception ex) when (ex is InvalidDataException or TemplateValidationException) { return new LetterRenderResultDto(false, $"Brief konnte nicht erzeugt werden: {ex.Message}", null, null); } var fileName = SanitizeFileName($"{installed.Name}_{student.LastName}_{student.FirstName}.pdf"); return new LetterRenderResultDto(true, "Brief erzeugt.", Convert.ToBase64String(pdf), fileName); } private static PlaceholderValue? TryConvert(PlaceholderType type, string raw) => type switch { PlaceholderType.Text => new TextValue(raw), PlaceholderType.Multiline => new MultilineValue(raw), PlaceholderType.Date => TryParseDate(raw) is { } date ? new DateValue(date) : null, PlaceholderType.Number => TryParseNumber(raw) is { } number ? new NumberValue(number) : null, // Image/Table/Chart/Drawing sind über MCP-Textparameter nicht sinnvoll setzbar. _ => null, }; private static DateOnly? TryParseDate(string raw) => DateOnly.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.None, out var d) || DateOnly.TryParse(raw, CultureInfo.GetCultureInfo("de-DE"), DateTimeStyles.None, out d) ? d : null; private static decimal? TryParseNumber(string raw) => decimal.TryParse(raw, NumberStyles.Number, CultureInfo.InvariantCulture, out var n) || decimal.TryParse(raw, NumberStyles.Number, CultureInfo.GetCultureInfo("de-DE"), out n) ? n : null; private static string SanitizeFileName(string value) { foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; } private sealed class DictionaryDataProvider(IReadOnlyDictionary values) : ITemplateDataProvider { public IReadOnlyDictionary GetValues() => values; } }