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
@@ -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; } = "";
}
+10
View File
@@ -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; } }
}
+35 -28
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 =>
{
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);
}
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 =>
{
header.RelativeItem().Text(text =>
{
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);
});
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);
+97
View File
@@ -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 I3.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),
+70
View File
@@ -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
+101
View File
@@ -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;
}
+37
View File
@@ -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'] : ''),
]);