feat: Anhänge je Stunde, Klausur-Sitzplan mischen, Gefährdungsbeurteilungs-Assistent

Lesson bekommt dieselbe Anhang-Infrastruktur wie Documentation (Material,
Arbeitsblätter, Experimentunterlagen), samt Fix einer Sync-Lücke, die Anhang-
Dateibytes bisher nur für Documentation statt generisch übertragen hat
(IHasAttachments). Sitzplan-Tab bekommt einen "Plätze mischen"-Button für
Klausursitzpläne. Neu: mehrschrittiger Gefährdungsbeurteilungs-Assistent mit
optionalem KI-Entwurf (ai-backend/gbu.php) und PDF-Export, Format bewusst als
JSON-Anhang statt eigener Datenbank-Entität. Details und Architekturentscheidungen
in TODO.md (4.2, 7.1.5, 10.1.8).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 17:13:14 +02:00
co-authored by Claude Sonnet 5
parent 331033db5c
commit 038337997f
29 changed files with 1886 additions and 22 deletions
@@ -381,6 +381,47 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
}
}
/// <summary>
/// Fragt einen Entwurf für eine Gefährdungsbeurteilung zu genau dieser Lesson ab (Nutzerwunsch
/// neben 4.2 "Anhänge je Stunde"): die KI erkennt aus Thema/Verlaufsplan das Experiment und
/// liefert Stoffe, Gefährdungen, Schutzmaßnahmen etc. als Entwurf zur Weiterbearbeitung im
/// Gefährdungsbeurteilungs-Assistenten. Ändert nichts an der Lesson selbst.
/// </summary>
public async Task<AiHazardAssessmentResponse> RequestHazardAssessmentDraftAsync(Unit unit, AiLesson lesson, string token)
{
var request = new AiHazardAssessmentRequest { Unit = BuildContext(unit, ""), Lesson = lesson };
using var req = new HttpRequestMessage(HttpMethod.Post, "gbu.php")
{
Content = JsonContent.Create(request, options: JsonOptions),
};
req.Headers.Authorization = new("Bearer", token);
HttpResponseMessage resp;
try { resp = await http.SendAsync(req); }
catch (HttpRequestException)
{
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
}
if (resp.StatusCode == HttpStatusCode.Unauthorized)
throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden.");
if (resp.StatusCode == (HttpStatusCode)402)
throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
if (!resp.IsSuccessStatusCode)
throw new AiBackendException("Die Anfrage an den KI-Dienst ist fehlgeschlagen.");
try
{
var result = await resp.Content.ReadFromJsonAsync<AiHazardAssessmentResponse>(JsonOptions);
return result ?? throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
}
catch (Exception ex) when (ex is not AiBackendException)
{
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
}
}
/// <summary>
/// Rein (nur Repository-Lesezugriff für den Alternativpfad-Katalog, kein Schreiben) — testbar
/// mit Fakes. Gibt die zu speichernden Lesson-Objekte zurück; der Aufrufer ruft
@@ -73,6 +73,23 @@ public sealed record DocumentationPrintEntry(
bool IsConfidential,
bool IsDraft);
public sealed record HazardAssessmentPrintData(
string Title,
string GroupLabel,
string DateDisplay,
string KindLabel,
string Procedure,
IReadOnlyList<HazardSubstancePrintRow> Substances,
IReadOnlyList<string> Hazards,
IReadOnlyList<string> ProtectiveMeasures,
string FirstAid,
string Disposal,
string Notes,
bool IsAiAssisted);
public sealed record HazardSubstancePrintRow(
string Name, string Amount, string Pictograms, string HStatements, string PStatements);
/// <summary>
/// PDF-Erzeugung über QuestPDF (11.3). Liefert fertige Bytes - der Speicherdialog läuft wie bei
/// allen Exporten über <see cref="ExportService"/>. Lebt bewusst in LehrerApp.Desktop statt Core:
@@ -346,8 +363,95 @@ public sealed class PdfExportService
page.Footer().Element(Footer);
})).GeneratePdf();
public byte[] BuildHazardAssessmentPdf(HazardAssessmentPrintData data) =>
Document.Create(container => container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(36);
page.DefaultTextStyle(style => style.FontSize(9));
page.Header().Element(header => Header(header, $"Gefährdungsbeurteilung · {data.Title}",
$"{data.GroupLabel} · {data.KindLabel} · {data.DateDisplay}"));
page.Content().PaddingVertical(10).Column(column =>
{
column.Spacing(10);
if (data.IsAiAssisted) column.Item().Element(AiDisclaimerBanner);
if (!string.IsNullOrWhiteSpace(data.Procedure))
column.Item().Element(c => TextSection(c, "Durchführung", data.Procedure));
if (data.Substances.Count > 0)
column.Item().Element(c => SubstanceTable(c, data.Substances));
if (data.Hazards.Count > 0)
column.Item().Element(c => BulletSection(c, "Gefährdungen", data.Hazards));
if (data.ProtectiveMeasures.Count > 0)
column.Item().Element(c => BulletSection(c, "Schutzmaßnahmen", data.ProtectiveMeasures));
if (!string.IsNullOrWhiteSpace(data.FirstAid))
column.Item().Element(c => TextSection(c, "Erste Hilfe", data.FirstAid));
if (!string.IsNullOrWhiteSpace(data.Disposal))
column.Item().Element(c => TextSection(c, "Entsorgung", data.Disposal));
if (!string.IsNullOrWhiteSpace(data.Notes))
column.Item().Element(c => TextSection(c, "Weitere Hinweise", data.Notes));
});
page.Footer().Element(Footer);
})).GeneratePdf();
// ── Bausteine ───────────────────────────────────────────────────────────────
private static void AiDisclaimerBanner(IContainer container) =>
container.Background(Colors.Orange.Lighten4).Padding(8).Text(
"⚠️ Enthält KI-generierte Anteile — kein rechtssicheres Dokument. Vor Verwendung " +
"eigenverantwortlich prüfen, insbesondere H-/P-Sätze und Mengenangaben gegen das " +
"Sicherheitsdatenblatt.").FontSize(9).FontColor(Colors.Orange.Darken3).SemiBold();
private static void TextSection(IContainer container, string title, string text) =>
container.Column(column =>
{
column.Item().Text(title).FontSize(11).SemiBold();
column.Item().Text(text);
});
private static void BulletSection(IContainer container, string title, IReadOnlyList<string> items) =>
container.Column(column =>
{
column.Item().Text(title).FontSize(11).SemiBold();
foreach (var item in items)
column.Item().Text($"• {item}");
});
private static void SubstanceTable(IContainer container, IReadOnlyList<HazardSubstancePrintRow> rows) =>
container.Column(column =>
{
column.Item().Text("Gefahrstoffe").FontSize(11).SemiBold();
column.Item().Table(table =>
{
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(2f);
columns.RelativeColumn(1f);
columns.RelativeColumn(1.3f);
columns.RelativeColumn(2f);
columns.RelativeColumn(2f);
});
table.Header(header =>
{
header.Cell().Element(HeaderCell).Text("Stoff").SemiBold();
header.Cell().Element(HeaderCell).Text("Menge").SemiBold();
header.Cell().Element(HeaderCell).Text("GHS").SemiBold();
header.Cell().Element(HeaderCell).Text("H-Sätze").SemiBold();
header.Cell().Element(HeaderCell).Text("P-Sätze").SemiBold();
});
foreach (var row in rows)
{
table.Cell().Element(BodyCell).Text(row.Name);
table.Cell().Element(BodyCell).Text(row.Amount);
table.Cell().Element(BodyCell).Text(row.Pictograms);
table.Cell().Element(BodyCell).Text(row.HStatements);
table.Cell().Element(BodyCell).Text(row.PStatements);
}
});
});
/// <summary>Horizontaler Balken, 0-100 % der verfügbaren Zellenbreite (max. 150pt).</summary>
private static void Bar(IContainer container, double percent, string color) =>
container.MaxWidth(150).Height(7).Background(Colors.Grey.Lighten3)