feat: Chemikalien-Recherche über lokale RiSU-Stoffliste (kein KI-Aufruf mehr)

Ersetzt den ursprünglichen KI-Websuche-Ansatz (GESTIS scheiterte sowohl über
Websuche als auch über direkten API-Zugriff an fehlenden schulspezifischen
Tätigkeitsbeschränkungen) durch einen reinen lokalen Datenbank-Lookup gegen die
offizielle RiSU-Stoffliste (1764 Stoffe inkl. Tätigkeitsbeschränkungs-Codes,
Legende zur Abfragezeit übersetzt statt in die Datenbank gebacken). Keine
KI-Kosten, keine Drittanbieter-Abhängigkeit mehr für diese Funktion.

HazardSubstance.AgeRestriction zu ActivityRestriction umbenannt, da die
Tätigkeitsbeschränkung mehr abdeckt als reine Altersgrenzen. Details und
verworfene Zwischenstände in TODO.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 23:43:56 +02:00
co-authored by Claude Sonnet 5
parent 038337997f
commit 7b883555bf
14 changed files with 647 additions and 37 deletions
@@ -76,6 +76,29 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
Converters = { new GermanDateOnlyJsonConverter(), new GermanTimeOnlyJsonConverter() },
};
/// <summary>
/// Baut eine nutzergerichtete Fehlermeldung für eine fehlgeschlagene Backend-Antwort, die
/// weder 401 noch 402 war (die haben eigene, feste Meldungen). Liest den vom Backend
/// gesendeten Klartext-Grund (<c>ai_backend_fail</c> liefert immer <c>{"error": "..."}</c>)
/// mit, statt nur eine pauschale Meldung zu zeigen — ohne das war z.B. beim Fehlschlagen der
/// Websuche in substance.php (falscher Tool-Bezeichner, fehlender Beta-Header o.ä.) der
/// eigentliche Grund nirgends sichtbar, nur "Die Anfrage ist fehlgeschlagen".
/// </summary>
private static async Task<AiBackendException> BuildRequestFailedExceptionAsync(HttpResponseMessage resp)
{
string? backendReason = null;
try
{
var body = await resp.Content.ReadFromJsonAsync<BackendErrorResult>(JsonOptions);
backendReason = string.IsNullOrWhiteSpace(body?.Error) ? null : body!.Error;
}
catch { /* Antwortkörper war kein valides {"error": "..."}-JSON - Fallback unten greift. */ }
return new AiBackendException(backendReason is null
? "Die Anfrage an den KI-Dienst ist fehlgeschlagen."
: $"Die Anfrage an den KI-Dienst ist fehlgeschlagen: {backendReason}");
}
public async Task<string> LoginAsync(string username, string password)
{
HttpResponseMessage resp;
@@ -327,7 +350,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
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.");
throw await BuildRequestFailedExceptionAsync(resp);
try
{
@@ -368,7 +391,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
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.");
throw await BuildRequestFailedExceptionAsync(resp);
try
{
@@ -409,7 +432,7 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
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.");
throw await BuildRequestFailedExceptionAsync(resp);
try
{
@@ -422,6 +445,46 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
}
}
/// <summary>
/// Fragt sicherheitsrelevante Daten zu einer Chemikalie ab (Nutzerwunsch neben 4.2): eigener
/// Endpunkt <c>ai-backend/substance.php</c>, serverseitig dateibasiert gecacht (Name/CAS,
/// Ablauffrist je nach Einstufung) — wiederholte Anfragen für denselben Stoff verursachen
/// nach dem ersten Mal keine weiteren Kosten mehr. Kein Unit-/Guppen-Kontext nötig, nur der
/// eingetippte Stoffname.
/// </summary>
public async Task<AiSubstanceResearchResponse> RequestSubstanceResearchAsync(string substanceName, string token)
{
using var req = new HttpRequestMessage(HttpMethod.Post, "substance.php")
{
Content = JsonContent.Create(new { name = substanceName }, 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 await BuildRequestFailedExceptionAsync(resp);
try
{
var result = await resp.Content.ReadFromJsonAsync<AiSubstanceResearchResponse>(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
@@ -478,4 +541,5 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
private class LoginResult { public string Token { get; set; } = ""; }
private class BalanceResult { public decimal BalanceUsd { get; set; } }
private class BackendErrorResult { public string? Error { get; set; } }
}
+33 -26
View File
@@ -88,7 +88,8 @@ public sealed record HazardAssessmentPrintData(
bool IsAiAssisted);
public sealed record HazardSubstancePrintRow(
string Name, string Amount, string Pictograms, string HStatements, string PStatements);
string Name, string Cas, string Amount, string Pictograms, string SignalWord,
string HStatements, string PStatements, string ActivityRestriction);
/// <summary>
/// PDF-Erzeugung über QuestPDF (11.3). Liefert fertige Bytes - der Speicherdialog läuft wie bei
@@ -380,7 +381,7 @@ public sealed class PdfExportService
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));
column.Item().Element(c => SubstanceBlocks(c, data.Substances));
if (data.Hazards.Count > 0)
column.Item().Element(c => BulletSection(c, "Gefährdungen", data.Hazards));
if (data.ProtectiveMeasures.Count > 0)
@@ -419,37 +420,43 @@ public sealed class PdfExportService
column.Item().Text($"• {item}");
});
private static void SubstanceTable(IContainer container, IReadOnlyList<HazardSubstancePrintRow> rows) =>
/// Eigener Block je Gefahrstoff statt einer engen Tabelle — bei sieben Datenpunkten pro Stoff
/// (inkl. CAS, Signalwort, Altersbeschränkung) wäre eine Tabelle auf A4-Hochformat zu schmal
/// für lesbare H-/P-Satz-Texte.
private static void SubstanceBlocks(IContainer container, IReadOnlyList<HazardSubstancePrintRow> rows) =>
container.Column(column =>
{
column.Spacing(6);
column.Item().Text("Gefahrstoffe").FontSize(11).SemiBold();
column.Item().Table(table =>
foreach (var row in rows)
column.Item().Element(c => SubstanceBlock(c, row));
});
private static void SubstanceBlock(IContainer container, HazardSubstancePrintRow row) =>
container.Border(0.8f).BorderColor(Colors.Grey.Lighten2).Padding(6).Column(block =>
{
block.Spacing(2);
block.Item().Row(header =>
{
table.ColumnsDefinition(columns =>
header.RelativeItem().Text(text =>
{
columns.RelativeColumn(2f);
columns.RelativeColumn(1f);
columns.RelativeColumn(1.3f);
columns.RelativeColumn(2f);
columns.RelativeColumn(2f);
text.Span(row.Name).SemiBold();
if (!string.IsNullOrWhiteSpace(row.Cas)) text.Span($" · CAS {row.Cas}").FontColor(Colors.Grey.Darken1);
if (!string.IsNullOrWhiteSpace(row.Amount)) text.Span($" · {row.Amount}").FontColor(Colors.Grey.Darken1);
});
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);
}
if (!string.IsNullOrWhiteSpace(row.SignalWord))
header.ConstantItem(70).AlignRight().Text(row.SignalWord).SemiBold()
.FontColor(Colors.Red.Darken2);
});
if (!string.IsNullOrWhiteSpace(row.Pictograms))
block.Item().Text($"GHS: {row.Pictograms}").FontSize(8).FontColor(Colors.Grey.Darken1);
if (!string.IsNullOrWhiteSpace(row.HStatements))
block.Item().Text($"H-Sätze: {row.HStatements}");
if (!string.IsNullOrWhiteSpace(row.PStatements))
block.Item().Text($"P-Sätze: {row.PStatements}");
if (!string.IsNullOrWhiteSpace(row.ActivityRestriction))
block.Item().Text($"Tätigkeitsbeschränkung: {row.ActivityRestriction}").SemiBold()
.FontColor(Colors.Orange.Darken3);
});
/// <summary>Horizontaler Balken, 0-100 % der verfügbaren Zellenbreite (max. 150pt).</summary>