KI-Backend: Umfangs-Umschalter + Prompt Caching (4.5.15/4.5.16)

Umfangs-Umschalter: Checkbox "Auch bestehende Stundeninhalte anpassen" im
AiAssistDialog steuert, ob die KI bestehende Stunden inhaltlich ändern darf oder
die Einheit nur um neue Stunden erweitern soll. Zweifach durchgesetzt (Systemprompt
+ hartes client-seitiges Verwerfen in ApplyResponse), nicht nur der KI-Antwort
vertraut. Dabei auch einen Bug gefixt: eine bereits "Durchgeführt" markierte Stunde
wurde durch eine übernommene KI-Änderung stillschweigend auf "Geplant" zurückgesetzt.

Prompt Caching: der Systemprompt ist jetzt vollständig statisch (Voraussetzung für
Caching) und wird von AnthropicProvider.php als "cache_control: ephemeral" markiert
— wiederholte Anfragen innerhalb der 5-Minuten-TTL zahlen nur den reduzierten
Cache-Read-Preis. transactions-Tabelle und Preistabelle um Cache-Token-Spalten
erweitert, Migration für bereits deployte Installationen beigelegt.
This commit is contained in:
2026-08-16 16:06:52 +02:00
parent 4437f951d9
commit 1928916eac
14 changed files with 328 additions and 38 deletions
+30
View File
@@ -55,6 +55,33 @@ macht der Nutzer selbst — dieses README beschreibt die nötigen Schritte.
7. In der App unter Einstellungen → KI-Unterstützung aktivieren und mit dem angelegten Nutzer
anmelden.
## Update für bereits deployte Installationen (Prompt-Caching-Nachtrag)
Falls `ai-backend/` schon einmal deployt wurde (schema.sql lief bereits, `config.php` existiert
schon): zwei Schritte nötig, damit Abrechnung und `plan.php` mit dem neuen Prompt Caching
funktionieren.
1. Migration einmalig einspielen (ergänzt zwei neue, nicht-destruktive Spalten):
```bash
mysql -u <user> -p <datenbankname> < migrations/2026-08-add-cache-tokens.sql
```
2. In der bestehenden `config.php` bei jedem Preistabellen-Eintrag `cache_write`/`cache_read`
ergänzen (siehe `config.example.php` für aktuelle Richtwerte) — ohne diese Ergänzung wird der
Cache-Anteil einfach mit 0 USD abgerechnet (kein Fehler, aber auch keine Kostenersparnis).
Danach alle geänderten Dateien (`plan.php`, `db.php`, `providers/`, `.htaccess`, falls noch nicht
aktuell) erneut hochladen.
## Prompt Caching
Der Systemprompt in `plan.php` ist vollständig statisch (identisch bei jeder Anfrage, jedes
Nutzers). `AnthropicProvider.php` markiert ihn deshalb als cacheable — wiederholte Anfragen
innerhalb der Anthropic-Cache-TTL (Standard: 5 Minuten) zahlen für diesen Anteil nur den stark
reduzierten "Cache-Read"-Preis statt des vollen Input-Preises; der erste Aufruf nach TTL-Ablauf
zahlt einen kleinen Aufpreis fürs Neuschreiben des Caches. Das passiert automatisch, ohne
Zutun — nichts an der Bedienung der App ändert sich dadurch. Beobachtbar ist der Effekt in der
`transactions`-Tabelle (`cache_creation_input_tokens` vs. `cache_read_input_tokens`).
## Smoke-Test ohne echten API-Key
`plan.php` liest die Umgebungsvariable `AI_BACKEND_FAKE_PROVIDER` — bei `1` wird statt eines
@@ -101,6 +128,9 @@ grundsätzlich nicht) — dann hilft nur eine serverseitige Konfiguration durch
- 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.
- Ob Prompt Caching tatsächlich greift (`cache_read_input_tokens` > 0 bei einer zweiten Anfrage
innerhalb von 5 Minuten) — der `FakeProvider` simuliert kein Caching, das lässt sich nur gegen
die echte Anthropic-API beobachten (z.B. per Blick in die `transactions`-Tabelle).
## Guthaben aufladen
+10 -5
View File
@@ -29,11 +29,16 @@ return [
// 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.
// USD je 1 Million Token, getrennt nach Input/Output/Prompt-Cache. Vor dem produktiven Einsatz
// gegen die aktuelle Anthropic-Preisseite gegenprüfen — Preise ändern sich.
// "cache_write" = Aufpreis beim ersten Aufruf, der den (jetzt vollständig statischen)
// Systemprompt neu in den Cache schreibt — Anthropic-Standard: 1,25× Input-Preis.
// "cache_read" = deutlich reduzierter Preis für jeden Folgeaufruf innerhalb der Cache-TTL
// (Anthropic-Standard: 5 Minuten), der den Systemprompt aus dem Cache liest statt neu zu
// verarbeiten — Anthropic-Standard: 0,1× Input-Preis.
'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],
'claude-sonnet-5' => ['input' => 3.00, 'output' => 15.00, 'cache_write' => 3.75, 'cache_read' => 0.30],
'claude-opus-5' => ['input' => 5.00, 'output' => 25.00, 'cache_write' => 6.25, 'cache_read' => 0.50],
'claude-haiku-4-5' => ['input' => 1.00, 'output' => 5.00, 'cache_write' => 1.25, 'cache_read' => 0.10],
],
];
@@ -0,0 +1,7 @@
-- Migration für bereits deployte Installationen (schema.sql von vor dem Prompt-Caching-Nachtrag):
-- fügt die beiden neuen Spalten nachträglich hinzu, ohne bestehende Daten anzufassen.
-- Einmalig ausführen: mysql -u <user> -p <datenbankname> < migrations/2026-08-add-cache-tokens.sql
ALTER TABLE transactions
ADD COLUMN cache_creation_input_tokens INT NOT NULL DEFAULT 0 AFTER output_tokens,
ADD COLUMN cache_read_input_tokens INT NOT NULL DEFAULT 0 AFTER cache_creation_input_tokens;
+108 -8
View File
@@ -20,9 +20,94 @@ if (!is_array($body) || !isset($body['unit'])) {
ai_backend_fail(400, 'Ungültige Anfrage.');
}
// Fester Basiskontext (4.5.13): erklärt Domäne, Felder und Konventionen einmal grundlegend, damit
// die Lehrkraft das im freien "instruction"-Feld nicht bei jeder Anfrage wiederholen muss — dort
// steht dann nur noch das fachlich Konkrete ("Baue zwei Stunden zu X mit steigendem Anspruch").
$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.
Du bist ein Assistent für die Unterrichtsplanung einer Lehrkraft an einer deutschen Schule.
Du bekommst eine Unterrichtseinheit ("unit", mit ihren bisherigen Unterrichtsstunden "lessons")
sowie eine freie Anweisung der Lehrkraft ("instruction") und sollst neue oder geänderte Stunden
für diese Einheit vorschlagen.
## Eingabeschema
Die Eingabe hat exakt diese Struktur:
{
"instruction": "<freie Anweisung der Lehrkraft, kann auch leer sein>",
"allowModifyingExistingLessons": <true oder false, siehe Abschnitt "Umfang dieser Anfrage">,
"unit": {
"id": "<GUID der Einheit>",
"title": "<Titel der Einheit>",
"startDate": "<TT.MM.JJJJ oder null>",
"endDate": "<TT.MM.JJJJ oder null>",
"competencies": ["<Kompetenz-Codes, die der Einheit bereits zugeordnet sind>"],
"notes": "<Notizen zur Einheit oder null>",
"subjectName": "<Fach>",
"gradeLevel": <Klassenstufe als Zahl>,
"groupName": "<Name der Lerngruppe>",
"competencyCatalog": [
{ "name": "<Kompetenzbereich>", "items": [{ "code": "<Code>", "description": "<Beschreibung>" }] }
],
"alternativePathCatalog": [
{ "id": "<GUID>", "name": "<Name des alternativen Ablaufs>" }
],
"lessons": [
{
"id": "<GUID der bestehenden 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 alternativePathCatalog oder null>"
}
],
"homework": "<Hausaufgabe oder null>",
"reflection": "<Reflexion oder null>"
}
]
}
}
Alle Felder auf Einheiten-Ebene ("unit.id"/"title"/"startDate"/"endDate"/"competencies"/"notes"/
"subjectName"/"gradeLevel"/"groupName"/"competencyCatalog"/"alternativePathCatalog") sind reiner
Lesekontext. Die Einheit selbst wird nicht verändert — es gibt in deiner Antwort kein Feld dafür.
Ausschließlich "unit.lessons[]" ist das, was du vorschlägst/änderst und in der Antwort zurückgibst.
## Umfang dieser Anfrage
Das Feld "allowModifyingExistingLessons" in der Eingabe legt fest, was du vorschlagen darfst:
- true: Du darfst sowohl neue Stunden vorschlagen ("id": null) als auch bestehende Stunden
inhaltlich ändern (dafür deren "id" aus der Eingabe exakt übernehmen).
- false: Du darfst AUSSCHLIESSLICH neue Stunden vorschlagen ("id": null). Ändere KEINE bestehende
Stunde inhaltlich — gib niemals die "id" einer bereits vorhandenen Stunde zurück, auch nicht
unverändert. Falls die Anweisung der Lehrkraft eine Änderung an einer bestehenden Stunde
verlangt, die dadurch nicht möglich ist, erkläre das kurz im "summary"-Feld und schlage
stattdessen sinnvolle neue Stunden vor.
## Fachlicher Kontext und Konventionen
- Richte Anspruch, Wortwahl und Methodik nach "subjectName"/"gradeLevel"/"groupName" aus.
- "competencyCatalog" dient nur zur fachlichen Einordnung — das Schema hat kein Feld, um
einzelnen Stunden Kompetenzen zuzuordnen, erfinde daher kein solches Feld in der Antwort.
- Eine Stunde entspricht einer Unterrichtsstunde à i.d.R. 45 Minuten (Doppelstunden als zwei
Lessons oder als eine mit entsprechend höherer Phasen-Gesamtdauer, je nachdem wie es in den
bereits vorhandenen Stunden dieser Einheit gehandhabt wird).
- Eine Phase ("phases[]") ist eine Zeile im tabellarischen Stundenverlaufsplan, wie in deutschen
Schulen üblich: typische Phasennamen sind z.B. Einstieg, Erarbeitung, Übung, Sicherung,
Reflexion, Vertiefung — orientiere dich an bereits in der Einheit verwendeten Phasennamen,
wenn vorhanden, statt eigene Konventionen einzuführen.
- "shorthand" je Phase ist ein kurzes Sozialform-/Medienkürzel, wie es in deutschen
Stundenverlaufsplänen üblich ist (z.B. "L" Lehrervortrag, "S" Schülerbeitrag, "EA"
Einzelarbeit, "PA" Partnerarbeit, "GA" Gruppenarbeit, "UG" Unterrichtsgespräch, "Tb" Tafelbild,
"AB" Arbeitsblatt) — kein Fließtext.
- "durationMinutes" je Phase sollte in Summe zur für die Stunde realistischen Zeit passen
(i.d.R. rund 45 Minuten je Einzelstunde, abzüglich organisatorischer Zeit).
## Antwortformat
Antworte AUSSCHLIESSLICH mit gültigem JSON (kein Freitext davor/danach) in genau diesem Schema:
{
@@ -52,7 +137,10 @@ Antworte AUSSCHLIESSLICH mit gültigem JSON (kein Freitext davor/danach) in gena
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.
Eingabe stand. Nutze für "alternativePathName" nur Namen aus dem mitgelieferten Katalog. Wenn die
Anweisung der Lehrkraft unklar oder zu knapp ist, triff eine plausible, fachlich begründbare
Annahme, statt nachzufragen (eine Rückfrage ist über diese Schnittstelle nicht möglich) — beschreibe
deine Annahme kurz im "summary"-Feld.
PROMPT;
$userContent = json_encode($body);
@@ -76,12 +164,19 @@ try {
ai_backend_fail(502, $e->getMessage());
}
$pricing = $config['pricing'][$modelKey] ?? ($useFake ? ['input' => 0, 'output' => 0] : null);
$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']);
+ ($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.
@@ -99,9 +194,14 @@ try {
$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]);
'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) {
+16 -2
View File
@@ -3,7 +3,17 @@ declare(strict_types=1);
require_once __DIR__ . '/ProviderInterface.php';
/** Ruft die Anthropic Messages API direkt per curl auf — bewusst ohne SDK-Abhängigkeit. */
/**
* Ruft die Anthropic Messages API direkt per curl auf — bewusst ohne SDK-Abhängigkeit.
*
* Der Systemprompt wird als eigener, mit "cache_control" markierter Content-Block gesendet
* (Prompt Caching, siehe TODO 4.5.9-Nachtrag) statt als einfacher String — er ist über alle
* Anfragen hinweg identisch (siehe plan.php), also ein idealer Kandidat: wiederholte Aufrufe
* innerhalb der Cache-TTL (Anthropic-Standard: 5 Minuten) zahlen dafür nur den stark reduzierten
* "Cache-Read"-Preis statt des vollen Input-Preises. Der erste Aufruf nach TTL-Ablauf zahlt einen
* kleinen Aufpreis fürs Neuschreiben des Caches ("cache_creation_input_tokens") — siehe
* plan.php für die Abrechnung beider Fälle.
*/
class AnthropicProvider implements ProviderInterface
{
public function __construct(private string $apiKey, private string $model) {}
@@ -22,7 +32,9 @@ class AnthropicProvider implements ProviderInterface
CURLOPT_POSTFIELDS => json_encode([
'model' => $this->model,
'max_tokens' => $maxTokens,
'system' => $systemPrompt,
'system' => [
['type' => 'text', 'text' => $systemPrompt, 'cache_control' => ['type' => 'ephemeral']],
],
'messages' => [['role' => 'user', 'content' => $userContent]],
]),
CURLOPT_TIMEOUT => 90,
@@ -58,6 +70,8 @@ class AnthropicProvider implements ProviderInterface
'content' => $text,
'inputTokens' => (int) ($data['usage']['input_tokens'] ?? 0),
'outputTokens' => (int) ($data['usage']['output_tokens'] ?? 0),
'cacheCreationInputTokens' => (int) ($data['usage']['cache_creation_input_tokens'] ?? 0),
'cacheReadInputTokens' => (int) ($data['usage']['cache_read_input_tokens'] ?? 0),
];
}
}
+2
View File
@@ -31,6 +31,8 @@ class FakeProvider implements ProviderInterface
]),
'inputTokens' => 42,
'outputTokens' => 17,
'cacheCreationInputTokens' => 0,
'cacheReadInputTokens' => 0,
];
}
}
+6 -1
View File
@@ -4,7 +4,12 @@ declare(strict_types=1);
interface ProviderInterface
{
/**
* @return array{content: string, inputTokens: int, outputTokens: int}
* cacheCreationInputTokens/cacheReadInputTokens sind für Provider ohne Prompt-Caching-
* Unterstützung einfach immer 0 (siehe FakeProvider) — plan.php rechnet damit trotzdem
* korrekt, ohne je nach Provider zu unterscheiden.
*
* @return array{content: string, inputTokens: int, outputTokens: int,
* cacheCreationInputTokens: int, cacheReadInputTokens: int}
*/
public function sendMessage(string $systemPrompt, string $userContent, int $maxTokens): array;
}
+14 -9
View File
@@ -21,15 +21,20 @@ CREATE TABLE IF NOT EXISTS tokens (
) 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,
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,
-- Prompt Caching (Nachtrag): der stark reduziert abgerechnete "Cache-Read"-Anteil bzw. der
-- etwas teurere "Cache-Write"-Anteil beim ersten Aufruf nach TTL-Ablauf. Für Requests ohne
-- Caching (z.B. FakeProvider) immer 0, nicht NULL — vereinfacht Summenbildung in Auswertungen.
cache_creation_input_tokens INT NOT NULL DEFAULT 0,
cache_read_input_tokens INT NOT NULL DEFAULT 0,
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;