Files
LehrerApp/ai-backend/plan.php
T
admin 8495e1b8d0 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.
2026-08-16 01:31:14 +02:00

120 lines
4.4 KiB
PHP

<?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);