This commit is contained in:
1 parent
97eb41a0ea
commit
336869e75f
24 files changed
+38
-1504
No files matched your search
@@ -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`,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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.)
|
|
||||||
@@ -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 +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
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
@@ -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;
|
|
||||||
@@ -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);
|
|
||||||
@@ -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),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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";
|
|
||||||
@@ -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.',
|
|
||||||
]);
|
|
||||||
@@ -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']]);
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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'] : ''),
|
|
||||||
]);
|
|
||||||
@@ -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);
|
|
||||||
Reference in new issue
Block a user