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