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