KI-gestützte Planungsunterstützung (4.5.9) + Kompetenzkatalog-Import (8.1.2)

KI-Unterstützung: neuer Einstellungen-Tab (Anmeldung, Guthaben) und Button im
Planungs-Tab, der Einheiten+Stunden als JSON an ein neues PHP-Backend (ai-backend/)
sendet und die Antwort als prüfbare Vorschlagsliste zurückbringt. Provider-Aufruf,
Guthabenverwaltung und Abrechnung nach echten Token-Kosten laufen serverseitig, der
Desktop-Client sieht nie einen LLM-API-Key. Zentral abgesichert: eine von der KI
zurückgegebene Stunden-Id, die zu keiner echten Lesson der Einheit passt, wird nie
als Update übernommen, sondern immer als neue Stunde behandelt.

Kompetenzkatalog-Import (8.1.2): JSON-Export/Import für Kompetenzkataloge.
This commit is contained in:
2026-08-16 01:31:14 +02:00
parent 2b4fda7bb3
commit 8495e1b8d0
40 changed files with 2326 additions and 51 deletions
+19
View File
@@ -0,0 +1,19 @@
# Sperrt alles, was kein öffentlicher Endpunkt ist. Nur login.php / status.php / plan.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]
+81
View File
@@ -0,0 +1,81 @@
# KI-Backend (TODO 4.5.9)
Kleines PHP-Zwischenelement, das die KI-gestützte Planungsunterstützung des Desktop-Clients
absichert: der LLM-API-Key liegt nur hier auf dem Server, der Desktop-Client bekommt nur ein
eigenes Bearer-Token gegen dieses Backend und ein pro Nutzer geführtes Guthaben. Deployment
macht der Nutzer selbst — dieses README beschreibt die nötigen Schritte.
## Voraussetzungen
- PHP 8.1 oder neuer (nutzt `never`-Rückgabetypen, `match`, First-Class-Callable-Syntax nicht,
aber typisierte Properties/Enums nicht zwingend — 8.1 ist die sichere Untergrenze).
- PHP-Erweiterungen: `pdo_mysql`, `curl`, `json` (bei den meisten Hosting-Paketen bereits dabei).
- Eine MySQL- oder MariaDB-Datenbank.
- Ein API-Key für Anthropic (`https://console.anthropic.com`).
## Einrichtung
1. Datenbank anlegen und `schema.sql` importieren:
```bash
mysql -u <user> -p <datenbankname> < schema.sql
```
2. `config.example.php` nach `config.php` kopieren und ausfüllen (DB-Zugang, Anthropic-API-Key,
ggf. die Preistabelle gegen die aktuelle Anthropic-Preisseite prüfen — Preise ändern sich).
`config.php` ist in `.gitignore` und darf nie committet werden.
3. Ersten Nutzer anlegen (weitere Lehrer später genauso):
```bash
php scripts/create-user.php sebastian "einStarkesPasswort" 10.00
```
Der dritte Parameter ist das Startguthaben in USD, optional (Standard 0).
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. In `LehrerApp.Desktop/AppBootstrapper.cs` die Konstante `AiBackendUrl` auf die tatsächlich
deployte Domain setzen und die App neu bauen.
6. In der App unter Einstellungen → KI-Unterstützung aktivieren und mit dem angelegten Nutzer
anmelden.
## 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.
## 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.
## 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.)
+34
View File
@@ -0,0 +1,34 @@
<?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 '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.
'max_output_tokens' => 8000,
// USD je 1 Million Token, getrennt nach Input/Output. Vor dem produktiven Einsatz gegen die
// aktuelle Anthropic-Preisseite gegenprüfen — Preise ändern sich.
'pricing' => [
'claude-sonnet-5' => ['input' => 3.00, 'output' => 15.00],
'claude-opus-5' => ['input' => 5.00, 'output' => 25.00],
'claude-haiku-4-5' => ['input' => 1.00, 'output' => 5.00],
],
];
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/** 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,
]);
}
/**
* 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 = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
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): never
{
http_response_code($httpStatus);
header('Content-Type: application/json');
echo json_encode(['error' => $message]);
exit;
}
+34
View File
@@ -0,0 +1,34 @@
<?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]);
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/providers/AnthropicProvider.php';
require_once __DIR__ . '/providers/FakeProvider.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.');
}
$systemPrompt = <<<PROMPT
Du bist ein Assistent für die Unterrichtsplanung einer Lehrkraft. Du bekommst eine
Unterrichtseinheit (JSON) mit ihren bisherigen Stunden sowie eine freie Anweisung der Lehrkraft.
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>"
}
],
"homework": "<Hausaufgabe oder null>",
"reflection": "<Reflexion 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.
PROMPT;
$userContent = json_encode($body);
$useFake = getenv('AI_BACKEND_FAKE_PROVIDER') === '1'; // nur für lokale Smoke-Tests, siehe README.md
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);
}
try {
$result = $provider->sendMessage($systemPrompt, $userContent, $config['max_output_tokens']);
} catch (RuntimeException $e) {
ai_backend_fail(502, $e->getMessage());
}
$pricing = $config['pricing'][$modelKey] ?? ($useFake ? ['input' => 0, 'output' => 0] : null);
if ($pricing === null) {
ai_backend_fail(500, "Kein Preis für Modell '$modelKey' konfiguriert.");
}
$cost = ($result['inputTokens'] / 1_000_000 * $pricing['input'])
+ ($result['outputTokens'] / 1_000_000 * $pricing['output']);
// 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, cost_usd, balance_after)
VALUES (?, "usage", ?, ?, ?, ?, ?)'
)->execute([$user['id'], $modelKey, $result['inputTokens'], $result['outputTokens'], $cost, $newBalance]);
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
// 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).
$parsed = json_decode($result['content'], true);
if (!is_array($parsed) || !isset($parsed['lessons'])) {
ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.');
}
echo json_encode($parsed);
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/ProviderInterface.php';
/** Ruft die Anthropic Messages API direkt per curl auf — bewusst ohne SDK-Abhängigkeit. */
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' => $systemPrompt,
'messages' => [['role' => 'user', 'content' => $userContent]],
]),
CURLOPT_TIMEOUT => 90,
]);
$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,
'inputTokens' => (int) ($data['usage']['input_tokens'] ?? 0),
'outputTokens' => (int) ($data['usage']['output_tokens'] ?? 0),
];
}
}
+36
View File
@@ -0,0 +1,36 @@
<?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
{
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,
];
}
}
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
interface ProviderInterface
{
/**
* @return array{content: string, inputTokens: int, outputTokens: int}
*/
public function sendMessage(string $systemPrompt, string $userContent, int $maxTokens): array;
}
+35
View File
@@ -0,0 +1,35 @@
-- 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,
cost_usd DECIMAL(10,6) NOT NULL,
balance_after DECIMAL(10,4) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_created (user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+29
View File
@@ -0,0 +1,29 @@
<?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";
+12
View File
@@ -0,0 +1,12 @@
<?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']]);