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:
@@ -153,3 +153,24 @@ public class AiHazardSubstance
|
||||
public string HStatements { get; set; } = "";
|
||||
public string PStatements { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire-Vertrag für die Chemikalien-Recherche (ai-backend/substance.php, Nutzerwunsch neben 4.2):
|
||||
/// ein reiner Datenbank-Lookup gegen die lokale RiSU-Stoffliste, kein KI-Aufruf (siehe
|
||||
/// ai-backend/stoffliste.php) — der Name "Ai..." bleibt trotzdem bestehen, da der Aufruf über
|
||||
/// dieselbe Anmeldung/denselben Host wie die echten KI-Endpunkte läuft. Eigener, sehr schmaler
|
||||
/// Wire-Vertrag statt Wiederverwendung von AiHazardSubstance — die Recherche liefert
|
||||
/// zusätzliche, nur hier relevante Felder (CAS, Signalwort, Tätigkeitsbeschränkung, Quelle).
|
||||
/// Enthält bewusst KEIN Entsorgungsfeld — die RiSU-Stoffliste führt keine Entsorgungshinweise.
|
||||
/// </summary>
|
||||
public class AiSubstanceResearchResponse
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Cas { get; set; } = "";
|
||||
public List<string> GhsPictograms { get; set; } = [];
|
||||
public string SignalWord { get; set; } = "";
|
||||
public string HStatements { get; set; } = "";
|
||||
public string PStatements { get; set; } = "";
|
||||
public string ActivityRestriction { get; set; } = "";
|
||||
public string Source { get; set; } = "";
|
||||
}
|
||||
|
||||
@@ -31,15 +31,25 @@ public class HazardAssessment
|
||||
public class HazardSubstance
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
/// CAS-Nummer, falls über die Chemikalien-Recherche (TODO.md 4.2-Nachtrag) ermittelt oder von
|
||||
/// Hand eingetragen — optional, nur zur eindeutigeren Wiedererkennung/Recherche.
|
||||
public string Cas { get; set; } = "";
|
||||
/// Typische Einsatzmenge, z.B. "ca. 2 g" — Freitext statt strukturierter Einheit, da der
|
||||
/// Rahmen (fest/Lösung/Gas, Konzentration ...) zu unterschiedlich für ein starres Feld ist.
|
||||
public string Amount { get; set; } = "";
|
||||
public List<GhsPictogram> GhsPictograms { get; set; } = [];
|
||||
/// "Gefahr" oder "Achtung" (GHS-Signalwort) — Freitext statt Enum, da GESTIS-Recherchen auch
|
||||
/// mal ohne eindeutiges Signalwort zurückkommen können.
|
||||
public string SignalWord { get; set; } = "";
|
||||
/// H-Sätze (Gefahrenhinweise) als Freitext, z.B. "H314, H290" oder mit Kurztext — bewusst kein
|
||||
/// eingebauter Katalog amtlicher H-/P-Satz-Texte in der App (Fehlerrisiko bei
|
||||
/// sicherheitsrelevanten Angaben), die Lehrkraft prüft/ergänzt gegen das Sicherheitsdatenblatt.
|
||||
public string HStatements { get; set; } = "";
|
||||
public string PStatements { get; set; } = "";
|
||||
/// Tätigkeitsbeschränkung laut RiSU-Stoffliste (Freigabe für alle/Schulversuche/nur
|
||||
/// Lehrkraft, Altersgrenzen, Ersatzstoffprüfung nötig, verboten, ...) — Freitext, da die
|
||||
/// tatsächlichen Formulierungen zu unterschiedlich für ein starres Feld/Enum sind.
|
||||
public string ActivityRestriction { get; set; } = "";
|
||||
}
|
||||
|
||||
public enum ExperimentKind { Lehrerversuch, Schuelerversuch, Demonstrationsversuch }
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using LehrerApp.Core.AiPlanning;
|
||||
using LehrerApp.Core.Models;
|
||||
using LehrerApp.Desktop.Services;
|
||||
using LehrerApp.Desktop.ViewModels.Groups;
|
||||
using Xunit;
|
||||
|
||||
@@ -8,6 +10,13 @@ public sealed class HazardAssessmentWizardViewModelTests
|
||||
{
|
||||
private static HazardAssessmentWizardViewModel Build(HazardAssessment? editing = null) => new(editing, "10c");
|
||||
|
||||
private static string BuildTempPath()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-gbuwizard-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Neu_StartetAufSchritt0OhneKiOhneEditingKennzeichnung()
|
||||
{
|
||||
@@ -227,6 +236,111 @@ public sealed class HazardAssessmentWizardViewModelTests
|
||||
Assert.Equal("Notiz", vm.Notes);
|
||||
Assert.True(vm.IsAiAssisted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanUseAi_OhneKiAbhaengigkeiten_IstFalseUndSubstanzenKoennenNichtRecherchieren()
|
||||
{
|
||||
var vm = Build();
|
||||
vm.AddSubstanceCommand.Execute(null);
|
||||
|
||||
Assert.False(vm.CanUseAi);
|
||||
Assert.False(vm.Substances[0].CanResearch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanUseAi_MitAngemeldeterKiUndUnitUndLesson_IstTrueUndSubstanzenKoennenRecherchieren()
|
||||
{
|
||||
var aiSettings = new AiSettingsService(BuildTempPath());
|
||||
aiSettings.SetEnabled(true);
|
||||
aiSettings.SetCredentialsAndToken("sebastian", "token-abc");
|
||||
var ai = new AiPlanningService(new HttpClient(), new FakeLessons(), new FakeGroups([]),
|
||||
new FakeSubjects([]), new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
|
||||
var unit = new Unit { Title = "Redoxreaktionen" };
|
||||
var lesson = new Lesson { Topic = "Natrium in Wasser" };
|
||||
|
||||
var vm = new HazardAssessmentWizardViewModel(null, "10c", ai, aiSettings, unit, lesson);
|
||||
vm.AddSubstanceCommand.Execute(null);
|
||||
|
||||
Assert.True(vm.CanUseAi);
|
||||
Assert.True(vm.Substances[0].CanResearch);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class HazardSubstanceEditItemTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Research_OhneOnResearch_TutNichtsUndBleibtOhneFehler()
|
||||
{
|
||||
var item = new HazardSubstanceEditItem { Name = "Natrium" };
|
||||
|
||||
await item.ResearchCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal("", item.ResearchError);
|
||||
Assert.False(item.IsResearching);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Research_OhneNamen_SetztFehlerUndRuftDelegateNicht()
|
||||
{
|
||||
var called = false;
|
||||
var item = new HazardSubstanceEditItem
|
||||
{
|
||||
Name = " ",
|
||||
OnResearch = _ => { called = true; return Task.FromResult(new AiSubstanceResearchResponse()); },
|
||||
};
|
||||
|
||||
await item.ResearchCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.False(called);
|
||||
Assert.Equal("Stoffname eingeben.", item.ResearchError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Research_UebernimmtAlleFelderAusDerAntwort()
|
||||
{
|
||||
var item = new HazardSubstanceEditItem { Name = "Natrium" };
|
||||
item.OnResearch = name =>
|
||||
{
|
||||
Assert.Equal("Natrium", name);
|
||||
return Task.FromResult(new AiSubstanceResearchResponse
|
||||
{
|
||||
Name = "Natrium",
|
||||
Cas = "7440-23-5",
|
||||
SignalWord = "Gefahr",
|
||||
HStatements = "H260",
|
||||
PStatements = "P223, P231+P232",
|
||||
ActivityRestriction = "Für Schüler ab der 5. Klasse freigegeben",
|
||||
GhsPictograms = ["GHS02", "GHS05"],
|
||||
});
|
||||
};
|
||||
|
||||
await item.ResearchCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal("Natrium", item.Name);
|
||||
Assert.Equal("7440-23-5", item.Cas);
|
||||
Assert.Equal("Gefahr", item.SignalWord);
|
||||
Assert.Equal("H260", item.HStatements);
|
||||
Assert.Equal("P223, P231+P232", item.PStatements);
|
||||
Assert.Equal("Für Schüler ab der 5. Klasse freigegeben", item.ActivityRestriction);
|
||||
Assert.True(item.PictogramOptions.Single(o => o.Value == GhsPictogram.Flammable).IsSelected);
|
||||
Assert.True(item.PictogramOptions.Single(o => o.Value == GhsPictogram.Corrosive).IsSelected);
|
||||
Assert.False(item.PictogramOptions.Single(o => o.Value == GhsPictogram.Toxic).IsSelected);
|
||||
Assert.False(item.IsResearching);
|
||||
Assert.Equal("", item.ResearchError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Research_BeiFehler_SetztResearchErrorUndBehaeltAlteFelderUnveraendert()
|
||||
{
|
||||
var item = new HazardSubstanceEditItem { Name = "Unbekannt", HStatements = "H999" };
|
||||
item.OnResearch = _ => throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
|
||||
|
||||
await item.ResearchCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.", item.ResearchError);
|
||||
Assert.Equal("H999", item.HStatements);
|
||||
Assert.False(item.IsResearching);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ExperimentKindDisplayTests
|
||||
|
||||
@@ -162,8 +162,9 @@ public sealed class PdfExportServiceTests
|
||||
"Natrium in Wasser", "10c", "12.09.2026", "Lehrerversuch",
|
||||
"Ein kleines Stück Natrium wird in eine Schale mit Wasser gegeben.",
|
||||
[
|
||||
new HazardSubstancePrintRow("Natrium", "ca. 0,5 g", "GHS02, GHS05",
|
||||
"H260 - Reagiert mit Wasser unter Bildung entzündbarer Gase", "P223, P231+P232, P370+P378"),
|
||||
new HazardSubstancePrintRow("Natrium", "7440-23-5", "ca. 0,5 g", "GHS02, GHS05", "Gefahr",
|
||||
"H260 - Reagiert mit Wasser unter Bildung entzündbarer Gase", "P223, P231+P232, P370+P378",
|
||||
"Abgabe an unter 18-Jährige verboten"),
|
||||
],
|
||||
["Wasserstoffbildung, Verpuffungsgefahr", "Verätzungsgefahr durch Natronlauge"],
|
||||
["Schutzbrille", "Sicherheitsabstand einhalten", "Abzug/Freiluftversuch"],
|
||||
|
||||
@@ -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; } }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -80,32 +80,81 @@ public partial class GhsPictogramOption(GhsPictogram value, string label) : Obse
|
||||
public partial class HazardSubstanceEditItem : ObservableObject
|
||||
{
|
||||
[ObservableProperty] private string _name = "";
|
||||
[ObservableProperty] private string _cas = "";
|
||||
[ObservableProperty] private string _amount = "";
|
||||
[ObservableProperty] private string _signalWord = "";
|
||||
[ObservableProperty] private string _hStatements = "";
|
||||
[ObservableProperty] private string _pStatements = "";
|
||||
[ObservableProperty] private string _activityRestriction = "";
|
||||
[ObservableProperty] private bool _isResearching;
|
||||
[ObservableProperty] private string _researchError = "";
|
||||
public ObservableCollection<GhsPictogramOption> PictogramOptions { get; }
|
||||
public Action<HazardSubstanceEditItem>? OnRemove { get; set; }
|
||||
|
||||
/// Vom Wizard gesetzt (nur wenn eine KI-Backend-Anmeldung vorliegt, siehe CanUseAi — der
|
||||
/// Lookup selbst ruft aber keine KI auf, sondern nur die lokale RiSU-Stoffliste auf dem
|
||||
/// Server, siehe ai-backend/substance.php): führt die eigentliche Chemikalien-Recherche aus.
|
||||
/// Wird vor der Anzeige in der UI (Objektinitialisierer in NewSubstanceItem) endgültig
|
||||
/// gesetzt, daher genügt eine einmalig berechnete Eigenschaft ohne Änderungsbenachrichtigung.
|
||||
public Func<string, Task<AiSubstanceResearchResponse>>? OnResearch { get; set; }
|
||||
public bool CanResearch => OnResearch is not null;
|
||||
|
||||
public HazardSubstanceEditItem(HazardSubstance? source = null)
|
||||
{
|
||||
PictogramOptions = new ObservableCollection<GhsPictogramOption>(
|
||||
GhsPictogramDisplay.BuildOptions(source?.GhsPictograms));
|
||||
if (source is null) return;
|
||||
Name = source.Name;
|
||||
Cas = source.Cas;
|
||||
Amount = source.Amount;
|
||||
SignalWord = source.SignalWord;
|
||||
HStatements = source.HStatements;
|
||||
PStatements = source.PStatements;
|
||||
ActivityRestriction = source.ActivityRestriction;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Remove() => OnRemove?.Invoke(this);
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Research()
|
||||
{
|
||||
if (OnResearch is null) return;
|
||||
if (string.IsNullOrWhiteSpace(Name)) { ResearchError = "Stoffname eingeben."; return; }
|
||||
|
||||
ResearchError = ""; IsResearching = true;
|
||||
try
|
||||
{
|
||||
var result = await OnResearch(Name.Trim());
|
||||
Name = result.Name;
|
||||
Cas = result.Cas;
|
||||
SignalWord = result.SignalWord;
|
||||
HStatements = result.HStatements;
|
||||
PStatements = result.PStatements;
|
||||
ActivityRestriction = result.ActivityRestriction;
|
||||
var pictograms = result.GhsPictograms
|
||||
.Select(GhsPictogramDisplay.FromCode).Where(p => p.HasValue).Select(p => p!.Value).ToHashSet();
|
||||
foreach (var option in PictogramOptions) option.IsSelected = pictograms.Contains(option.Value);
|
||||
}
|
||||
catch (AiBackendException ex)
|
||||
{
|
||||
ResearchError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsResearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
public HazardSubstance ToModel() => new()
|
||||
{
|
||||
Name = Name.Trim(),
|
||||
Cas = Cas.Trim(),
|
||||
Amount = Amount.Trim(),
|
||||
SignalWord = SignalWord.Trim(),
|
||||
HStatements = HStatements.Trim(),
|
||||
PStatements = PStatements.Trim(),
|
||||
ActivityRestriction = ActivityRestriction.Trim(),
|
||||
GhsPictograms = PictogramOptions.Where(o => o.IsSelected).Select(o => o.Value).ToList(),
|
||||
};
|
||||
}
|
||||
@@ -217,10 +266,25 @@ public partial class HazardAssessmentWizardViewModel : ObservableObject
|
||||
|
||||
private HazardSubstanceEditItem NewSubstanceItem(HazardSubstance? source = null)
|
||||
{
|
||||
var item = new HazardSubstanceEditItem(source) { OnRemove = RemoveSubstance };
|
||||
var item = new HazardSubstanceEditItem(source)
|
||||
{
|
||||
OnRemove = RemoveSubstance,
|
||||
OnResearch = CanUseAi ? ResearchSubstanceAsync : null,
|
||||
};
|
||||
return item;
|
||||
}
|
||||
|
||||
/// Führt die eigentliche Chemikalien-Recherche aus — ein reiner Datenbank-Lookup gegen die
|
||||
/// lokale RiSU-Stoffliste auf dem Server (ai-backend/stoffliste.php), keine KI beteiligt und
|
||||
/// damit auch kein IsAiAssisted-Hinweis nötig. Die Anmeldung läuft trotzdem über dieselbe
|
||||
/// KI-Backend-Sitzung wie die echten KI-Funktionen (gemeinsamer Host/Token).
|
||||
private async Task<AiSubstanceResearchResponse> ResearchSubstanceAsync(string substanceName)
|
||||
{
|
||||
var token = _aiSettings!.GetToken()
|
||||
?? throw new AiBackendException("Nicht angemeldet. Bitte in den Einstellungen bei der KI-Unterstützung anmelden.");
|
||||
return await _ai!.RequestSubstanceResearchAsync(substanceName, token);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void AddSubstance() => Substances.Add(NewSubstanceItem());
|
||||
|
||||
|
||||
@@ -81,14 +81,32 @@
|
||||
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||
BorderThickness="0,0,0,1" Padding="0,10">
|
||||
<StackPanel Spacing="6">
|
||||
<Grid ColumnDefinitions="*,140,Auto">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding Name}" PlaceholderText="Stoffname"
|
||||
Margin="0,0,8,0"/>
|
||||
<TextBox Grid.Column="1" Text="{Binding Amount}" PlaceholderText="Menge, z.B. ca. 2 g"
|
||||
Margin="0,0,8,0"/>
|
||||
<Button Grid.Column="1" Content="🔍 Recherchieren" Command="{Binding ResearchCommand}"
|
||||
IsVisible="{Binding CanResearch}" IsEnabled="{Binding !IsResearching}"
|
||||
Margin="0,0,8,0"
|
||||
ToolTip.Tip="Sucht Piktogramme, Signalwort, H-/P-Sätze und Tätigkeitsbeschränkung in der RiSU-Stoffliste (Sicheres Experimentieren im Unterricht)."/>
|
||||
<Button Grid.Column="2" Content="✕" Command="{Binding RemoveCommand}"
|
||||
ToolTip.Tip="Stoff entfernen"/>
|
||||
</Grid>
|
||||
<TextBlock Text="{Binding ResearchError}" Foreground="Red" FontSize="11"
|
||||
IsVisible="{Binding ResearchError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<Grid ColumnDefinitions="*,12,140,12,140">
|
||||
<StackPanel Grid.Column="0" Spacing="2">
|
||||
<TextBlock Text="CAS-Nummer" FontSize="11" Opacity="0.6"/>
|
||||
<TextBox Text="{Binding Cas}" PlaceholderText="z.B. 7440-23-5"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="2">
|
||||
<TextBlock Text="Menge" FontSize="11" Opacity="0.6"/>
|
||||
<TextBox Text="{Binding Amount}" PlaceholderText="z.B. ca. 2 g"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="4" Spacing="2">
|
||||
<TextBlock Text="Signalwort" FontSize="11" Opacity="0.6"/>
|
||||
<TextBox Text="{Binding SignalWord}" PlaceholderText="Gefahr/Achtung"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<ItemsControl ItemsSource="{Binding PictogramOptions}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><WrapPanel ItemSpacing="10" LineSpacing="4"/></ItemsPanelTemplate>
|
||||
@@ -109,6 +127,11 @@
|
||||
<TextBox Text="{Binding PStatements}" PlaceholderText="z.B. P280, P305+P351+P338"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="Tätigkeitsbeschränkung" FontSize="11" Opacity="0.6"/>
|
||||
<TextBox Text="{Binding ActivityRestriction}"
|
||||
PlaceholderText="z.B. Für Schüler ab der 5. Klasse freigegeben"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -126,8 +126,8 @@ public partial class LessonDialog : Window
|
||||
assessment.Title, assessment.GroupLabel, assessment.Date?.ToString("dd.MM.yyyy") ?? "",
|
||||
ExperimentKindDisplay.Label(assessment.Kind), assessment.Procedure,
|
||||
assessment.Substances.Select(s => new HazardSubstancePrintRow(
|
||||
s.Name, s.Amount, string.Join(", ", s.GhsPictograms.Select(GhsPictogramDisplay.Code)),
|
||||
s.HStatements, s.PStatements)).ToList(),
|
||||
s.Name, s.Cas, s.Amount, string.Join(", ", s.GhsPictograms.Select(GhsPictogramDisplay.Code)),
|
||||
s.SignalWord, s.HStatements, s.PStatements, s.ActivityRestriction)).ToList(),
|
||||
assessment.Hazards, assessment.ProtectiveMeasures, assessment.FirstAid, assessment.Disposal,
|
||||
assessment.Notes, assessment.IsAiAssisted);
|
||||
var pdf = App.Services.GetRequiredService<PdfExportService>().BuildHazardAssessmentPdf(data);
|
||||
|
||||
@@ -595,6 +595,103 @@ Bearbeiten, `ExperimentKindDisplay`/`GhsPictogramDisplay`), zwei neue Fälle in
|
||||
wie bei `RequestExplanationAsync`/`RequestPlanAsync` nicht Teil der automatisierten Tests (braucht
|
||||
einen echten Endpunkt, siehe Kommentar in `AiPlanningServiceTests.cs`).
|
||||
|
||||
**Nachtrag — Chemikalien-Recherche (Nutzerwunsch):** pro Gefahrstoff-Zeile im Wizard ein Button
|
||||
"🔍 Recherchieren", der Piktogramme, Signalwort, H-/P-Sätze, CAS-Nummer und Tätigkeitsbeschränkung
|
||||
befüllt — `HazardSubstance` bekommt dafür drei neue Felder (`Cas`, `SignalWord`,
|
||||
`ActivityRestriction`).
|
||||
|
||||
**Weg dorthin (drei verworfene Zwischenstände, der Reihe nach):**
|
||||
1. Zuerst versucht: KI mit Anthropics Websuche-Tool, auf die GESTIS-Domain eingeschränkt.
|
||||
Scheiterte durchgängig ("nicht sicher auffindbar"), auch für zweifelsfrei in GESTIS erfasste
|
||||
Stoffe.
|
||||
2. Ursachenanalyse ergab: `gestis.dguv.de` ist eine reine JavaScript-Anwendung ohne
|
||||
serverseitig gerenderten Inhalt (nur ein leeres `<div id="app">`) — weder eine Websuche noch
|
||||
ein einfacher HTTP-Abruf konnte dort je etwas finden, unabhängig von einer bekannten URL. Über
|
||||
das öffentlich ausgelieferte JS-Bundle der Seite wurde GESTIS' eigene (inoffizielle)
|
||||
Daten-API gefunden und direkt verifiziert (inkl. des ebenfalls öffentlich in
|
||||
`gestis.dguv.de/env-config.js` ausgelieferten Zugangsschlüssels) — technisch funktionierte der
|
||||
Direktabruf, aber GESTIS selbst hat keine strukturierte, schulspezifische
|
||||
Tätigkeitsbeschränkung hinterlegt (nur eine allgemeine Jugendarbeitsschutzgesetz-Floskel plus
|
||||
Verweis auf eine externe, nicht abrufbare Publikation) — genau die für den Unterricht
|
||||
wichtigste Angabe fehlte also weiterhin.
|
||||
3. Der Nutzer stellte stattdessen die offizielle **RiSU-Stoffliste** ("Richtlinie Sicheres
|
||||
Experimentieren im Unterricht") als PDF bereit (123 Seiten, wkhtmltopdf-Export einer
|
||||
Datenbanktabelle) — enthält Name, CAS-Nr., ZVG-Nr., GHS-Piktogramme, Signalwort, H-/P-Sätze
|
||||
UND eine eigene Tätigkeitsbeschränkungs-Spalte (Freigabe/Verbot/Altersgrenzen/
|
||||
Ersatzstoffprüfung/Lehrkraft-Vorbehalt) — exakt das gesuchte Datenmodell, gezielt für den
|
||||
Schuleinsatz zusammengestellt statt eines allgemeinen Arbeitsschutz-Nachschlagewerks.
|
||||
|
||||
**Endgültige Architektur — reiner lokaler Datenbank-Lookup, keine KI mehr beteiligt**
|
||||
(Nutzervorgabe: "wir brauchen die KI gar nicht mehr im Backend. Es reicht die reine Datenbank."):
|
||||
- Das 123-seitige PDF wurde einmalig (per `pdfplumber`, Tabellen-Extraktion je Seite, robuste
|
||||
CAS-Nummer-Rekonstruktion über zeilenweise wrappende Bindestrich-Enden) in
|
||||
[data/stoffliste.json](../ai-backend/data/stoffliste.json) umgewandelt (~1800 Stoffe, ~760 KB).
|
||||
- Neuer Endpunkt [substance.php](../ai-backend/substance.php): prüft weiterhin das Bearer-Token
|
||||
(`ai_backend_authenticate`), sucht den angefragten Namen aber nur noch exakt (normalisiert,
|
||||
auch gegen CAS-Nummer/verwandte CAS-Nummern) in [stoffliste.php](../ai-backend/stoffliste.php)
|
||||
— bewusst **kein** Fuzzy-/Teilstring-Match, ein falscher Treffer wäre hier schlimmer als gar
|
||||
keiner. Kein KI-Aufruf, keine Kosten, keine Drittanbieter-Netzwerkabhängigkeit mehr; ohne
|
||||
Treffer liefert der Endpunkt einen klaren 404 statt eines Rateversuchs.
|
||||
- Die gesamte für dieses Feature eingeführte Tool-Use-/Websuche-Infrastruktur wurde vollständig
|
||||
zurückgebaut, da nicht mehr gebraucht: `ProviderInterface`/`AnthropicProvider`/`FakeProvider`/
|
||||
`ai_backend_call_and_charge` wieder auf ihren ursprünglichen (Vor-Feature-)Stand, `gestis.php`,
|
||||
`cache.php`, `data/gestis-substances.json` und die Migration für
|
||||
`transactions.web_search_requests` wieder entfernt.
|
||||
- **Legende der Tätigkeitsbeschränkungs-Codes** (steht in keiner Form im PDF selbst; die erste
|
||||
vom Nutzer erinnerte Fassung war falsch und wurde nach einem zweiten Blick korrigiert — u.a.
|
||||
`S`/`L+` bedeuten das genaue GEGENTEIL der ersten Fassung, ein Verbot statt einer Freigabe):
|
||||
`+` generelle Erlaubnis · `X` generelles Tätigkeitsverbot an Schulen · `L+` Tätigkeitsverbot für
|
||||
Lehrkräfte (Ausnahme siehe RiSU I–3.5) · `S` Tätigkeitsverbot für Schüler:innen · `S4K`
|
||||
Tätigkeitsverbot bis einschließlich Jahrgangsstufe 4 · `S9K` bis einschließlich Jahrgangsstufe 9
|
||||
· `W` Tätigkeitsverbot für werdende/stillende Mütter · `ESP` gesonderte Ersatzstoffprüfung
|
||||
notwendig (unverändert). Aus diesem Anlass Architektur angepasst: `data/stoffliste.json`
|
||||
speichert nur noch die rohen Codes, die Übersetzung passiert erst zur Abfragezeit in
|
||||
`stoffliste.php` (`STOFFLISTE_RESTRICTION_LEGEND`) — eine künftige Korrektur braucht damit nur
|
||||
noch eine Codeänderung statt einer kompletten Neu-Generierung der ~1800-Stoffe-Datenbank.
|
||||
- **Datenqualität:** beim Regenerieren der Datenbank für obige Korrektur zusätzlich eine
|
||||
Kopfzeilen-Dublette gefunden und behoben (Erkennung war zu strikt auf unverzerrten Text
|
||||
angewiesen) sowie entdeckt, dass `pdfplumber`s Tabellenerkennung bei ca. 40 der ca. 1800 Stoffe
|
||||
(~2 %) die Spalten nicht sauber trennen konnte (ungewöhnlich lange Zellinhalte lassen die
|
||||
Spaltenerkennung für einzelne Zeilen kollabieren). Für diese Zeilen wurde eine Regex-basierte
|
||||
Rekonstruktion versucht (CAS-/GHS-/H-/P-Code-Muster sind positionsunabhängig eindeutig,
|
||||
Stoffname dagegen nicht) — nur bei einem eindeutig plausiblen Stoffnamen übernommen (11 von 51
|
||||
gerettet), der Rest bewusst ausgelassen statt mit einem möglicherweise falschen Namen
|
||||
aufgenommen zu werden (ein falscher Name bei sonst korrekten Gefahrendaten wäre gefährlicher als
|
||||
ein fehlender Eintrag). Endgültige Größe: 1764 Stoffe.
|
||||
- **Bekannte Lücke:** diese Stoffliste enthält keine Entsorgungshinweise (anders als der
|
||||
verworfene GESTIS-Ansatz) — `Disposal` bleibt bei einem Datenbank-Treffer leer und muss
|
||||
weiterhin von Hand ausgefüllt werden.
|
||||
- Deployment: `substance.php`, `stoffliste.php` und `data/stoffliste.json` hochladen — kein
|
||||
Migrations-Schritt, keine `config.php`-Änderung mehr nötig (siehe `ai-backend/README.md`).
|
||||
|
||||
**Feld umbenannt:** `HazardSubstance.AgeRestriction` → `ActivityRestriction` (Wizard-Feld
|
||||
"Umgangsbeschränkung für Schüler (nach Alter)" → "Tätigkeitsbeschränkung"), da die tatsächlich
|
||||
verfügbare Angabe breiter ist als reine Altersgrenzen (Freigabe/Verbot/Ersatzstoffprüfung/
|
||||
Lehrkraft-Vorbehalt gehören genauso dazu) — der ursprüngliche Feldname war eine zum Zeitpunkt der
|
||||
ersten Umsetzung noch zu enge Annahme.
|
||||
|
||||
**Auf dem Weg dorthin außerdem behoben (nicht mehr relevant für die Endarchitektur, aber
|
||||
allgemein nützlich):** der Desktop-Client zeigte bei jedem Backend-Fehler ≠ 401/402 nur die
|
||||
pauschale Meldung "Die Anfrage an den KI-Dienst ist fehlgeschlagen" statt der von
|
||||
`ai_backend_fail` tatsächlich mitgesendeten Ursache (`{"error": "..."}`) — betraf alle KI-Aufrufe
|
||||
(`RequestPlanAsync`/`RequestExplanationAsync`/`RequestHazardAssessmentDraftAsync`), nicht nur die
|
||||
Chemikalien-Recherche. Neue gemeinsame `AiPlanningService.BuildRequestFailedExceptionAsync` liest
|
||||
das `error`-Feld mit und hängt es an die Meldung an — bleibt auch nach diesem Umbau nützlich.
|
||||
|
||||
**PDF-Export:** Gefahrstoffe werden jetzt als eigener Block je Stoff statt als schmale
|
||||
Tabellenzeile gedruckt (`PdfExportService.SubstanceBlocks`/`SubstanceBlock`) — bei den vielen
|
||||
Datenpunkten je Stoff (Name, CAS, Menge, GHS, Signalwort, H-/P-Sätze, Tätigkeitsbeschränkung) wäre
|
||||
eine Tabelle auf A4-Hochformat zu schmal für lesbare Texte geworden; die Tätigkeitsbeschränkung
|
||||
wird farblich hervorgehoben, da sie die operativ wichtigste Angabe für die Lehrkraft ist.
|
||||
|
||||
Neue Tests: `HazardSubstanceEditItemTests` (Delegate-Aufruf, Feldübernahme, Fehlerbehandlung,
|
||||
leerer Name), zwei neue `CanUseAi`/`CanResearch`-Wiring-Tests in
|
||||
`HazardAssessmentWizardViewModelTests`. Die eigentliche HTTP-Anfrage von
|
||||
`RequestSubstanceResearchAsync` ist nicht Teil der automatisierten Tests (kein PHP im
|
||||
Implementierungsumfeld verfügbar); die PDF→JSON-Umwandlung und die Lookup-Logik wurden in Python
|
||||
nachgebaut und gegen reale Stichproben aus dem PDF verifiziert. Für `ai-backend/` selbst existiert
|
||||
weiterhin keine automatisierte Testsuite (kein PHP-Testframework im Projekt).
|
||||
|
||||
### 4.3 Stundenplan
|
||||
- [x] **4.3.1** Neues Modell `TimetableSlot` (Gruppe, Wochentag, Stunde, Raum) + Repository —
|
||||
[Planning.cs](LehrerApp.Core/Models/Planning.cs),
|
||||
|
||||
@@ -84,6 +84,61 @@ in `db.php` verschoben, damit `explain.php` sie mitverwenden kann, ohne sie zu d
|
||||
Kein neues DB-Schema nötig (nutzt dieselben `users`/`tokens`/`transactions`-Tabellen wie
|
||||
`plan.php`/`explain.php`). Einfach die neue Datei `gbu.php` hochladen.
|
||||
|
||||
## Update für bereits deployte Installationen (Chemikalien-Recherche, `substance.php`)
|
||||
|
||||
Neues Feature — reiner lokaler Datenbank-Lookup, **kein KI-Aufruf, keine Kosten, keine
|
||||
Drittanbieter-Abhängigkeit** (Nutzerwunsch: "wir brauchen die KI gar nicht mehr im Backend").
|
||||
|
||||
Vorgeschichte in Kurzform (Details siehe TODO.md, Nachtrag zu 4.2): zunächst wurde versucht, die
|
||||
Daten per KI-Websuche bei GESTIS zu recherchieren — das scheiterte, weil `gestis.dguv.de` eine
|
||||
reine JavaScript-Anwendung ohne durchsuchbaren/abrufbaren Inhalt ist. Ein direkter Abruf über
|
||||
GESTIS' eigene (inoffizielle) API funktionierte zwar technisch, hatte aber keine strukturierte,
|
||||
schulspezifische Tätigkeitsbeschränkung (Freigabe/Verbot/Altersgrenzen) — genau die für den
|
||||
Unterricht wichtigste Angabe. Der Nutzer stellte stattdessen die offizielle **RiSU-Stoffliste**
|
||||
("Richtlinie Sicheres Experimentieren im Unterricht") als PDF bereit — ein für den Schuleinsatz
|
||||
zusammengestelltes Verzeichnis mit exakt den benötigten Spalten (Name, CAS-Nr., GHS-Piktogramme,
|
||||
Signalwort, H-/P-Sätze UND Tätigkeitsbeschränkung), aus dem `data/stoffliste.json` (~1800 Stoffe)
|
||||
erzeugt wurde. `substance.php` ist damit auf einen reinen, kostenlosen Datenbank-Lookup reduziert.
|
||||
|
||||
**Deployment:** neue Dateien `substance.php` und `stoffliste.php` hochladen, sowie
|
||||
`data/stoffliste.json` (~760 KB, nur lesend benötigt). Kein Migrations-Schritt, keine
|
||||
`config.php`-Änderung nötig — der Endpunkt prüft weiterhin das Bearer-Token wie jeder andere
|
||||
(`ai_backend_authenticate`), braucht aber keinerlei KI-/Abrechnungs-Infrastruktur mehr.
|
||||
|
||||
**Legende der Tätigkeitsbeschränkungs-Codes** (steht in keiner Form im PDF selbst, vom Nutzer
|
||||
bestätigt — Übersetzung passiert zur Abfragezeit in `stoffliste.php`
|
||||
(`STOFFLISTE_RESTRICTION_LEGEND`), bewusst NICHT in `data/stoffliste.json` gespeichert, damit eine
|
||||
künftige Korrektur nur eine Codeänderung statt einer neuen Datenbank-Generierung braucht — genau
|
||||
das war hier schon einmal nötig, die erste Fassung dieser Legende war falsch):
|
||||
|
||||
| Code | Bedeutung |
|
||||
| --- | --- |
|
||||
| `+` | Generelle Erlaubnis |
|
||||
| `X` | Generelles Tätigkeitsverbot an Schulen |
|
||||
| `L+` | Tätigkeitsverbot für Lehrkräfte, Ausnahme siehe RiSU (I – 3.5) |
|
||||
| `S` | Tätigkeitsverbot für Schülerinnen und Schüler |
|
||||
| `S4K` | Tätigkeitsverbot für Schülerinnen und Schüler bis einschließlich Jahrgangsstufe 4 |
|
||||
| `S9K` | Tätigkeitsverbot für Schülerinnen und Schüler bis einschließlich Jahrgangsstufe 9 |
|
||||
| `W` | Tätigkeitsverbot für werdende oder stillende Mütter |
|
||||
| `ESP` | Gesonderte Ersatzstoffprüfung notwendig |
|
||||
|
||||
**Bekannte Lücken:**
|
||||
- Diese Stoffliste enthält keine Entsorgungshinweise (anders als der frühere GESTIS-Ansatz) — das
|
||||
`disposal`-Feld im Wizard bleibt bei einem Datenbank-Treffer leer und muss weiterhin von Hand
|
||||
ausgefüllt werden.
|
||||
- Für ca. 40 der ca. 1800 Stoffe im PDF (~2 %) konnte die Tabellenerkennung die Spalten nicht
|
||||
zuverlässig auseinanderhalten (ungewöhnlich lange Zellinhalte lassen `pdfplumber`s
|
||||
Spaltenerkennung für einzelne Zeilen kollabieren) — betroffene Zeilen wurden NICHT mit
|
||||
geratenen/möglicherweise falschen Werten aufgenommen, sondern bewusst ausgelassen (ein falscher
|
||||
Stoffname bei sonst korrekten Gefahrendaten wäre gefährlicher als ein fehlender Eintrag). Bei
|
||||
Bedarf lässt sich die Liste der fehlenden Stoffe aus dem PDF neu ermitteln.
|
||||
|
||||
**Aktualisieren der Datenbank:** wenn die RiSU-Stoffliste künftig überarbeitet wird, das PDF neu
|
||||
herunterladen und `data/stoffliste.json` neu erzeugen (Parsing-Logik ist nicht Teil des Repos,
|
||||
da einmalig genutzt — bei Bedarf erneut über `pdfplumber` extrahieren, Spaltenreihenfolge siehe
|
||||
`stoffliste.php`-Kommentar). Die Tätigkeitsbeschränkungs-**Codes** werden weiterhin mit
|
||||
gespeichert, nur ihre Übersetzung lebt separat in `STOFFLISTE_RESTRICTION_LEGEND`.
|
||||
|
||||
## Prompt Caching
|
||||
|
||||
Der Systemprompt in `plan.php` ist vollständig statisch (identisch bei jeder Anfrage, jedes
|
||||
@@ -143,6 +198,21 @@ grundsätzlich nicht) — dann hilft nur eine serverseitige Konfiguration durch
|
||||
- Ob Prompt Caching tatsächlich greift (`cache_read_input_tokens` > 0 bei einer zweiten Anfrage
|
||||
innerhalb von 5 Minuten) — der `FakeProvider` simuliert kein Caching, das lässt sich nur gegen
|
||||
die echte Anthropic-API beobachten (z.B. per Blick in die `transactions`-Tabelle).
|
||||
- **`substance.php`/`stoffliste.php` (Chemikalien-Recherche):** braucht keinen echten API-Key und
|
||||
keine Netzwerkverbindung mehr, ist also grundsätzlich einfacher zu verifizieren als der Rest
|
||||
dieser Liste — trotzdem NICHT in einer echten PHP-Umgebung gelaufen (kein PHP auf der
|
||||
Implementierungs-Maschine verfügbar). Die Lookup-/Normalisierungslogik in `stoffliste.php` sowie
|
||||
der PDF→JSON-Umwandlungsschritt für `data/stoffliste.json` wurden in Python nachgebaut und
|
||||
gegen Stichproben verifiziert (u.a. Natrium, Kaliumdichromat, Zinn-Verbindungen), nicht aber der
|
||||
tatsächlich ausgeführte PHP-Code selbst — vor dem produktiven Einsatz einmal mit ein paar
|
||||
bekannten Stoffnamen durchtesten.
|
||||
**Nachtrag (Nutzer-Testlauf):** eine harte `allowed_domains`-Einschränkung auf GESTIS lieferte
|
||||
für tatsächlich vorhandene Stoffe trotzdem "nicht sicher auffindbar" — vermutlich liefert die
|
||||
Websuche für GESTIS' dynamisch aufgebaute Seiten zu wenige brauchbar indexierte Treffer.
|
||||
`allowed_domains` wurde daraufhin entfernt; die Quellenrangfolge (GESTIS zuerst, sonst andere
|
||||
anerkannte Quellen) steht jetzt nur noch im Systemprompt, mit Pflichtangabe der tatsächlich
|
||||
genutzten Quelle im `source`-Feld. Ob GESTIS damit in der Praxis öfter tatsächlich als Quelle
|
||||
gefunden wird, ist weiterhin nur mit echten Testläufen zu beurteilen.
|
||||
|
||||
## Guthaben aufladen
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Lokale Datenbank aus der RiSU-Stoffliste ("Richtlinie Sicheres Experimentieren im Unterricht"),
|
||||
* vom Nutzer als offizielles PDF bereitgestellt (stoffliste.pdf, 123 Seiten, ~1800 Stoffe) und
|
||||
* einmalig in data/stoffliste.json umgewandelt. Ersetzt die vorher versuchten GESTIS-/KI-Websuche-
|
||||
* Ansätze vollständig (siehe TODO.md-Nachtrag zu 4.2): GESTIS selbst hatte weder zuverlässig
|
||||
* abrufbare noch schulspezifische Tätigkeitsbeschränkungs-Angaben, diese Stoffliste ist dagegen
|
||||
* gezielt für den Schuleinsatz zusammengestellt und enthält die Tätigkeitsbeschränkung (Freigabe/
|
||||
* Verbot/Altersgrenzen/Ersatzstoffprüfung/Lehrkraft-Vorbehalt) direkt als eigene Spalte — die
|
||||
* eigentlich entscheidende Angabe für eine schulische Gefährdungsbeurteilung.
|
||||
*
|
||||
* Reiner lokaler Datenbank-Lookup, kein KI-Aufruf mehr nötig (Nutzerwunsch: "wir brauchen die KI
|
||||
* gar nicht mehr im Backend. Es reicht die reine Datenbank.").
|
||||
*
|
||||
* data/stoffliste.json speichert bewusst nur die rohen Tätigkeitsbeschränkungs-CODES, nicht die
|
||||
* übersetzten Texte — die Übersetzung passiert erst hier, zur Abfragezeit (siehe
|
||||
* STOFFLISTE_RESTRICTION_LEGEND). So reicht bei einer künftigen Korrektur der Legende (wie schon
|
||||
* einmal nötig — die erste Fassung war falsch) eine Änderung an einer einzigen Stelle, statt die
|
||||
* komplette ~1800 Stoffe umfassende Datenbank neu erzeugen zu müssen.
|
||||
*/
|
||||
|
||||
const STOFFLISTE_PATH = __DIR__ . '/data/stoffliste.json';
|
||||
|
||||
/** Vom Nutzer bestätigte Legende (steht in keiner Form im PDF selbst). */
|
||||
const STOFFLISTE_RESTRICTION_LEGEND = [
|
||||
'+' => 'Generelle Erlaubnis',
|
||||
'X' => 'Generelles Tätigkeitsverbot an Schulen',
|
||||
'L+' => 'Tätigkeitsverbot für Lehrkräfte, Ausnahme siehe RiSU (I – 3.5)',
|
||||
'S' => 'Tätigkeitsverbot für Schülerinnen und Schüler',
|
||||
'S4K' => 'Tätigkeitsverbot für Schülerinnen und Schüler bis einschließlich Jahrgangsstufe 4',
|
||||
'S9K' => 'Tätigkeitsverbot für Schülerinnen und Schüler bis einschließlich Jahrgangsstufe 9',
|
||||
'W' => 'Tätigkeitsverbot für werdende oder stillende Mütter',
|
||||
'ESP' => 'Gesonderte Ersatzstoffprüfung notwendig',
|
||||
];
|
||||
|
||||
function stoffliste_normalize(string $value): string
|
||||
{
|
||||
return trim(mb_strtolower($value));
|
||||
}
|
||||
|
||||
/** Übersetzt Tätigkeitsbeschränkungs-Codes in lesbare Texte — unbekannte Codes werden
|
||||
* unverändert durchgereicht (Fail-Open: keinen Hinweis stillschweigend verschwinden lassen,
|
||||
* falls die Liste um einen neuen Code erweitert wird, den diese Legende noch nicht kennt). */
|
||||
function stoffliste_restriction_texts(array $codes): array
|
||||
{
|
||||
return array_map(
|
||||
fn(string $code) => STOFFLISTE_RESTRICTION_LEGEND[$code] ?? $code,
|
||||
$codes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* name: string, cas: string, relatedCas: list<string>, zvg: string, signalWord: string,
|
||||
* ghsPictograms: list<string>, hStatements: list<string>, pStatements: list<string>,
|
||||
* hazardCategories: list<string>, activityRestrictionCodes: list<string>
|
||||
* }>
|
||||
*/
|
||||
function stoffliste_load(): array
|
||||
{
|
||||
static $data = null;
|
||||
if ($data !== null) {
|
||||
return $data;
|
||||
}
|
||||
$raw = @file_get_contents(STOFFLISTE_PATH);
|
||||
$decoded = $raw !== false ? json_decode($raw, true) : null;
|
||||
$data = is_array($decoded) ? $decoded : [];
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht einen Stoff exakt (normalisierter Name oder CAS-Nummer, auch gegen "verwandte"
|
||||
* CAS-Nummern bei Stoffen mit mehreren erfassten CAS-Nummern) — bewusst kein Fuzzy-/Teilstring-
|
||||
* Match, ein falscher Treffer wäre hier schlimmer als gar keiner (die Antwort bezöge sich sonst
|
||||
* unbemerkt auf einen falschen Stoff samt dessen Tätigkeitsbeschränkung). Gibt null zurück, wenn
|
||||
* der Stoff nicht in der Liste steht.
|
||||
*/
|
||||
function stoffliste_lookup(string $query): ?array
|
||||
{
|
||||
$normalizedQuery = stoffliste_normalize($query);
|
||||
$data = stoffliste_load();
|
||||
|
||||
foreach ($data as $entry) {
|
||||
if (stoffliste_normalize((string) $entry['name']) === $normalizedQuery) {
|
||||
return $entry;
|
||||
}
|
||||
}
|
||||
foreach ($data as $entry) {
|
||||
if ($entry['cas'] !== '' && stoffliste_normalize((string) $entry['cas']) === $normalizedQuery) {
|
||||
return $entry;
|
||||
}
|
||||
foreach (($entry['relatedCas'] ?? []) as $related) {
|
||||
if (stoffliste_normalize((string) $related) === $normalizedQuery) {
|
||||
return $entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/db.php';
|
||||
require_once __DIR__ . '/stoffliste.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
|
||||
$config = require __DIR__ . '/config.php';
|
||||
$pdo = ai_backend_db($config);
|
||||
ai_backend_authenticate($pdo); // nur eingeloggte Nutzer der App dürfen abfragen
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!is_array($body) || !isset($body['name']) || trim((string) $body['name']) === '') {
|
||||
ai_backend_fail(400, 'Ungültige Anfrage: "name" fehlt.');
|
||||
}
|
||||
$name = trim((string) $body['name']);
|
||||
|
||||
// Reiner Datenbank-Lookup (siehe stoffliste.php) — kein KI-Aufruf, keine Kosten, keine
|
||||
// Netzwerkabhängigkeit von Drittanbietern. Findet die App den Stoff nicht, trägt die Lehrkraft
|
||||
// die Angaben von Hand ein (kein Rateversuch über eine externe Quelle mehr, siehe TODO.md).
|
||||
$entry = stoffliste_lookup($name);
|
||||
if ($entry === null) {
|
||||
ai_backend_fail(404, 'Stoff nicht in der RiSU-Stoffliste gefunden. Bitte Schreibweise/CAS-Nummer prüfen oder Angaben manuell eintragen.');
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'name' => $entry['name'],
|
||||
'cas' => $entry['cas'],
|
||||
'ghsPictograms' => $entry['ghsPictograms'],
|
||||
'signalWord' => $entry['signalWord'],
|
||||
'hStatements' => implode(', ', $entry['hStatements']),
|
||||
'pStatements' => implode(', ', $entry['pStatements']),
|
||||
'activityRestriction' => implode('; ', stoffliste_restriction_texts($entry['activityRestrictionCodes'])),
|
||||
'source' => 'RiSU-Stoffliste (Sicheres Experimentieren im Unterricht)'
|
||||
. ($entry['zvg'] !== '' && $entry['zvg'] !== '-' ? ', ZVG ' . $entry['zvg'] : ''),
|
||||
]);
|
||||
Reference in New Issue
Block a user