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:
+108
-8
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user