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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user