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
+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'] : ''),
]);