KI-Feature 4.5.21: didaktischer Hintergrund je Stunde, nur auf Nachfrage

Neuer Endpunkt ai-backend/explain.php mit eigenem statischen Systemprompt
(Begründung des Phasenaufbaus, mögliche Stolpersteine, Differenzierungsideen).
Bewusst als separater Endpunkt statt Zusatzfeld in jeder plan.php-Antwort,
damit die Erklärung nur bei tatsächlicher Nutzung abgerechnet wird statt bei
jeder Planungsanfrage mitgeneriert zu werden.

Die Guthaben-Abrechnung (SELECT-FOR-UPDATE, Transaktions-Insert) wurde aus
plan.php nach ai_backend_call_and_charge in db.php ausgelagert, damit sie
nicht an zwei Stellen gepflegt werden muss. Kein neues DB-Schema nötig.

Im AiAssistDialog erscheint je Stunde ein Button "Didaktischen Hintergrund
erklären", der nach dem Laden durch den Text ersetzt wird.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 22:32:36 +02:00
co-authored by Claude Sonnet 5
parent f703fe7b66
commit 364df378f0
11 changed files with 333 additions and 79 deletions
+4 -3
View File
@@ -1,6 +1,7 @@
# 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.
# 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
+7
View File
@@ -72,6 +72,13 @@ funktionieren.
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).
## Prompt Caching
Der Systemprompt in `plan.php` ist vollständig statisch (identisch bei jeder Anfrage, jedes
+80
View File
@@ -1,6 +1,9 @@
<?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
{
@@ -73,3 +76,80 @@ function ai_backend_fail(int $httpStatus, string $message): never
echo json_encode(['error' => $message]);
exit;
}
/**
* 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);
}
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;
}
+68
View File
@@ -0,0 +1,68 @@
<?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>"
}
}
## 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 = json_decode($result['content'], true);
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 -66
View File
@@ -2,8 +2,6 @@
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');
@@ -152,70 +150,7 @@ deine Annahme kurz im "summary"-Feld.
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());
}
$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;
}
$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).