# KI-Backend (TODO 4.5.9) Kleines PHP-Zwischenelement, das die KI-gestützte Planungsunterstützung des Desktop-Clients absichert: der LLM-API-Key liegt nur hier auf dem Server, der Desktop-Client bekommt nur ein eigenes Bearer-Token gegen dieses Backend und ein pro Nutzer geführtes Guthaben. Deployment macht der Nutzer selbst — dieses README beschreibt die nötigen Schritte. ## Voraussetzungen - PHP 8.1 oder neuer (nutzt `never`-Rückgabetypen, `match`, First-Class-Callable-Syntax nicht, aber typisierte Properties/Enums nicht zwingend — 8.1 ist die sichere Untergrenze). - PHP-Erweiterungen: `pdo_mysql`, `curl`, `json` (bei den meisten Hosting-Paketen bereits dabei). - Eine MySQL- oder MariaDB-Datenbank. - Ein API-Key für Anthropic (`https://console.anthropic.com`). ## Einrichtung 1. Datenbank anlegen und `schema.sql` importieren: ```bash mysql -u -p < schema.sql ``` 2. `config.example.php` nach `config.php` kopieren und ausfüllen (DB-Zugang, Anthropic-API-Key, ggf. die Preistabelle gegen die aktuelle Anthropic-Preisseite prüfen — Preise ändern sich). `config.php` ist in `.gitignore` und darf nie committet werden. 3. Falls du die Web-Variante aus Schritt 5 nutzen willst (kein PHP-CLI/SSH auf dem Hosting): `setup_secret` in `config.php` auf einen langen Zufallswert setzen (z.B. per `openssl rand -hex 32` erzeugt, oder ein beliebiger Passwortgenerator). 4. Den kompletten `ai-backend/`-Ordner auf den PHP-Server hochladen. **Empfehlung:** `config.php`, `db.php`, `schema.sql`, `providers/` und `scripts/` außerhalb des öffentlichen Webroots ablegen, falls das Hosting das erlaubt (Pfade in den `require`-Aufrufen entsprechend anpassen) — robuster als sich allein auf die mitgelieferte `.htaccess` zu verlassen, die nur bei Apache mit aktiviertem `mod_rewrite`/erlaubten `.htaccess`-Overrides greift. 5. Ersten Nutzer anlegen (weitere Lehrer später genauso) — zwei Varianten, je nachdem was das Hosting hergibt: **Mit PHP-CLI/SSH-Zugriff** (`scripts/create-user.php`): ```bash php scripts/create-user.php sebastian "einStarkesPasswort" 10.00 ``` Der dritte Parameter ist das Startguthaben in USD, optional (Standard 0). **Ohne CLI/SSH** (übliches Shared Hosting, nur FTP/Webspace) — der Server läuft nach Schritt 4 bereits, also einmalig von deinem eigenen Rechner aus aufrufen: ```bash curl -X POST https://DEINE-DOMAIN/setup-user.php \ -d '{"secret":"","username":"sebastian","password":"einStarkesPasswort","balanceUsd":10.00}' ``` **Direkt danach `setup-user.php` per FTP wieder vom Server löschen** — sonst bleibt ein Endpunkt erreichbar, der (mit dem Geheimnis) beliebige Nutzer anlegen kann. Ein erneuter Aufruf mit demselben Benutzernamen aktualisiert Passwort/Guthaben, falls z.B. das Guthaben beim ersten Versuch vergessen wurde — dafür braucht es die Datei aber kein zweites Mal auf dem Server, einfach vor dem Löschen alle nötigen Nutzer in einer Sitzung anlegen. 6. In `LehrerApp.Desktop/AppBootstrapper.cs` die Konstante `AiBackendUrl` auf die tatsächlich deployte Domain setzen und die App neu bauen. 7. In der App unter Einstellungen → KI-Unterstützung aktivieren und mit dem angelegten Nutzer anmelden. ## Update für bereits deployte Installationen (Prompt-Caching-Nachtrag) Falls `ai-backend/` schon einmal deployt wurde (schema.sql lief bereits, `config.php` existiert schon): zwei Schritte nötig, damit Abrechnung und `plan.php` mit dem neuen Prompt Caching funktionieren. 1. Migration einmalig einspielen (ergänzt zwei neue, nicht-destruktive Spalten): ```bash mysql -u -p < migrations/2026-08-add-cache-tokens.sql ``` 2. In der bestehenden `config.php` bei jedem Preistabellen-Eintrag `cache_write`/`cache_read` ergänzen (siehe `config.example.php` für aktuelle Richtwerte) — ohne diese Ergänzung wird der Cache-Anteil einfach mit 0 USD abgerechnet (kein Fehler, aber auch keine Kostenersparnis). Danach alle geänderten Dateien (`plan.php`, `db.php`, `providers/`, `.htaccess`, falls noch nicht aktuell) erneut hochladen. ## Update für bereits deployte Installationen ("Schattenfeld"-Nachtrag, `explain.php`) Kein neues DB-Schema nötig (nutzt dieselben `users`/`tokens`/`transactions`-Tabellen wie `plan.php`). Einfach die neue Datei `explain.php` sowie die aktualisierten `db.php` und `plan.php` hochladen (die Abrechnungslogik wurde aus `plan.php` in eine gemeinsame Funktion `ai_backend_call_and_charge` in `db.php` verschoben, damit `explain.php` sie mitverwenden kann, ohne sie zu duplizieren). ## Update für bereits deployte Installationen (Gefährdungsbeurteilungs-Entwurf, `gbu.php`) 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 Nutzers). `AnthropicProvider.php` markiert ihn deshalb als cacheable — wiederholte Anfragen innerhalb der Anthropic-Cache-TTL (Standard: 5 Minuten) zahlen für diesen Anteil nur den stark reduzierten "Cache-Read"-Preis statt des vollen Input-Preises; der erste Aufruf nach TTL-Ablauf zahlt einen kleinen Aufpreis fürs Neuschreiben des Caches. Das passiert automatisch, ohne Zutun — nichts an der Bedienung der App ändert sich dadurch. Beobachtbar ist der Effekt in der `transactions`-Tabelle (`cache_creation_input_tokens` vs. `cache_read_input_tokens`). ## Smoke-Test ohne echten API-Key `plan.php` liest die Umgebungsvariable `AI_BACKEND_FAKE_PROVIDER` — bei `1` wird statt eines echten Anthropic-Aufrufs `providers/FakeProvider.php` verwendet (liefert eine feste, schema-valide Test-Antwort). Damit lassen sich Login, Guthabenprüfung und die 402-Pfade lokal prüfen, ohne echte Kosten zu verursachen: ```bash AI_BACKEND_FAKE_PROVIDER=1 php -S localhost:8000 -t . ``` Dann z.B.: ```bash curl -X POST http://localhost:8000/login.php \ -d '{"username":"sebastian","password":"einStarkesPasswort"}' curl http://localhost:8000/status.php -H "Authorization: Bearer " ``` **Wichtig:** `AI_BACKEND_FAKE_PROVIDER` niemals auf dem produktiven Server setzen — sonst bekommt die App nur die feste Test-Antwort statt echter KI-Vorschläge. ## Fehlerbehebung: "Anmeldung abgelaufen" direkt nach dem Anmelden Login funktioniert, aber jede Anfrage danach (Guthaben, KI-Anfrage) meldet sofort eine abgelaufene/ungültige Anmeldung? Typische Ursache: viele Apache/PHP-FPM-Hosting-Setups reichen den `Authorization`-Header standardmäßig gar nicht erst an PHP durch — `login.php` braucht ihn nicht, aber `status.php`/`plan.php` schon, und der kommt dort leer an. Dagegen sind bereits zwei Absicherungen eingebaut: - `.htaccess` erzwingt die Weiterleitung des Headers (`RewriteRule .* - [E=HTTP_AUTHORIZATION:%1]`). - `db.php` liest zusätzlich `REDIRECT_HTTP_AUTHORIZATION` und `getallheaders()` als Fallback. Falls es trotzdem weiter auftritt: `.htaccess`-Overrides sind auf manchem Hosting serverseitig deaktiviert (`AllowOverride None`), oder es läuft nginx statt Apache (dort gilt `.htaccess` grundsätzlich nicht) — dann hilft nur eine serverseitige Konfiguration durch den Hoster/Support (z.B. bei nginx ein `fastcgi_param HTTP_AUTHORIZATION $http_authorization;`). ## Update für bereits deployte Installationen (Fehlzeiten-Statusvorschlag, `untis-status.php`) Kein neues DB-Schema nötig (nutzt dieselben `users`/`tokens`/`transactions`-Tabellen und dieselbe `ai_backend_call_and_charge`-Abrechnung wie `plan.php`/`explain.php`/`gbu.php`). Einfach die neue Datei `untis-status.php` hochladen. Bewusst ohne jeden Personenbezug im Payload (Nutzer-Vorgabe zum Untis-Hub) — die Zeilen enthalten nur eine rein technische Positions-Id sowie Fehlminuten/ Bearbeitet-Kennzeichen/ENr-Klammerung und den Freitext-Grund, nie Name, Klasse oder Datum. ## Fehlerbehebung: KI-Antwort endet mitten im JSON Eine im Rettungsdialog am Ende abgeschnittene Modellantwort entsteht typischerweise nicht durch die Socketverbindung, sondern durch Anthropics `stop_reason: max_tokens`. Das Backend erkennt diesen Fall ausdrücklich und nennt das konfigurierte Limit in der Fehlermeldung. Der empfohlene Wert für `max_output_tokens` ist **16000**; abgerechnet werden trotzdem nur tatsächlich erzeugte Tokens. Für echte Zeitüberschreitungen wartet der Anthropic-cURL-Aufruf höchstens 180 Sekunden und der Desktop-Client 210 Sekunden. Bricht eine Anfrage reproduzierbar früher ab, liegt davor noch ein Zeitlimit des Hosters (nginx/Apache/FastCGI/Reverse Proxy), das auf mindestens 180 Sekunden gesetzt werden muss. ## Was hiermit NICHT geprüft ist - Ob Anthropic zuverlässig valides JSON im erwarteten Schema liefert (reine Prompt-Qualitätsfrage, nur mit dem echten API-Key zu beurteilen). - Ob die berechneten Kosten exakt mit der tatsächlichen Anthropic-Abrechnung übereinstimmen. - TLS/`.htaccess`-Wirksamkeit und PHP-Version/Erweiterungen auf dem tatsächlichen Hosting. - Die komplette Kette Desktop → dieses Backend → Anthropic unter echten Netzwerkbedingungen. - 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). - **`untis-status.php` (Fehlzeiten-Statusvorschlag):** dieselbe Einschränkung wie oben (echtes JSON im erwarteten Schema, korrekte Kostenberechnung) gilt hier ebenso, zusätzlich ungeprüft: ob das Modell die vorgegebene, feste Statusliste zuverlässig einhält statt eigene Werte zu erfinden (der Desktop-Client verwirft einen nicht in dieser Liste enthaltenen Wert defensiv, siehe `WebUntisLessonAbsenceRow.SelectableStatuses`), sowie ob die Id-Rückgabetreue in der Praxis so zuverlässig ist wie in den Tests angenommen (dort nur mit statischen Antworten simuliert). - **`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 Für den aktuellen Umfang (ein bis wenige Nutzer) reicht ein manueller SQL-Befehl: ```sql UPDATE users SET balance_usd = balance_usd + 10.00 WHERE username = 'sebastian'; INSERT INTO transactions (user_id, type, cost_usd, balance_after) SELECT id, 'topup', -10.00, balance_usd FROM users WHERE username = 'sebastian'; ``` (Kein Admin-UI in dieser Ausbaustufe — bei Bedarf später ergänzbar.)