- UntisHubService.RecordRun: ein abgeschlossener Langzeit-Fehlzeitenabgleich schliesst die kurzfristige Kadenz derselben Gruppe automatisch mit ab (nicht umgekehrt). - Fehlzeitenabgleich-Dialog: neue "Uebernahme als"-ComboBox statt starrem Zielstatus, vorbelegt mit dem berechneten Vorschlag, aber frei aenderbar. - Neuer ai-backend-Endpunkt untis-status.php + AiPlanningService.RequestUntisStatusSuggestionsAsync: gebuendelter, anonymisierter KI-Statusvorschlag (nur Positions-Id + Rohsignale, nie Name/Klasse/ Datum), mit hartem Id-Mengen-Abgleich gegen Verwechslung. - Neue MCP-Tools (UntisComparisonTools): get_untis_hub_status, get_untis_absence_rows/ apply_untis_absence_status (anonymer Weg ueber ENr-Zuordnung) sowie get_named_untis_absence_pattern als bewusste, eng begrenzte Ausnahme (Name+Fehlzeiten fuer explizit angegebene Schueler-IDs, mit Bestaetigung ohne Sitzungsfreigabe - dafuer IMcpConfirmationService.ConfirmAsync um allowSessionTrust erweitert). - MapStatus/ENr-Zuordnung aus dem ViewModel in das neue, geteilte UntisLessonAbsenceHelper gezogen, damit Dialog und MCP-Tool nie unterschiedliche Statusvorschlaege berechnen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
15 KiB
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
-
Datenbank anlegen und
schema.sqlimportieren:mysql -u <user> -p <datenbankname> < schema.sql -
config.example.phpnachconfig.phpkopieren und ausfüllen (DB-Zugang, Anthropic-API-Key, ggf. die Preistabelle gegen die aktuelle Anthropic-Preisseite prüfen — Preise ändern sich).config.phpist in.gitignoreund darf nie committet werden. -
Falls du die Web-Variante aus Schritt 5 nutzen willst (kein PHP-CLI/SSH auf dem Hosting):
setup_secretinconfig.phpauf einen langen Zufallswert setzen (z.B. peropenssl rand -hex 32erzeugt, oder ein beliebiger Passwortgenerator). -
Den kompletten
ai-backend/-Ordner auf den PHP-Server hochladen. Empfehlung:config.php,db.php,schema.sql,providers/undscripts/außerhalb des öffentlichen Webroots ablegen, falls das Hosting das erlaubt (Pfade in denrequire-Aufrufen entsprechend anpassen) — robuster als sich allein auf die mitgelieferte.htaccesszu verlassen, die nur bei Apache mit aktiviertemmod_rewrite/erlaubten.htaccess-Overrides greift. -
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):php scripts/create-user.php sebastian "einStarkesPasswort" 10.00Der 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:
curl -X POST https://DEINE-DOMAIN/setup-user.php \ -d '{"secret":"<setup_secret aus config.php>","username":"sebastian","password":"einStarkesPasswort","balanceUsd":10.00}'Direkt danach
setup-user.phpper 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. -
In
LehrerApp.Desktop/AppBootstrapper.csdie KonstanteAiBackendUrlauf die tatsächlich deployte Domain setzen und die App neu bauen. -
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.
- Migration einmalig einspielen (ergänzt zwei neue, nicht-destruktive Spalten):
mysql -u <user> -p <datenbankname> < migrations/2026-08-add-cache-tokens.sql - In der bestehenden
config.phpbei jedem Preistabellen-Eintragcache_write/cache_readergänzen (sieheconfig.example.phpfü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
pdfplumbers 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:
AI_BACKEND_FAKE_PROVIDER=1 php -S localhost:8000 -t .
Dann z.B.:
curl -X POST http://localhost:8000/login.php \
-d '{"username":"sebastian","password":"einStarkesPasswort"}'
curl http://localhost:8000/status.php -H "Authorization: Bearer <token aus login.php>"
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:
.htaccesserzwingt die Weiterleitung des Headers (RewriteRule .* - [E=HTTP_AUTHORIZATION:%1]).db.phpliest zusätzlichREDIRECT_HTTP_AUTHORIZATIONundgetallheaders()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) — derFakeProvidersimuliert kein Caching, das lässt sich nur gegen die echte Anthropic-API beobachten (z.B. per Blick in dietransactions-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, sieheWebUntisLessonAbsenceRow.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 instoffliste.phpsowie der PDF→JSON-Umwandlungsschritt fürdata/stoffliste.jsonwurden 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 harteallowed_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_domainswurde daraufhin entfernt; die Quellenrangfolge (GESTIS zuerst, sonst andere anerkannte Quellen) steht jetzt nur noch im Systemprompt, mit Pflichtangabe der tatsächlich genutzten Quelle imsource-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:
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.)