CI / build-and-test (push) Waiting to run
Die DrawingValue-Builder gingen bisher immer von einer festen Breite (170) aus, unabhängig davon, wie breit die DRAWBOX/FLOWDRAWBOX in der jeweiligen Vorlage tatsächlich deklariert ist. Bei einer breiteren Box (oder einer in "pt" statt "mm" deklarierten Seite, wie im echten Fehlzeitenbrief-Template) blieb dadurch ein Großteil der eigentlich verfügbaren Fläche ungenutzt leer - sichtbar als "Luft" rechts und unterhalb des Kalenders im generierten PDF. CreateLetterDialogViewModel/LetterTemplateTools ermitteln jetzt die real deklarierte Boxbreite samt Maßeinheit aus dem geladenen Template (LetterPlaceholderBuilder.FindDeclaredDrawingBox) und reichen sie an die Builder durch, die ihr mm-entworfenes Raster entsprechend skalieren. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
145 lines
8.5 KiB
C#
145 lines
8.5 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, 1);
|
|
var absenceDayListBox = LetterPlaceholderBuilder.FindDeclaredDrawingBox(loaded,
|
|
StudentAbsenceDayListDrawingBuilder.PlaceholderName) ?? new(170, 1);
|
|
var values = LetterPlaceholderBuilder.BuildStandardValues(student, contact, group, date, letterText,
|
|
teacherName, attendanceCalendars?.Build(student, new AttendanceCalendarOptions(date, 1),
|
|
calendarBox.Width, calendarBox.MillimeterScale),
|
|
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;
|
|
}
|
|
}
|