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
+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) {