Files
LehrerApp/LehrerApp.Desktop/Services/Mcp/Tools/LetterTemplateTools.cs
T
adminandClaude Sonnet 5 4542f263fc
CI / build-and-test (push) Waiting to run
fix: Anwesenheitskalender respektiert die von der Vorlage vorgegebene Boxhöhe
Nachdem die Kalender-Breite jetzt an die reale DRAWBOX/FLOWDRAWBOX-Breite
gekoppelt ist, konnte das (korrekt größer werdende) Raster die von der
Vorlage deklarierte Höhe überschreiten - eine DRAWBOX bricht anders als eine
FLOWDRAWBOX nicht automatisch auf Folgeseiten um, überschüssiger Inhalt wird
von QuestTemplateRenderer stillschweigend am unteren Rand abgeschnitten.

StudentAttendanceCalendarDrawingBuilder ermittelt jetzt vorab, wie viele
Wochen die gewählten Monate brauchen, und verkleinert "Größe" automatisch so
weit, dass der Kalender innerhalb der deklarierten Höhe bleibt (nur bei
DRAWBOX/IsFixed - eine FLOWDRAWBOX darf weiterhin frei wachsen, da sie bei
Bedarf auf weitere Seiten fließt statt abzuschneiden).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 14:39:45 +02:00

146 lines
8.6 KiB
C#

using System.ComponentModel;
using System.Globalization;
using LehrerApp.Core.Interfaces;
using LehrerApp.Desktop.Services;
using LehrerApp.Templating;
namespace LehrerApp.Desktop.Services.Mcp.Tools;
/// <summary>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
/// <c>.lavorlage</c>-Elternbrief-Vorlagen (<see cref="TemplateStore"/>), an Schüler+Kontakt
/// gebunden, exakt wie im bestehenden "Elternbrief"-Dialog
/// (<c>CreateLetterDialogViewModel</c>). 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.</summary>
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<LetterTemplateDto> ListLetterTemplates()
{
var result = new List<LetterTemplateDto>();
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<string, string>? 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 calendarBox = LetterPlaceholderBuilder.FindDeclaredDrawingBox(loaded,
StudentAttendanceCalendarDrawingBuilder.PlaceholderName) ?? new(170, float.PositiveInfinity, 1, false);
var absenceDayListBox = LetterPlaceholderBuilder.FindDeclaredDrawingBox(loaded,
StudentAbsenceDayListDrawingBuilder.PlaceholderName) ?? new(170, float.PositiveInfinity, 1, false);
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText,
teacherName, attendanceCalendars?.Build(student, new AttendanceCalendarOptions(date, 1),
calendarBox.Width, calendarBox.MillimeterScale,
calendarBox.IsFixed ? calendarBox.Height : float.PositiveInfinity),
attendanceCalendars?.BuildAbsenceDayList(student, new AttendanceCalendarOptions(date, 1),
absenceDayListBox.Width, absenceDayListBox.MillimeterScale));
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<string, PlaceholderValue> values) : ITemplateDataProvider
{
public IReadOnlyDictionary<string, PlaceholderValue> GetValues() => values;
}
}