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:
@@ -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