Gemeinsames PHP-Backend nach LehrerBackend ausgliedern
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
admin committed 2026-10-19 10:03:42 +02:00
1 parent 97eb41a0ea
commit 336869e75f
24 files changed
+38 -1504

No files matched your search

+7
View File
@@ -42,6 +42,13 @@ The API can also run via `docker/docker-compose.yml` (reads `JWT_SECRET` from th
### Project layout ### Project layout
The PHP backend has lived in the independent
[LehrerBackend repository](https://git.science-teaching.de/admin/LehrerBackend) since
2026-10-19 (local sibling `../LehrerBackend/`). `ai-backend/README.md` is only a pointer;
change PHP endpoints, providers, authentication and billing in LehrerBackend. Older
`ai-backend/...` paths in comments/TODO refer to the new repository root. Desktop clients and
DTOs stay here; no submodule or local backend checkout is needed to build the app.
- **LehrerApp.Core** — domain models (`Models/`), repository interfaces (`Interfaces/IRepositories.cs`), - **LehrerApp.Core** — domain models (`Models/`), repository interfaces (`Interfaces/IRepositories.cs`),
and framework-free business services (`Services/`): `GradingService` (rounding, grading-key and framework-free business services (`Services/`): `GradingService` (rounding, grading-key
validation, report-grade calculation), `SchoolYearService`, `AppLogger`, `BackupService`, validation, report-grade calculation), `SchoolYearService`, `AppLogger`, `BackupService`,
+3 -1
View File
@@ -111,7 +111,9 @@ deterministisch mit einem weißen Asset maskiert und anschließend als `TEXT` od
eingefügt. Der Nutzer prüft alle Vorschläge im Importdialog und muss die Übernahme ausdrücklich eingefügt. Der Nutzer prüft alle Vorschläge im Importdialog und muss die Übernahme ausdrücklich
bestätigen. Das Paket wird dabei noch nicht gespeichert. bestätigen. Das Paket wird dabei noch nicht gespeichert.
Die serverseitige Klassifikation liegt in `ai-backend/pdf-template.php` und verwendet denselben Die serverseitige Klassifikation liegt in
[`LehrerBackend/pdf-template.php`](https://git.science-teaching.de/admin/LehrerBackend/src/branch/main/pdf-template.php)
und verwendet denselben
Login-, Bearer-Token-, Guthaben- und Abrechnungsmechanismus wie die übrigen KI-Funktionen. Das Login-, Bearer-Token-, Guthaben- und Abrechnungsmechanismus wie die übrigen KI-Funktionen. Das
Passwort wird vom eigenständigen Designer nicht gespeichert. Tabellen-/Chart-Erkennung und die Passwort wird vom eigenständigen Designer nicht gespeichert. Tabellen-/Chart-Erkennung und die
automatische Rekonstruktion mehrseitiger Vorlagen sind bewusst nicht Teil von v1. automatische Rekonstruktion mehrseitiger Vorlagen sind bewusst nicht Teil von v1.
+11 -3
View File
@@ -6,6 +6,14 @@ Jeder Punkt ist so formuliert, dass er einzeln an einen Agenten übergeben werde
**Stand:** 2026-08-18 **Stand:** 2026-08-18
**Legende:** `[ ]` offen · `[~]` teilweise umgesetzt · `[x]` fertig **Legende:** `[ ]` offen · `[~]` teilweise umgesetzt · `[x]` fertig
**Repository-Änderung 19.10.2026:** Das bisherige `ai-backend/` wurde mit 17 Backend-Commits
in das eigenständige [LehrerBackend](https://git.science-teaching.de/admin/LehrerBackend)
ausgegliedert. Dort liegen jetzt sämtliche PHP-Endpunkte, Anbieter und die Betriebsanleitung.
Lokaler Checkout: `../LehrerBackend/`; Serveradresse und Desktop-Vertrag bleiben unverändert.
Historische Nennungen von `ai-backend/...` unten beziehen sich auf Dateien im neuen Backend.
[Wegweiser](ai-backend/README.md) ·
[Betrieb](https://git.science-teaching.de/admin/LehrerBackend/src/branch/main/docs/betrieb.md).
--- ---
## 0. Konventionen für alle Aufgaben ## 0. Konventionen für alle Aufgaben
@@ -934,10 +942,10 @@ befüllt — `HazardSubstance` bekommt dafür drei neue Felder (`Cas`, `SignalWo
(Nutzervorgabe: "wir brauchen die KI gar nicht mehr im Backend. Es reicht die reine Datenbank."): (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 - Das 123-seitige PDF wurde einmalig (per `pdfplumber`, Tabellen-Extraktion je Seite, robuste
CAS-Nummer-Rekonstruktion über zeilenweise wrappende Bindestrich-Enden) in CAS-Nummer-Rekonstruktion über zeilenweise wrappende Bindestrich-Enden) in
[data/stoffliste.json](../ai-backend/data/stoffliste.json) umgewandelt (~1800 Stoffe, ~760 KB). [data/stoffliste.json](https://git.science-teaching.de/admin/LehrerBackend/src/branch/main/data/stoffliste.json) umgewandelt (~1800 Stoffe, ~760 KB).
- Neuer Endpunkt [substance.php](../ai-backend/substance.php): prüft weiterhin das Bearer-Token - Neuer Endpunkt [substance.php](https://git.science-teaching.de/admin/LehrerBackend/src/branch/main/substance.php): prüft weiterhin das Bearer-Token
(`ai_backend_authenticate`), sucht den angefragten Namen aber nur noch exakt (normalisiert, (`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) auch gegen CAS-Nummer/verwandte CAS-Nummern) in [stoffliste.php](https://git.science-teaching.de/admin/LehrerBackend/src/branch/main/stoffliste.php)
— bewusst **kein** Fuzzy-/Teilstring-Match, ein falscher Treffer wäre hier schlimmer als gar — 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 keiner. Kein KI-Aufruf, keine Kosten, keine Drittanbieter-Netzwerkabhängigkeit mehr; ohne
Treffer liefert der Endpunkt einen klaren 404 statt eines Rateversuchs. Treffer liefert der Endpunkt einen klaren 404 statt eines Rateversuchs.
-28
View File
@@ -1,28 +0,0 @@
# Sperrt alles, was kein öffentlicher Endpunkt ist. Nur login.php / status.php / plan.php /
# explain.php sollen von außen aufrufbar sein. Siehe README.md — noch robuster ist es,
# config.php/db.php/schema.sql/providers/ komplett außerhalb des Webroots abzulegen, falls das
# Hosting das erlaubt.
<FilesMatch "^(config(\.example)?\.php|db\.php)$">
Require all denied
</FilesMatch>
<Files "schema.sql">
Require all denied
</Files>
<FilesMatch "\.md$">
Require all denied
</FilesMatch>
RewriteEngine On
RewriteRule ^providers/ - [F,L]
RewriteRule ^scripts/ - [F,L]
# Viele Apache/PHP-FPM-Setups reichen den Authorization-Header standardmäßig NICHT an PHP durch
# (login.php funktioniert dann, aber jede Anfrage mit Bearer-Token danach schlägt fehl, weil der
# Header bei PHP leer ankommt). Diese Zeile erzwingt die Weiterleitung — zusammen mit dem
# Mehrquellen-Fallback in db.php (ai_backend_authorization_header) deckt das die gängigen
# Hosting-Varianten ab.
RewriteCond %{HTTP:Authorization} ^(.+)$
RewriteRule .* - [E=HTTP_AUTHORIZATION:%1]
+17 -249
View File
@@ -1,254 +1,22 @@
# KI-Backend (TODO 4.5.9) # Backend umgezogen
Kleines PHP-Zwischenelement, das die KI-gestützte Planungsunterstützung des Desktop-Clients Seit dem 19.10.2026 wird das frühere KI-Backend als eigenständiges, gemeinsam genutztes
absichert: der LLM-API-Key liegt nur hier auf dem Server, der Desktop-Client bekommt nur ein [LehrerBackend](https://git.science-teaching.de/admin/LehrerBackend) gepflegt.
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 - [Quellcode und Übersicht](https://git.science-teaching.de/admin/LehrerBackend/src/branch/main/README.md)
- [Einrichtung und Betrieb](https://git.science-teaching.de/admin/LehrerBackend/src/branch/main/docs/betrieb.md)
- [Umzug und Git-Historie](https://git.science-teaching.de/admin/LehrerBackend/src/branch/main/docs/migration.md)
- PHP 8.1 oder neuer (nutzt `never`-Rückgabetypen, `match`, First-Class-Callable-Syntax nicht, Lokal liegt es neben diesem Repository unter `../LehrerBackend/`.
aber typisierte Properties/Enums nicht zwingend — 8.1 ist die sichere Untergrenze). Änderungen an PHP-Endpunkten, Anbietern, Authentifizierung und Abrechnung gehören dorthin.
- PHP-Erweiterungen: `pdo_mysql`, `curl`, `json` (bei den meisten Hosting-Paketen bereits dabei). Die Desktop-Anbindung und ihre Datenmodelle bleiben in LehrerApp.
- Eine MySQL- oder MariaDB-Datenbank. Es gibt keine Submodule und keine zusätzliche Build-Abhängigkeit.
- Ein API-Key für Anthropic (`https://console.anthropic.com`).
## Einrichtung Serveradresse, Datenbank, Nutzerkonten und bestehende Endpunkte bleiben durch den Umzug
unverändert. Die Konfiguration liegt lokal ausschließlich in `../LehrerBackend/config.php`
und wird nicht eingecheckt. Produktionsupdates künftig aus LehrerBackend durchführen.
1. Datenbank anlegen und `schema.sql` importieren: Ältere Verweise auf `ai-backend/...` in Kommentaren und historischen Roadmap-Einträgen
```bash bezeichnen die entsprechenden Dateien im Hauptordner von LehrerBackend.
mysql -u <user> -p <datenbankname> < schema.sql Die bisherige Historie bleibt in LehrerApp erhalten und wurde zusätzlich für LehrerBackend
``` extrahiert. In diesem Ordner liegt nur noch dieser Wegweiser.
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":"<setup_secret aus config.php>","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 <user> -p <datenbankname> < 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 <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:
- `.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.)
-47
View File
@@ -1,47 +0,0 @@
<?php
// Kopieren nach config.php und mit echten Werten füllen. config.php selbst ist in .gitignore
// eingetragen und darf nie committet werden.
return [
'db' => [
'host' => 'localhost',
'name' => 'lehrerapp_ai',
'user' => 'CHANGE_ME',
'pass' => 'CHANGE_ME',
],
// Nur für setup-user.php gebraucht (Web-Alternative zu scripts/create-user.php auf Hosting
// ohne PHP-CLI/SSH) — ein langer Zufallswert, z.B. per `openssl rand -hex 32` erzeugt.
// setup-user.php nach Gebrauch vom Server löschen, siehe README.md.
'setup_secret' => 'CHANGE_ME',
// Nur 'anthropic' ist aktuell fertig implementiert (siehe providers/AnthropicProvider.php).
// Die Provider-Schnittstelle ist so gebaut, dass ein OpenAiProvider später ergänzt werden
// kann — bewusst nicht in dieser Runde, da aktuelle OpenAI-Preise/API-Version zum Zeitpunkt
// der Implementierung nicht verifiziert wurden (siehe TODO.md, Nachtrag zu 4.5.9).
'llm_provider' => 'anthropic',
'anthropic' => [
'api_key' => 'sk-ant-CHANGE_ME',
'model' => 'claude-sonnet-5',
],
// Sicherheitsnetz gegen ausufernde Antworten (und damit Kosten) pro Anfrage.
// 8.000 waren bei ausführlichen Einheitenplanungen nachweislich zu knapp und führten zu
// mitten im JSON abgeschnittenen Antworten. Das ist eine Obergrenze, kein vorab berechneter
// Festpreis: kürzere Antworten verbrauchen und kosten weiterhin nur ihre tatsächlichen Tokens.
'max_output_tokens' => 16000,
// USD je 1 Million Token, getrennt nach Input/Output/Prompt-Cache. Vor dem produktiven Einsatz
// gegen die aktuelle Anthropic-Preisseite gegenprüfen — Preise ändern sich.
// "cache_write" = Aufpreis beim ersten Aufruf, der den (jetzt vollständig statischen)
// Systemprompt neu in den Cache schreibt — Anthropic-Standard: 1,25× Input-Preis.
// "cache_read" = deutlich reduzierter Preis für jeden Folgeaufruf innerhalb der Cache-TTL
// (Anthropic-Standard: 5 Minuten), der den Systemprompt aus dem Cache liest statt neu zu
// verarbeiten — Anthropic-Standard: 0,1× Input-Preis.
'pricing' => [
'claude-sonnet-5' => ['input' => 3.00, 'output' => 15.00, 'cache_write' => 3.75, 'cache_read' => 0.30],
'claude-opus-5' => ['input' => 5.00, 'output' => 25.00, 'cache_write' => 6.25, 'cache_read' => 0.50],
'claude-haiku-4-5' => ['input' => 1.00, 'output' => 5.00, 'cache_write' => 1.25, 'cache_read' => 0.10],
],
];
-1
View File
@@ -1 +0,0 @@
[{"name":"SSttooffff--//GGeemmiisscchh&&sshhyy;;bbeezzeeiicchhuunngg && IIUUPPAACC","cas":"","relatedCas":[],"zvg":"ZZVVGG","signalWord":"SSiiggnnaall&&sshhyy;;\nwwoorrtt","ghsPictograms":["GGeeffaahhrreenn&&sshhyy;;","ppiikkttoo&&sshhyy;;","ggrraammmm"],"hStatements":["HH-- uunndd EEUUHH--SSaattzz"],"pStatements":["PP--SSaattzz"],"hazardCategories":["PPhhrraasseenn"],"activityRestrictionCodes":["TTäättiiggkkeeiittss&&sshhyy;;","bbeesscchhrräänn&&sshhyy;;","kkuunnggeenn"]},{"name":"Abfälle, anorganische Salze und Lösungen, ohne CN und Schwermetalle","cas":"","relatedCas":[],"zvg":"17","signalWord":"Gefahr","ghsPictograms":["GHS05","GHS06","GHS08"],"hStatements":["H301","H311","H315","H317","H331","H341","H373"],"pStatements":[],"hazardCategories":["Giftig","Reizend","Allergisierend bei Hautkontakt","KMR-Stoff Kat. 2","Kann die Organe schädigen"],"activityRestrictionCodes":["S4K","W"]},{"name":"Abfälle, organisch fest","cas":"","relatedCas":[],"zvg":"11","signalWord":"Gefahr","ghsPictograms":["GHS05","GHS06","GHS08","GHS09"],"hStatements":["H314","H317","H335","H341","H411"],"pStatements":["P260","P262","P280","P501","P301+P310"],"hazardCategories":["Ätzend/Korrosiv","Allergisierend bei Hautkontakt","Reizend","KMR-Stoff Kat. 2","Gewässergefährdend"],"activityRestrictionCodes":["S4K","W"]},{"name":"Abfälle, organisch flüssig halogenfrei","cas":"","relatedCas":[],"zvg":"12","signalWord":"Gefahr","ghsPictograms":["GHS02","GHS05","GHS06","GHS08","GHS09"],"hStatements":["H225","H314","H317","H335","H341","H411"],"pStatements":["P210","P260","P262","P280","P501","P301+P310"],"hazardCategories":["Leicht entzündbar","Ätzend/Korrosiv","Allergisierend bei Hautkontakt","Reizend","KMR-Stoff Kat. 2","Gewässergefährdend"],"activityRestrictionCodes":["S4K","W"]},{"name":"Abfälle, organisch flüssig halogenhaltig","cas":"","relatedCas":[],"zvg":"13","signalWord":"Gefahr","ghsPictograms":["GHS02","GHS05","GHS06","GHS08","GHS09"],"hStatements":["H225","H314","H317","H335","H341","H411"],"pStatements":["P210","P260","P262","P280","P501","P301+P310"],"hazardCategories":["Leicht entzündbar","Ätzend/Korrosiv","Allergisierend bei Hautkontakt","Reizend","KMR-Stoff Kat. 2","Gewässergefährdend"],"activityRestrictionCodes":["S4K","W"]},{"name":"Abfälle, organische Peroxide","cas":"","relatedCas":[],"zvg":"10","signalWord":"Gefahr","ghsPictograms":["GHS01","GHS05","GHS06","GHS08","GHS09"],"hStatements":["H241","H314","H317","H331","H373","H411"],"pStatements":["P210","P260","P262","P273","P280","P501","P301+P312","P302+P352"],"hazardCategories":["Explosiv","Ätzend/Korrosiv","Allergisierend bei Hautkontakt","Giftig","Kann die Organe schädigen","Gewässergefährdend"],"activityRestrictionCodes":["S","W"]},{"name":"Abfälle, Quecksilbermetall, mit Glasbruch oder Adsorbens","cas":"","relatedCas":[],"zvg":"14","signalWord":"Gefahr","ghsPictograms":["GHS06","GHS08","GHS09"],"hStatements":["H330","H372","H410","H360D"],"pStatements":["P260","P273","P280","P314","P391","P405","P501"],"hazardCategories":["Lebensgefahr","Schädigt die Organe","Gewässergefährdend","KMR-Stoff Kat. 1"],"activityRestrictionCodes":["S","W"]},{"name":"Abfälle, Quecksilberverbindungen","cas":"","relatedCas":[],"zvg":"15","signalWord":"Gefahr","ghsPictograms":["GHS05","GHS06","GHS08","GHS09"],"hStatements":["H314","H317","H373","H410","H300+H310+H330"],"pStatements":["P260","P262","P273","P280","P501","P301+P310"],"hazardCategories":["Ätzend/Korrosiv","Allergisierend bei Hautkontakt","Kann die Organe schädigen","Gewässergefährdend","Lebensgefahr"],"activityRestrictionCodes":["S","W"]},{"name":"Abfälle, Schwermetallsalzlösungen, wässrig","cas":"","relatedCas":[],"zvg":"16","signalWord":"Gefahr","ghsPictograms":["GHS05","GHS06","GHS08","GHS09"],"hStatements":["H314","H373","H410","H360D","H300+H310+H330"],"pStatements":["P260","P262","P273","P280","P501","P301+P312","P302+P352"],"hazardCategories":["Ätzend/Korrosiv","Kann die Organe schädigen","Gewässergefährdend","KMR-Stoff Kat. 1","Lebensgefahr"],"activityRestrictionCodes":["S","W"]},{"name":"Acenaphthen 1,8-Ethylennaphthalin","cas":"83-32-9","relatedCas":[],"zvg":"23000","signalWord":"Achtung","ghsPictograms":["GHS09"],"hStatements":["H410"],"pStatements":["P273","P391","P501"],"hazardCategories":["Gewässergefährdend"],"activityRestrictionCodes":["+"]},{"name":"Acetaldehyd Ethanal","cas":"75-07-0","relatedCas":[],"zvg":"12760","signalWord":"Gefahr","ghsPictograms":["GHS02","GHS07","GHS08"],"hStatements":["H224","H319","H335","H341","H350"],"pStatements":["P202","P210","P233","P308+P313","P403+P233","P305+P351+P338"],"hazardCategories":["Extrem entzündbar","Reizend","KMR-Stoff Kat. 2","KMR-Stoff Kat. 1"],"activityRestrictionCodes":["X"]},{"name":"Acetaldehyd 1% Ethanal-Lösung w=1%","cas":"","relatedCas":[],"zvg":"12760.002","signalWord":"Gefahr","ghsPictograms":["GHS08"],"hStatements":["H341","H350"],"pStatements":["P201","P210","P308+P313","P403+P233","P305+P351+P338"],"hazarLine truncated
-200
View File
@@ -1,200 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/providers/AnthropicProvider.php';
require_once __DIR__ . '/providers/FakeProvider.php';
/** Baut eine PDO-Verbindung aus config.php auf. */
function ai_backend_db(array $config): PDO
{
$db = $config['db'];
$dsn = "mysql:host={$db['host']};dbname={$db['name']};charset=utf8mb4";
return new PDO($dsn, $db['user'], $db['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
/**
* Liest den Authorization-Header — auf vielen Apache/PHP-FPM-Setups landet er NICHT in
* $_SERVER['HTTP_AUTHORIZATION'] (Header wird von PHP-FPM standardmäßig verworfen), sondern nur
* in $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] (nach der .htaccess-Weiterleitung, siehe dort) oder
* ist nur über getallheaders() erreichbar. Alle drei Quellen abklappern, statt sich auf eine zu
* verlassen — genau das war die Ursache für "Login klappt, danach sofort Session abgelaufen".
*/
function ai_backend_authorization_header(): string
{
if (!empty($_SERVER['HTTP_AUTHORIZATION'])) {
return $_SERVER['HTTP_AUTHORIZATION'];
}
if (!empty($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
return $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
}
if (function_exists('getallheaders')) {
foreach (getallheaders() as $name => $value) {
if (strcasecmp($name, 'Authorization') === 0) {
return $value;
}
}
}
return '';
}
/**
* Bearer-Token aus dem Authorization-Header lesen, gegen tokens.token_hash prüfen
* (SHA-256, der Klartext wird nie gespeichert) und den zugehörigen aktiven User zurückgeben.
* Sendet bei fehlendem/ungültigem/abgelaufenem Token selbst eine 401-Antwort und beendet das Skript.
*/
function ai_backend_authenticate(PDO $pdo): array
{
$header = ai_backend_authorization_header();
if (!preg_match('/^Bearer\s+(.+)$/i', $header, $m)) {
ai_backend_fail(401, 'Kein gültiges Token übermittelt.');
}
$tokenHash = hash('sha256', $m[1]);
$stmt = $pdo->prepare(
'SELECT u.* FROM users u
JOIN tokens t ON t.user_id = u.id
WHERE t.token_hash = ? AND t.expires_at > NOW() AND u.is_active = 1
LIMIT 1'
);
$stmt->execute([$tokenHash]);
$user = $stmt->fetch();
if (!$user) {
ai_backend_fail(401, 'Ungültiges oder abgelaufenes Token.');
}
return $user;
}
/** Einheitliche Fehlerantwort als JSON, beendet danach das Skript. */
function ai_backend_fail(int $httpStatus, string $message, array $details = []): never
{
http_response_code($httpStatus);
header('Content-Type: application/json');
echo json_encode(['error' => $message] + $details, JSON_INVALID_UTF8_SUBSTITUTE);
exit;
}
/**
* Dekodiert eine JSON-Antwort des LLM. Modelle setzen trotz entsprechender Anweisung gelegentlich
* Markdown-Codezäune oder einen kurzen Begleittext um das eigentliche JSON. Diese rein
* syntaktischen Zusätze sollen eine ansonsten gültige Antwort nicht unbrauchbar machen.
*/
function ai_backend_decode_json_response(string $content): ?array
{
$content = trim($content, "\xEF\xBB\xBF \t\n\r\0\x0B");
$candidates = [$content];
if (preg_match('/```(?:json)?\s*([\s\S]*?)\s*```/i', $content, $match) === 1) {
$candidates[] = trim($match[1]);
}
$firstBrace = strpos($content, '{');
$lastBrace = strrpos($content, '}');
if ($firstBrace !== false && $lastBrace !== false && $lastBrace >= $firstBrace) {
$candidates[] = substr($content, $firstBrace, $lastBrace - $firstBrace + 1);
}
foreach (array_unique($candidates) as $candidate) {
$decoded = json_decode($candidate, true);
if (is_array($decoded) && json_last_error() === JSON_ERROR_NONE) {
return $decoded;
}
}
error_log(sprintf(
'LLM JSON parse failed: %s; length=%d; sha256=%s',
json_last_error_msg(),
strlen($content),
hash('sha256', $content)
));
return null;
}
/**
* Ruft das konfigurierte LLM (oder den FakeProvider für lokale Tests, siehe README.md) auf und
* verrechnet die echten Token-Kosten gegen das Guthaben des Nutzers. Gemeinsame Logik für jeden
* Endpunkt, der einen LLM-Call abrechnet (plan.php, explain.php) — die SELECT-FOR-UPDATE-
* Absicherung gegen Race Conditions bei gleichzeitigen Anfragen desselben Nutzers soll nicht an
* mehreren Stellen gepflegt werden müssen. Gibt bei Erfolg das Provider-Ergebnis unverändert
* zurück (inkl. "content", das der Aufrufer je nach Endpunkt selbst auswertet).
*/
function ai_backend_call_and_charge(PDO $pdo, array $config, array $user, string $systemPrompt, string $userContent): array
{
$useFake = getenv('AI_BACKEND_FAKE_PROVIDER') === '1';
if ($useFake) {
$provider = new FakeProvider();
$modelKey = 'fake';
} else {
$providerName = $config['llm_provider'];
if ($providerName !== 'anthropic') {
ai_backend_fail(500, "Provider '$providerName' ist nicht implementiert.");
}
$modelKey = $config['anthropic']['model'];
$provider = new AnthropicProvider($config['anthropic']['api_key'], $modelKey);
}
// AnthropicProvider erlaubt dem LLM-Aufruf per curl bis zu 180s (siehe dort) — PHPs eigenes
// max_execution_time-Limit (auf vielen Hosts standardmäßig nur 30s) läuft davon unbeeinflusst
// weiter und killt das Skript sonst mitten im curl_exec() mit einem unabfangbaren Fatal Error,
// sobald ein Aufruf einmal länger braucht als das Hosting-Default. Das ergibt beim Client keine
// lesbare Fehlermeldung (kein JSON-Body mehr), nur den pauschalen Fallback in
// AiPlanningService.BuildRequestFailedExceptionAsync. Deshalb hier großzügig über die 180s der
// Provider-Seite hinaus verlängern, statt das Limit stillschweigend greifen zu lassen.
set_time_limit(200);
try {
$result = $provider->sendMessage($systemPrompt, $userContent, $config['max_output_tokens']);
} catch (RuntimeException $e) {
ai_backend_fail(502, $e->getMessage());
}
$defaultPricing = ['input' => 0, 'output' => 0, 'cache_write' => 0, 'cache_read' => 0];
$pricing = $config['pricing'][$modelKey] ?? ($useFake ? $defaultPricing : null);
if ($pricing === null) {
ai_backend_fail(500, "Kein Preis für Modell '$modelKey' konfiguriert.");
}
$pricing += $defaultPricing; // fehlende cache_write/cache_read in älteren config.php-Einträgen -> 0
$cacheCreationTokens = $result['cacheCreationInputTokens'] ?? 0;
$cacheReadTokens = $result['cacheReadInputTokens'] ?? 0;
$cost = ($result['inputTokens'] / 1_000_000 * $pricing['input'])
+ ($result['outputTokens'] / 1_000_000 * $pricing['output'])
+ ($cacheCreationTokens / 1_000_000 * $pricing['cache_write'])
+ ($cacheReadTokens / 1_000_000 * $pricing['cache_read']);
// Guthaben abziehen und Transaktion protokollieren — mit Zeilensperre, damit zwei gleichzeitige
// Anfragen desselben Nutzers das Guthaben nicht versehentlich unter 0 drücken können.
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('SELECT balance_usd FROM users WHERE id = ? FOR UPDATE');
$stmt->execute([$user['id']]);
$currentBalance = (float) $stmt->fetchColumn();
if ($currentBalance - $cost < 0) {
$pdo->rollBack();
ai_backend_fail(402, 'Guthaben würde durch diese Anfrage negativ werden.');
}
$newBalance = $currentBalance - $cost;
$pdo->prepare('UPDATE users SET balance_usd = ? WHERE id = ?')->execute([$newBalance, $user['id']]);
$pdo->prepare(
'INSERT INTO transactions
(user_id, type, model, input_tokens, output_tokens,
cache_creation_input_tokens, cache_read_input_tokens, cost_usd, balance_after)
VALUES (?, "usage", ?, ?, ?, ?, ?, ?, ?)'
)->execute([
$user['id'], $modelKey, $result['inputTokens'], $result['outputTokens'],
$cacheCreationTokens, $cacheReadTokens, $cost, $newBalance,
]);
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
return $result;
}
-69
View File
@@ -1,69 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
header('Content-Type: application/json');
$config = require __DIR__ . '/config.php';
$pdo = ai_backend_db($config);
$user = ai_backend_authenticate($pdo);
if ((float) $user['balance_usd'] <= 0) {
ai_backend_fail(402, 'Kein Guthaben mehr vorhanden.');
}
$body = json_decode(file_get_contents('php://input'), true);
if (!is_array($body) || !isset($body['unit'], $body['lesson'])) {
ai_backend_fail(400, 'Ungültige Anfrage.');
}
// Fester, eigener Systemprompt (4.5.21 "Schattenfeld") — anders als plan.php ändert dieser
// Endpunkt nichts an der Planung, sondern liefert nur eine erklärende Einordnung dazu, damit die
// Lehrkraft eine von der KI vorgeschlagene Stunde souverän halten kann, auch wenn sie nicht die
// eigene Idee war. Bewusst als eigener Endpunkt statt als Zusatzfeld in jeder plan.php-Antwort:
// die Erklärung wird nur auf Nachfrage abgerufen und damit auch nur dann abgerechnet.
$systemPrompt = <<<PROMPT
Du bist ein Assistent für die Unterrichtsplanung einer Lehrkraft an einer deutschen Schule. Du
bekommst eine bereits vorgeschlagene oder geplante Unterrichtsstunde ("lesson", mit ihrem
Verlaufsplan "phases") im Kontext ihrer Einheit ("unit"). Ändere NICHTS an der Planung — erkläre
ausschließlich den didaktischen Hintergrund dazu, damit die Lehrkraft die Stunde souverän im
Unterricht einsetzen kann, auch wenn sie nicht ihre eigene Idee war.
## Eingabeschema
{
"unit": { "title": "...", "subjectName": "...", "gradeLevel": <Zahl>, "groupName": "..." , ...weitere Felder als Lesekontext },
"lesson": {
"topic": "<Thema>", "date": "<TT.MM.JJJJ oder null>",
"phases": [
{ "name": "<Phasenname>", "durationMinutes": <Zahl>, "activity": "<Tätigkeit>", "material": "<Material>", "shorthand": "<Kurzsymbol>" }
],
"homework": "<Hausaufgabe oder null>", "reflection": "<Reflexion oder null>",
"planningIdeas": "<grobe Ideen der Lehrkraft vor der Feinplanung oder null, nur Lesekontext>"
}
}
## Antwortformat
Antworte AUSSCHLIESSLICH mit gültigem JSON (kein Freitext davor/danach) in genau diesem Schema:
{ "explanation": "<Text>" }
Der Text sollte knapp, aber konkret sein (kein Roman) und dabei, soweit für diese Stunde relevant,
folgende Aspekte abdecken:
- Warum dieser Aufbau/diese Reihenfolge der Phasen (kurze didaktische Begründung)
- Mögliche Stolpersteine oder typische Schülerfehler/-missverständnisse bei diesem Thema
- Ansatzpunkte zur Differenzierung (schwächere/stärkere Schüler)
Kurze Absätze oder eine kurze Liste, keine Überschriften nötig — die Lehrkraft soll das in ein bis
zwei Minuten überfliegen können, nicht einen Aufsatz lesen.
PROMPT;
$userContent = json_encode($body);
$result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userContent);
$parsed = ai_backend_decode_json_response($result['content']);
if (!is_array($parsed) || !isset($parsed['explanation'])) {
ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.');
}
echo json_encode($parsed);
-90
View File
@@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
header('Content-Type: application/json');
$config = require __DIR__ . '/config.php';
$pdo = ai_backend_db($config);
$user = ai_backend_authenticate($pdo);
if ((float) $user['balance_usd'] <= 0) {
ai_backend_fail(402, 'Kein Guthaben mehr vorhanden.');
}
$body = json_decode(file_get_contents('php://input'), true);
if (!is_array($body) || !isset($body['unit'], $body['lesson'])) {
ai_backend_fail(400, 'Ungültige Anfrage.');
}
// Eigener Endpunkt (Nutzerwunsch neben 4.2 "Anhänge je Stunde"), da inhaltlich unabhängig von
// plan.php/explain.php: liefert einen Entwurf für eine Gefährdungsbeurteilung zu genau dieser
// Stunde, zur Weiterbearbeitung im Gefährdungsbeurteilungs-Assistenten des Desktop-Clients.
$systemPrompt = <<<PROMPT
Du bist ein Assistent für die Unterrichtsvorbereitung einer Chemie-/Naturwissenschafts-Lehrkraft
an einer deutschen Schule. Du bekommst eine geplante Unterrichtsstunde ("lesson", mit ihrem
Verlaufsplan "phases") im Kontext ihrer Einheit ("unit"). Erkenne aus Thema, Tätigkeiten und
Material der Stunde, welches Experiment durchgeführt wird, und formuliere dafür EINEN Entwurf
einer schulüblichen Gefährdungsbeurteilung.
## WICHTIG — Grenzen dieser Aufgabe
Dies ist NUR ein Entwurf zur Weiterbearbeitung durch die Lehrkraft, KEIN rechtsverbindliches
Dokument — das macht die Lehrkraft der App bereits an dieser Stelle unmissverständlich klar,
wiederhole diesen Hinweis NICHT im JSON selbst. Gib ausschließlich Angaben wieder, die dir aus
allgemeinem Fachwissen zu den genannten Stoffen/Verfahren bekannt sind. Erfinde KEINE H-/P-Sätze
oder Mengenangaben, wenn du dir nicht sicher bist — schreibe in diesem Fall einen Platzhalter wie
"bitte gegen Sicherheitsdatenblatt prüfen" statt einer erfundenen Angabe. Sei bei der Einstufung
Lehrerversuch/Schülerversuch/Demonstrationsversuch eher vorsichtig (im Zweifel Lehrerversuch statt
Schülerversuch).
## Eingabeschema
{
"unit": { "title": "...", "subjectName": "...", "gradeLevel": <Zahl>, "groupName": "...", ...weitere Felder als Lesekontext },
"lesson": {
"topic": "<Thema>",
"phases": [
{ "name": "...", "activity": "...", "material": "...", "shorthand": "..." }
]
}
}
## Antwortformat
Antworte AUSSCHLIESSLICH mit gültigem JSON (kein Freitext davor/danach) in genau diesem Schema:
{
"experimentKind": "Lehrerversuch" | "Schuelerversuch" | "Demonstrationsversuch",
"procedure": "<Kurzbeschreibung der Durchführung, 2-4 Sätze>",
"substances": [
{
"name": "<Stoffname>",
"amount": "<typische Einsatzmenge, z.B. \"ca. 2 g\">",
"ghsPictograms": ["GHS05", "GHS02"],
"hStatements": "<H-Sätze mit Kurztext, kommagetrennt, z.B. \"H314 - Verursacht schwere Verätzungen\">",
"pStatements": "<P-Sätze, kommagetrennt>"
}
],
"hazards": ["<identifizierte Gefährdung 1>", "..."],
"protectiveMeasures": ["<Schutzmaßnahme 1>", "..."],
"firstAid": "<Erste-Hilfe-Hinweise>",
"disposal": "<Entsorgungshinweise>"
}
Gültige Werte für "ghsPictograms": GHS01 (explosionsgefährlich), GHS02 (entzündbar), GHS03
(brandfördernd), GHS04 (Gase unter Druck), GHS05 (ätzend), GHS06 (giftig), GHS07
(gesundheitsschädlich/reizend), GHS08 (gesundheitsgefährdend), GHS09 (umweltgefährdend).
Halte alle Freitexte knapp und konkret (Stichpunkte statt Fließtext, wo sinnvoll). Nenne nur
Stoffe, die laut Verlaufsplan tatsächlich verwendet werden. Wenn im Verlaufsplan kein erkennbares
Experiment mit Gefahrstoffen vorkommt, liefere trotzdem das vollständige Schema mit plausiblen,
aber vorsichtigen Angaben und einer leeren "substances"-Liste.
PROMPT;
$userContent = json_encode($body);
$result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userContent);
$parsed = ai_backend_decode_json_response($result['content']);
if (!is_array($parsed) || !isset($parsed['procedure'])) {
ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.');
}
echo json_encode($parsed);
-34
View File
@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
header('Content-Type: application/json');
$config = require __DIR__ . '/config.php';
$pdo = ai_backend_db($config);
$body = json_decode(file_get_contents('php://input'), true);
$username = trim((string) ($body['username'] ?? ''));
$password = (string) ($body['password'] ?? '');
if ($username === '' || $password === '') {
ai_backend_fail(400, 'Benutzername und Passwort erforderlich.');
}
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = ? AND is_active = 1 LIMIT 1');
$stmt->execute([$username]);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
ai_backend_fail(401, 'Benutzername oder Passwort ist falsch.');
}
$token = bin2hex(random_bytes(32));
$tokenHash = hash('sha256', $token);
$expiresAt = (new DateTimeImmutable('+30 days'))->format('Y-m-d H:i:s');
$stmt = $pdo->prepare('INSERT INTO tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)');
$stmt->execute([$user['id'], $tokenHash, $expiresAt]);
echo json_encode(['token' => $token]);
@@ -1,7 +0,0 @@
-- Migration für bereits deployte Installationen (schema.sql von vor dem Prompt-Caching-Nachtrag):
-- fügt die beiden neuen Spalten nachträglich hinzu, ohne bestehende Daten anzufassen.
-- Einmalig ausführen: mysql -u <user> -p <datenbankname> < migrations/2026-08-add-cache-tokens.sql
ALTER TABLE transactions
ADD COLUMN cache_creation_input_tokens INT NOT NULL DEFAULT 0 AFTER output_tokens,
ADD COLUMN cache_read_input_tokens INT NOT NULL DEFAULT 0 AFTER cache_creation_input_tokens;
-80
View File
@@ -1,80 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
header('Content-Type: application/json');
$config = require __DIR__ . '/config.php';
$pdo = ai_backend_db($config);
$user = ai_backend_authenticate($pdo);
if ((float) $user['balance_usd'] <= 0) {
ai_backend_fail(402, 'Kein Guthaben mehr vorhanden.');
}
$body = json_decode(file_get_contents('php://input'), true);
if (!is_array($body) || !isset($body['document']['pages']) || !is_array($body['candidates'] ?? null)) {
ai_backend_fail(400, 'Dokument oder Kandidaten fehlen.');
}
if (count($body['candidates']) > 500) {
ai_backend_fail(413, 'Zu viele Textkandidaten in einer Anfrage.');
}
$systemPrompt = <<<'PROMPT'
Du klassifizierst Textblöcke aus deutschen Schul- und Verwaltungsdokumenten für einen
Vorlageneditor. Die Geometrie wurde deterministisch aus einem PDF extrahiert und darf von dir
niemals geändert oder ergänzt werden. Du erhältst Seiten, Textblöcke und eine Liste von Kandidaten.
Für jeden Kandidaten:
- vergib einen kurzen stabilen deutschen Placeholder-Namen nur aus Buchstaben und Ziffern,
- wähle type ausschließlich aus text, multiline, date, number,
- wähle confidence ausschließlich aus high, medium, low,
- darfst du benachbarte, logisch zusammengehörige Zeilen gruppieren. Dann enthält blockIds alle
Original-IDs der Gruppe. Jede übernommene ID muss exakt aus der Eingabe stammen.
- typische Namen sind Anrede, Empfaenger, Adresse, PlzOrt, Datum, Betreff, Aktenzeichen, Brieftext.
Antworte AUSSCHLIESSLICH mit gültigem JSON in diesem Schema:
{
"classifications": [
{
"id": "<id eines Kandidaten>",
"blockIds": ["<unveränderte Original-ID>"],
"name": "<PlaceholderName>",
"type": "text|multiline|date|number",
"confidence": "high|medium|low"
}
]
}
PROMPT;
$result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt,
json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$parsed = ai_backend_decode_json_response($result['content']);
if (!is_array($parsed) || !is_array($parsed['classifications'] ?? null)) {
ai_backend_fail(502, 'Die KI hat kein gültiges Klassifikations-JSON geliefert.');
}
$knownIds = [];
foreach ($body['document']['pages'] as $page) {
foreach (($page['textBlocks'] ?? []) as $block) {
if (isset($block['id'])) $knownIds[(string) $block['id']] = true;
}
}
$candidateIds = array_fill_keys(array_map(fn($x) => (string) ($x['id'] ?? ''), $body['candidates']), true);
$allowedTypes = ['text', 'multiline', 'date', 'number'];
$allowedConfidence = ['high', 'medium', 'low'];
foreach ($parsed['classifications'] as $classification) {
if (!isset($candidateIds[(string) ($classification['id'] ?? '')])
|| !in_array($classification['type'] ?? '', $allowedTypes, true)
|| !in_array($classification['confidence'] ?? '', $allowedConfidence, true)
|| !preg_match('/^[\pL\pN]+$/u', (string) ($classification['name'] ?? ''))
|| !is_array($classification['blockIds'] ?? null)) {
ai_backend_fail(502, 'Die KI-Klassifikation enthält ungültige Werte.');
}
foreach ($classification['blockIds'] as $blockId) {
if (!isset($knownIds[(string) $blockId])) {
ai_backend_fail(502, 'Die KI-Klassifikation referenziert einen unbekannten Textblock.');
}
}
}
echo json_encode(['classifications' => $parsed['classifications']], JSON_UNESCAPED_UNICODE);
-194
View File
@@ -1,194 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
header('Content-Type: application/json');
$config = require __DIR__ . '/config.php';
$pdo = ai_backend_db($config);
$user = ai_backend_authenticate($pdo);
if ((float) $user['balance_usd'] <= 0) {
ai_backend_fail(402, 'Kein Guthaben mehr vorhanden.');
}
$body = json_decode(file_get_contents('php://input'), true);
if (!is_array($body) || !isset($body['unit'])) {
ai_backend_fail(400, 'Ungültige Anfrage.');
}
// Fester Basiskontext (4.5.13): erklärt Domäne, Felder und Konventionen einmal grundlegend, damit
// die Lehrkraft das im freien "instruction"-Feld nicht bei jeder Anfrage wiederholen muss — dort
// steht dann nur noch das fachlich Konkrete ("Baue zwei Stunden zu X mit steigendem Anspruch").
$systemPrompt = <<<PROMPT
Du bist ein Assistent für die Unterrichtsplanung einer Lehrkraft an einer deutschen Schule.
Du bekommst eine Unterrichtseinheit ("unit", mit ihren bisherigen Unterrichtsstunden "lessons")
sowie eine freie Anweisung der Lehrkraft ("instruction") und sollst neue oder geänderte Stunden
für diese Einheit vorschlagen.
## Eingabeschema
Die Eingabe hat exakt diese Struktur:
{
"instruction": "<freie Anweisung der Lehrkraft, kann auch leer sein>",
"allowModifyingExistingLessons": <true oder false, siehe Abschnitt "Umfang dieser Anfrage">,
"focusLessonId": "<GUID einer einzelnen Stunde oder null, siehe Abschnitt "Umfang dieser Anfrage">,
"unit": {
"id": "<GUID der Einheit>",
"title": "<Titel der Einheit>",
"startDate": "<TT.MM.JJJJ oder null>",
"endDate": "<TT.MM.JJJJ oder null>",
"competencies": ["<Kompetenz-Codes, die der Einheit bereits zugeordnet sind>"],
"notes": "<Notizen zur Einheit oder null>",
"subjectName": "<Fach>",
"gradeLevel": <Klassenstufe als Zahl>,
"groupName": "<Name der Lerngruppe>",
"competencyCatalog": [
{ "name": "<Kompetenzbereich>", "items": [{ "code": "<Code>", "description": "<Beschreibung>" }] }
],
"alternativePathCatalog": [
{ "id": "<GUID>", "name": "<Name des alternativen Ablaufs>" }
],
"lessons": [
{
"id": "<GUID der bestehenden Stunde>",
"date": "<TT.MM.JJJJ oder null>",
"lessonNumber": <Zahl oder null>,
"topic": "<Thema>",
"startTime": "<HH:mm oder null>",
"phases": [
{
"name": "<Phasenname>", "durationMinutes": <Zahl>, "activity": "<Tätigkeit>",
"material": "<Material>", "shorthand": "<Kurzsymbol>",
"alternativePathName": "<Name aus alternativePathCatalog oder null>"
}
],
"homework": "<Hausaufgabe oder null>",
"reflection": "<Reflexion oder null>",
"planningIdeas": "<Grobe Ideen/Rohentwurf der Lehrkraft vor der Feinplanung oder null>"
}
]
}
}
Alle Felder auf Einheiten-Ebene ("unit.id"/"title"/"startDate"/"endDate"/"competencies"/"notes"/
"subjectName"/"gradeLevel"/"groupName"/"competencyCatalog"/"alternativePathCatalog") sind reiner
Lesekontext. Die Einheit selbst wird nicht verändert — es gibt in deiner Antwort kein Feld dafür.
Ausschließlich "unit.lessons[]" ist das, was du vorschlägst/änderst und in der Antwort zurückgibst.
## Umfang dieser Anfrage
Das Feld "allowModifyingExistingLessons" in der Eingabe legt fest, was du vorschlagen darfst:
- true: Du darfst sowohl neue Stunden vorschlagen ("id": null) als auch bestehende Stunden
inhaltlich ändern (dafür deren "id" aus der Eingabe exakt übernehmen).
- false: Du darfst AUSSCHLIESSLICH neue Stunden vorschlagen ("id": null). Ändere KEINE bestehende
Stunde inhaltlich — gib niemals die "id" einer bereits vorhandenen Stunde zurück, auch nicht
unverändert. Falls die Anweisung der Lehrkraft eine Änderung an einer bestehenden Stunde
verlangt, die dadurch nicht möglich ist, erkläre das kurz im "summary"-Feld und schlage
stattdessen sinnvolle neue Stunden vor.
Ist "focusLessonId" gesetzt (Anfrage aus dem Editor einer einzelnen Stunde heraus, nicht aus der
Einheiten-Übersicht): Bearbeite AUSSCHLIESSLICH die Stunde mit genau dieser Id gemäß der
Anweisung. Schlage KEINE neuen Stunden vor und ändere KEINE andere bestehende Stunde, auch wenn
"allowModifyingExistingLessons" true ist — die Lehrkraft sieht in diesem Fall nur diese eine
Stunde zur Prüfung, alles andere würde ihr gar nicht angezeigt.
## Fachlicher Kontext und Konventionen
- Richte Anspruch, Wortwahl und Methodik nach "subjectName"/"gradeLevel"/"groupName" aus.
- "competencyCatalog" dient nur zur fachlichen Einordnung — das Schema hat kein Feld, um
einzelnen Stunden Kompetenzen zuzuordnen, erfinde daher kein solches Feld in der Antwort.
- Eine Stunde entspricht einer Unterrichtsstunde à i.d.R. 45 Minuten (Doppelstunden als zwei
Lessons oder als eine mit entsprechend höherer Phasen-Gesamtdauer, je nachdem wie es in den
bereits vorhandenen Stunden dieser Einheit gehandhabt wird).
- Eine Phase ("phases[]") ist eine Zeile im tabellarischen Stundenverlaufsplan, wie in deutschen
Schulen üblich: typische Phasennamen sind z.B. Einstieg, Erarbeitung, Übung, Sicherung,
Reflexion, Vertiefung — orientiere dich an bereits in der Einheit verwendeten Phasennamen,
wenn vorhanden, statt eigene Konventionen einzuführen.
- "shorthand" je Phase ist ein kurzes Sozialform-/Medienkürzel, wie es in deutschen
Stundenverlaufsplänen üblich ist (z.B. "L" Lehrervortrag, "S" Schülerbeitrag, "EA"
Einzelarbeit, "PA" Partnerarbeit, "GA" Gruppenarbeit, "UG" Unterrichtsgespräch, "Tb" Tafelbild,
"AB" Arbeitsblatt) — kein Fließtext.
- "durationMinutes" je Phase sollte in Summe zur für die Stunde realistischen Zeit passen
(i.d.R. rund 45 Minuten je Einzelstunde, abzüglich organisatorischer Zeit).
- "planningIdeas" einer Stunde enthält oft schon vor der Feinplanung festgehaltene grobe Ideen der
Lehrkraft (Stichpunkte, erste Gedanken, mögliche Aufhänger) — nutze das, wo vorhanden, als starken
Hinweis für Thema/Verlaufsplan dieser Stunde, ohne jedes Stichwort wörtlich übernehmen zu müssen.
Gib das Feld in deiner Antwort unverändert zurück, außer die Anweisung der Lehrkraft verlangt
ausdrücklich eine Überarbeitung dieser Ideen selbst.
- "materialSuggestion" je Phase (nur in deiner Antwort, nicht in der Eingabe): ein kurzer Vorschlag
(1-2 Sätze), WAS ein zu dieser Phase passendes Medium/Material konkret zeigen oder enthalten
sollte (z.B. Aufbau eines Tafelbilds, Inhalt eines Arbeitsblatts) — nicht nur "ein Tafelbild
wäre hilfreich", sondern was darauf stehen sollte. Nur setzen, wenn ein Medium über das bereits
in "material"/"shorthand" Genannte hinaus wirklich einen Mehrwert hätte, sonst weglassen (null).
Die Lehrkraft erzeugt das Medium separat selbst mit diesem Vorschlag als Grundlage — plane hier
keine Umsetzungsdetails wie Layout oder Werkzeug.
## Antwortformat
Antworte AUSSCHLIESSLICH mit gültigem JSON (kein Freitext davor/danach) in genau diesem Schema:
{
"lessons": [
{
"id": "<GUID der bestehenden Stunde ODER null für eine neue Stunde>",
"date": "<TT.MM.JJJJ oder null>",
"lessonNumber": <Zahl oder null>,
"topic": "<Thema>",
"startTime": "<HH:mm oder null>",
"phases": [
{
"name": "<Phasenname>",
"durationMinutes": <Zahl>,
"activity": "<Tätigkeit>",
"material": "<Material>",
"shorthand": "<Kurzsymbol>",
"alternativePathName": "<Name aus dem übergebenen Katalog oder null für den Hauptweg>",
"materialSuggestion": "<konkreter Medienvorschlag oder null, siehe oben>"
}
],
"homework": "<Hausaufgabe oder null>",
"reflection": "<Reflexion oder null>",
"planningIdeas": "<unverändert aus der Eingabe übernommen, siehe oben, oder null>"
}
],
"summary": "<kurze menschenlesbare Zusammenfassung, was du getan hast>"
}
WICHTIG: Um eine bestehende Stunde zu ändern, gib exakt deren "id" aus der Eingabe zurück. Für
eine neu vorgeschlagene Stunde setze "id" auf null. Erfinde niemals eine Id, die nicht in der
Eingabe stand. Nutze für "alternativePathName" nur Namen aus dem mitgelieferten Katalog. Wenn die
Anweisung der Lehrkraft unklar oder zu knapp ist, triff eine plausible, fachlich begründbare
Annahme, statt nachzufragen (eine Rückfrage ist über diese Schnittstelle nicht möglich) — beschreibe
deine Annahme kurz im "summary"-Feld.
PROMPT;
$userContent = json_encode($body);
$result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userContent);
// Erst NACH der Abrechnung validieren: die Token wurden real verbraucht, das wird auch dann
// verrechnet, wenn die KI kein valides JSON geliefert hat (siehe Planungsdokument).
if (($result['stopReason'] ?? null) === 'max_tokens') {
ai_backend_fail(502,
'Die KI-Antwort wurde am Ausgabelimit von ' . $config['max_output_tokens'] .
' Tokens abgeschnitten. Bitte den Umfang der Anfrage verkleinern oder das Serverlimit erhöhen.', [
'errorCode' => 'output_limit_reached',
'rawResponse' => $result['content'],
]);
}
$parsed = ai_backend_decode_json_response($result['content']);
if (!is_array($parsed) || !isset($parsed['lessons']) || !is_array($parsed['lessons'])) {
// Die bezahlte Modellantwort nicht wegwerfen: Der Desktop-Client kann sie in einem
// Rettungsdialog vollständig anzeigen und die Lehrkraft daraus gültiges JSON markieren bzw.
// von Hand korrigieren lassen. Nur dieser Planungsendpunkt bietet einen manuellen Import an.
ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.', [
'rawResponse' => $result['content'],
]);
}
// Auch bei syntaktisch gültigem JSON kann erst der streng typisierte Desktop-Client einen
// Feldfehler entdecken (z.B. ein unlesbares Datum). Deshalb reist die Originalantwort bis zum
// Client mit; dort wird sie nach erfolgreicher Verarbeitung sofort wieder verworfen.
$parsed['rawResponse'] = $result['content'];
echo json_encode($parsed);
@@ -1,84 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ProviderInterface.php';
/**
* Ruft die Anthropic Messages API direkt per curl auf — bewusst ohne SDK-Abhängigkeit.
*
* Der Systemprompt wird als eigener, mit "cache_control" markierter Content-Block gesendet
* (Prompt Caching, siehe TODO 4.5.9-Nachtrag) statt als einfacher String — er ist über alle
* Anfragen hinweg identisch (siehe plan.php), also ein idealer Kandidat: wiederholte Aufrufe
* innerhalb der Cache-TTL (Anthropic-Standard: 5 Minuten) zahlen dafür 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 ("cache_creation_input_tokens") — siehe
* plan.php für die Abrechnung beider Fälle.
*/
class AnthropicProvider implements ProviderInterface
{
public function __construct(private string $apiKey, private string $model) {}
public function sendMessage(string $systemPrompt, string $userContent, int $maxTokens): array
{
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'content-type: application/json',
'x-api-key: ' . $this->apiKey,
'anthropic-version: 2023-06-01',
],
CURLOPT_POSTFIELDS => json_encode([
'model' => $this->model,
'max_tokens' => $maxTokens,
'system' => [
['type' => 'text', 'text' => $systemPrompt, 'cache_control' => ['type' => 'ephemeral']],
],
'messages' => [['role' => 'user', 'content' => $userContent]],
]),
// Umfangreiche Einheitenplanungen können bei großen Antworten länger dauern. Der
// Desktop-Client wartet etwas länger (210 s), damit immer dieser Server mit einer
// verständlichen Fehlermeldung antworten kann, statt dass zuerst der Client abbricht.
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_TIMEOUT => 180,
]);
$raw = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($raw === false) {
throw new RuntimeException("Anthropic-Anfrage fehlgeschlagen: $curlError");
}
if ($httpCode >= 400) {
throw new RuntimeException("Anthropic-API-Fehler (HTTP $httpCode): " . substr((string) $raw, 0, 500));
}
$data = json_decode((string) $raw, true);
if (!is_array($data)) {
throw new RuntimeException('Anthropic-Antwort konnte nicht als JSON gelesen werden.');
}
if (($data['stop_reason'] ?? null) === 'refusal') {
throw new RuntimeException('Die KI hat die Anfrage abgelehnt.');
}
$text = '';
foreach (($data['content'] ?? []) as $block) {
if (($block['type'] ?? null) === 'text') {
$text .= $block['text'];
}
}
return [
'content' => $text,
// Insbesondere "max_tokens" darf nicht verloren gehen: Dann ist der Text zwar eine
// technisch erfolgreiche API-Antwort, das JSON aber zwangsläufig am Ende abgeschnitten.
'stopReason' => $data['stop_reason'] ?? null,
'inputTokens' => (int) ($data['usage']['input_tokens'] ?? 0),
'outputTokens' => (int) ($data['usage']['output_tokens'] ?? 0),
'cacheCreationInputTokens' => (int) ($data['usage']['cache_creation_input_tokens'] ?? 0),
'cacheReadInputTokens' => (int) ($data['usage']['cache_read_input_tokens'] ?? 0),
];
}
}
-53
View File
@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ProviderInterface.php';
/**
* Für lokale Smoke-Tests ohne echten API-Key (siehe README.md) — liefert eine feste,
* valide AiPlanningResponse-JSON zurück statt einen echten LLM-Aufruf zu machen. Niemals als
* Standard-Provider in config.php eintragen, nur über eine explizite lokale Umgebungsvariable
* (siehe plan.php) für Entwicklungszwecke aktivieren.
*/
class FakeProvider implements ProviderInterface
{
public function sendMessage(string $systemPrompt, string $userContent, int $maxTokens): array
{
if (str_contains($systemPrompt, 'Placeholder-Namen')) {
$input = json_decode($userContent, true);
$classifications = array_map(static fn(array $candidate): array => [
'id' => $candidate['id'],
'blockIds' => $candidate['blockIds'] ?? [$candidate['id']],
'name' => $candidate['suggestedName'] ?? 'Feld',
'type' => $candidate['type'] ?? 'text',
'confidence' => $candidate['confidence'] ?? 'low',
], $input['candidates'] ?? []);
return [
'content' => json_encode(['classifications' => $classifications]),
'inputTokens' => 42, 'outputTokens' => 17,
'cacheCreationInputTokens' => 0, 'cacheReadInputTokens' => 0,
];
}
return [
'content' => json_encode([
'lessons' => [
[
'id' => null,
'date' => null,
'lessonNumber' => null,
'topic' => 'Fake-Vorschlag zum Testen',
'startTime' => null,
'phases' => [],
'homework' => null,
'reflection' => null,
],
],
'summary' => 'Antwort des FakeProvider (kein echter KI-Aufruf).',
]),
'inputTokens' => 42,
'outputTokens' => 17,
'cacheCreationInputTokens' => 0,
'cacheReadInputTokens' => 0,
];
}
}
@@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
interface ProviderInterface
{
/**
* cacheCreationInputTokens/cacheReadInputTokens sind für Provider ohne Prompt-Caching-
* Unterstützung einfach immer 0 (siehe FakeProvider) — plan.php rechnet damit trotzdem
* korrekt, ohne je nach Provider zu unterscheiden.
*
* @return array{content: string, inputTokens: int, outputTokens: int,
* cacheCreationInputTokens: int, cacheReadInputTokens: int}
*/
public function sendMessage(string $systemPrompt, string $userContent, int $maxTokens): array;
}
-40
View File
@@ -1,40 +0,0 @@
-- KI-Backend (TODO 4.5.9) — MySQL/MariaDB-Schema.
-- Multi-user-fähig von Anfang an, auch wenn zunächst nur ein Nutzer angelegt wird.
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL, -- password_hash()-Ausgabe (bcrypt/argon2)
balance_usd DECIMAL(10,4) NOT NULL DEFAULT 0.0000,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_active TINYINT(1) NOT NULL DEFAULT 1
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token_hash VARCHAR(64) NOT NULL, -- SHA-256 des Tokens; der Klartext wird nie gespeichert
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_token_hash (token_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS transactions (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
type ENUM('usage', 'topup') NOT NULL,
model VARCHAR(64) NULL, -- z.B. 'claude-sonnet-5'; NULL bei topup
input_tokens INT NULL,
output_tokens INT NULL,
-- Prompt Caching (Nachtrag): der stark reduziert abgerechnete "Cache-Read"-Anteil bzw. der
-- etwas teurere "Cache-Write"-Anteil beim ersten Aufruf nach TTL-Ablauf. Für Requests ohne
-- Caching (z.B. FakeProvider) immer 0, nicht NULL — vereinfacht Summenbildung in Auswertungen.
cache_creation_input_tokens INT NOT NULL DEFAULT 0,
cache_read_input_tokens INT NOT NULL DEFAULT 0,
cost_usd DECIMAL(10,6) NOT NULL,
balance_after DECIMAL(10,4) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_created (user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-29
View File
@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
// CLI-Helfer zum Anlegen des ersten Nutzers (oder weiterer Lehrer später), siehe README.md.
// Aufruf: php scripts/create-user.php <benutzername> <passwort> [startguthaben_usd]
require_once __DIR__ . '/../db.php';
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "Nur über die Kommandozeile ausführen.\n");
exit(1);
}
[, $username, $password, $startBalance] = array_pad($argv, 4, null);
if ($username === null || $password === null) {
fwrite(STDERR, "Aufruf: php scripts/create-user.php <benutzername> <passwort> [startguthaben_usd]\n");
exit(1);
}
$config = require __DIR__ . '/../config.php';
$pdo = ai_backend_db($config);
$hash = password_hash($password, PASSWORD_DEFAULT);
$balance = $startBalance !== null ? (float) $startBalance : 0.0;
$stmt = $pdo->prepare('INSERT INTO users (username, password_hash, balance_usd) VALUES (?, ?, ?)');
$stmt->execute([$username, $hash, $balance]);
echo "Nutzer '$username' angelegt, Startguthaben: " . number_format($balance, 2) . " USD.\n";
-49
View File
@@ -1,49 +0,0 @@
<?php
declare(strict_types=1);
// Web-erreichbare Alternative zu scripts/create-user.php, für Hosting ohne PHP-CLI/SSH-Zugriff.
// Durch ein Geheimnis aus config.php geschützt (setup_secret) — WICHTIG: diese Datei nach dem
// Anlegen der benötigten Nutzer wieder vom Server löschen, siehe README.md.
require_once __DIR__ . '/db.php';
header('Content-Type: application/json');
$config = require __DIR__ . '/config.php';
$secret = $config['setup_secret'] ?? null;
if (!is_string($secret) || $secret === '' || $secret === 'CHANGE_ME') {
ai_backend_fail(500, 'setup_secret ist in config.php nicht (sicher) gesetzt.');
}
$body = json_decode(file_get_contents('php://input'), true) ?? [];
if (!hash_equals($secret, (string) ($body['secret'] ?? ''))) {
ai_backend_fail(403, 'Falsches oder fehlendes Geheimnis.');
}
$username = trim((string) ($body['username'] ?? ''));
$password = (string) ($body['password'] ?? '');
$balance = isset($body['balanceUsd']) ? (float) $body['balanceUsd'] : 0.0;
if ($username === '' || $password === '') {
ai_backend_fail(400, 'username und password erforderlich.');
}
$pdo = ai_backend_db($config);
$hash = password_hash($password, PASSWORD_DEFAULT);
// Erneuter Aufruf mit demselben Benutzernamen aktualisiert Passwort/Guthaben, statt einen Fehler
// zu werfen — praktisch, falls beim ersten Versuch z.B. das Guthaben vergessen wurde.
$stmt = $pdo->prepare(
'INSERT INTO users (username, password_hash, balance_usd) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), balance_usd = VALUES(balance_usd)'
);
$stmt->execute([$username, $hash, $balance]);
echo json_encode([
'ok' => true,
'username' => $username,
'balanceUsd' => $balance,
'hinweis' => 'Nutzer angelegt/aktualisiert. Diese Datei (setup-user.php) jetzt vom Server löschen.',
]);
-12
View File
@@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
header('Content-Type: application/json');
$config = require __DIR__ . '/config.php';
$pdo = ai_backend_db($config);
$user = ai_backend_authenticate($pdo);
echo json_encode(['balanceUsd' => (float) $user['balance_usd']]);
-101
View File
@@ -1,101 +0,0 @@
<?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
@@ -1,37 +0,0 @@
<?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'] : ''),
]);
-81
View File
@@ -1,81 +0,0 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
header('Content-Type: application/json');
$config = require __DIR__ . '/config.php';
$pdo = ai_backend_db($config);
$user = ai_backend_authenticate($pdo);
if ((float) $user['balance_usd'] <= 0) {
ai_backend_fail(402, 'Kein Guthaben mehr vorhanden.');
}
$body = json_decode(file_get_contents('php://input'), true);
if (!is_array($body) || !isset($body['rows']) || !is_array($body['rows']) || count($body['rows']) === 0) {
ai_backend_fail(400, 'Ungültige Anfrage.');
}
// Datensparsamkeit (Nutzer-Feedback zum Untis-Hub): dieser Endpunkt bekommt bewusst NIE Namen,
// Klassen oder Datumsangaben - nur eine je Zeile rein technische, für die KI bedeutungslose "id"
// (für die Rückzuordnung im Desktop-Client) sowie die für die Statuseinschätzung tatsächlich
// relevanten Felder aus dem WebUntis-Fehlzeitendatensatz. Mehrere Zeilen eines Abgleichslaufs
// werden in einer Anfrage gebündelt statt je Zeile eine eigene Anfrage zu stellen (Kosten/Latenz).
$systemPrompt = <<<PROMPT
Du hilfst einer Lehrkraft beim Abgleich von WebUntis-Fehlzeiten mit dem internen Anwesenheitsstatus
ihrer Schulverwaltungs-App. Du bekommst eine Liste anonymisierter Fehlzeiten-Zeilen (KEINE Namen,
KEINE Klassen, KEINE Daten) und sollst je Zeile den passendsten internen Status vorschlagen.
## Eingabeschema
{
"rows": [
{
"id": "<rein technische Kennung ohne inhaltliche Bedeutung, exakt so zurückzugeben>",
"reasonText": "<Freitext-Grund aus WebUntis, kann leer sein>",
"absentMinutes": <Zahl, gemeldete Fehlminuten der Unterrichtsstunde>,
"handledOn": <true, wenn der Eintrag laut WebUntis bereits vom Klassenlehrer bearbeitet wurde>,
"externKeyInParentheses": <true/false/null - schulinterne Konvention: Klammerung der
Entschuldigungsnummer bedeutet unentschuldigt, ohne Klammern entschuldigt; null, wenn keine
Nummer hinterlegt ist>,
"currentGuess": "<bereits regelbasiert ermittelter Status, siehe Schlüsselliste unten>"
}
]
}
## Möglicher Zielstatus (exakt einer dieser Schlüssel je Zeile)
- "Present": eigentlich anwesend (z.B. wenn der Freitext klar nahelegt, dass die Fehlzeit ein Irrtum war)
- "Late": weniger als 10 Minuten verspätet
- "SignificantlyLate": mindestens 10, aber weniger als 45 Minuten verspätet
- "LeftDuringClass": während des Unterrichts nach Hause entlassen/gegangen
- "ExcusePending": krank gemeldet, Entschuldigung noch offen/unbearbeitet
- "Excused": krank, entschuldigt
- "Unexcused": unentschuldigt
## Aufgabe
"currentGuess" kommt aus einer festen Regel über die strukturierten Felder und ist im Zweifel schon
korrekt - weiche davon nur ab, wenn "reasonText" eindeutig eine andere Einordnung nahelegt (z.B. ein
Text, der klar auf eine Klassenfahrt, einen Arzttermin mit bereits vorliegendem Attest, oder einen
erkennbaren Fehleintrag hindeutet). Bei Unsicherheit "currentGuess" unverändert übernehmen, nicht raten.
## Antwortformat
Antworte AUSSCHLIESSLICH mit gültigem JSON (kein Freitext davor/danach), mit GENAU einem Eintrag je
Eingabe-"id" (dieselbe Anzahl, dieselben "id"-Werte unverändert, keine erfundenen oder ausgelassenen
ids):
{ "suggestions": [ { "id": "<unverändert aus der Eingabe>", "status": "<einer der Schlüssel oben>" } ] }
PROMPT;
$userContent = json_encode($body);
$result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userContent);
$parsed = ai_backend_decode_json_response($result['content']);
if (!is_array($parsed) || !isset($parsed['suggestions']) || !is_array($parsed['suggestions'])) {
ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.');
}
echo json_encode($parsed);