KI-Feature 4.5.21: didaktischer Hintergrund je Stunde, nur auf Nachfrage

Neuer Endpunkt ai-backend/explain.php mit eigenem statischen Systemprompt
(Begründung des Phasenaufbaus, mögliche Stolpersteine, Differenzierungsideen).
Bewusst als separater Endpunkt statt Zusatzfeld in jeder plan.php-Antwort,
damit die Erklärung nur bei tatsächlicher Nutzung abgerechnet wird statt bei
jeder Planungsanfrage mitgeneriert zu werden.

Die Guthaben-Abrechnung (SELECT-FOR-UPDATE, Transaktions-Insert) wurde aus
plan.php nach ai_backend_call_and_charge in db.php ausgelagert, damit sie
nicht an zwei Stellen gepflegt werden muss. Kein neues DB-Schema nötig.

Im AiAssistDialog erscheint je Stunde ein Button "Didaktischen Hintergrund
erklären", der nach dem Laden durch den Text ersetzt wird.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 22:32:36 +02:00
co-authored by Claude Sonnet 5
parent f703fe7b66
commit 364df378f0
11 changed files with 333 additions and 79 deletions
@@ -95,3 +95,21 @@ public class AiPlanningResponse
public List<AiLesson> Lessons { get; set; } = [];
public string? Summary { get; set; }
}
/// <summary>
/// Wire-Vertrag für den "Schattenfeld"-Endpunkt (4.5.21, ai-backend/explain.php): fragt nur auf
/// Nachfrage einen didaktischen Hintergrund zu einer bereits vorgeschlagenen/geplanten Lesson ab
/// (Warum dieser Aufbau, mögliche Stolpersteine, Differenzierung) — ändert nichts an der Planung,
/// eigener Endpunkt statt Zusatzfeld in jeder plan.php-Antwort, damit nur bei tatsächlicher
/// Nutzung abgerechnet wird.
/// </summary>
public class AiExplainRequest
{
public AiUnitContext Unit { get; set; } = new();
public AiLesson Lesson { get; set; } = new();
}
public class AiExplainResponse
{
public string Explanation { get; set; } = "";
}
@@ -0,0 +1,52 @@
using LehrerApp.Core.AiPlanning;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using Xunit;
namespace LehrerApp.Desktop.Tests;
/// Tests für das "Schattenfeld" (4.5.21): didaktischer Hintergrund zu einer KI-Stunde, nur auf
/// Klick nachgeladen. Die eigentliche HTTP-Abfrage (AiPlanningService.RequestExplanationAsync)
/// wird hier per Delegate ersetzt, da RequestExplanationCommand rein davon abhängt.
public sealed class AiLessonReviewItemTests
{
private static AiLesson Lesson() => new() { Topic = "Elektrolyse", Phases = [] };
[Fact]
public async Task RequestExplanation_SetztText_UndVerbirgtButtonDanach()
{
var item = new AiLessonReviewItem(Lesson(), isNew: true,
requestExplanation: _ => Task.FromResult("Didaktischer Hintergrund..."));
Assert.True(item.ShowExplanationButton);
await item.RequestExplanationCommand.ExecuteAsync(null);
Assert.Equal("Didaktischer Hintergrund...", item.Explanation);
Assert.False(item.ShowExplanationButton);
Assert.False(item.IsLoadingExplanation);
Assert.Equal("", item.ExplanationError);
}
[Fact]
public async Task RequestExplanation_BeiFehler_SetztExplanationErrorUndBehaeltButton()
{
var item = new AiLessonReviewItem(Lesson(), isNew: true,
requestExplanation: _ => throw new AiBackendException("Nicht genügend KI-Guthaben."));
await item.RequestExplanationCommand.ExecuteAsync(null);
Assert.Equal("Nicht genügend KI-Guthaben.", item.ExplanationError);
Assert.Equal("", item.Explanation);
Assert.True(item.ShowExplanationButton);
}
[Fact]
public void OhneAbfragefunktion_WirdKeinButtonAngeboten()
{
var item = new AiLessonReviewItem(Lesson(), isNew: true);
Assert.False(item.CanRequestExplanation);
Assert.False(item.ShowExplanationButton);
}
}
@@ -339,6 +339,47 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
}
}
/// <summary>
/// Fragt einen didaktischen Hintergrund zu einer bereits vorgeschlagenen/geplanten Lesson ab
/// (4.5.21 "Schattenfeld") — eigener Endpunkt (ai-backend/explain.php), damit das nur bei
/// tatsächlicher Nutzung abgerechnet wird statt bei jeder plan.php-Antwort mitgeneriert zu
/// werden. Ändert nichts an der Lesson, liefert nur erklärenden Text.
/// </summary>
public async Task<string> RequestExplanationAsync(Unit unit, AiLesson lesson, string token)
{
var request = new AiExplainRequest { Unit = BuildContext(unit, ""), Lesson = lesson };
using var req = new HttpRequestMessage(HttpMethod.Post, "explain.php")
{
Content = JsonContent.Create(request, options: JsonOptions),
};
req.Headers.Authorization = new("Bearer", token);
HttpResponseMessage resp;
try { resp = await http.SendAsync(req); }
catch (HttpRequestException)
{
throw new AiBackendException("Der KI-Dienst ist nicht erreichbar. Bitte Internetverbindung prüfen.");
}
if (resp.StatusCode == HttpStatusCode.Unauthorized)
throw new AiBackendException("Anmeldung abgelaufen. Bitte in den Einstellungen erneut anmelden.");
if (resp.StatusCode == (HttpStatusCode)402)
throw new AiBackendException("Nicht genügend KI-Guthaben. Bitte Guthaben aufladen.");
if (!resp.IsSuccessStatusCode)
throw new AiBackendException("Die Anfrage an den KI-Dienst ist fehlgeschlagen.");
try
{
var result = await resp.Content.ReadFromJsonAsync<AiExplainResponse>(JsonOptions);
return result?.Explanation ?? throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden.");
}
catch (Exception ex) when (ex is not AiBackendException)
{
throw new AiBackendException("Die Antwort der KI konnte nicht verarbeitet werden. Bitte erneut versuchen.");
}
}
/// <summary>
/// Rein (nur Repository-Lesezugriff für den Alternativpfad-Katalog, kein Schreiben) — testbar
/// mit Fakes. Gibt die zu speichernden Lesson-Objekte zurück; der Aufrufer ruft
@@ -1107,10 +1107,25 @@ public partial class AiLessonReviewItem : ObservableObject
/// AiPhaseStep.MaterialSuggestion) — leer, wenn die KI für keine Phase einen Vorschlag hatte.
public IReadOnlyList<MaterialPromptItem> MaterialPrompts { get; }
/// Ob für diese Lesson überhaupt ein "Hintergrund erklären"-Button angeboten wird (4.5.21) —
/// false z.B. in Tests/Kontexten ohne verdrahtete Abfragefunktion.
public bool CanRequestExplanation => _requestExplanation is not null;
/// Button verschwindet, sobald der Hintergrund einmal geladen wurde (Text steht dann da statt
/// des Buttons) — kein Grund, dieselbe kostenpflichtige Anfrage zweimal anzubieten.
public bool ShowExplanationButton => CanRequestExplanation && string.IsNullOrEmpty(Explanation);
partial void OnExplanationChanged(string value) => OnPropertyChanged(nameof(ShowExplanationButton));
private readonly Func<AiLesson, Task<string>>? _requestExplanation;
[ObservableProperty] private bool _accepted = true;
[ObservableProperty] private string _explanation = "";
[ObservableProperty] private bool _isLoadingExplanation;
[ObservableProperty] private string _explanationError = "";
public AiLessonReviewItem(AiLesson source, bool isNew, List<string>? fieldDiffs = null,
List<MaterialPromptItem>? materialPrompts = null)
List<MaterialPromptItem>? materialPrompts = null, Func<AiLesson, Task<string>>? requestExplanation = null)
{
Source = source;
IsNew = isNew;
@@ -1118,6 +1133,20 @@ public partial class AiLessonReviewItem : ObservableObject
DisplayLabel = isNew ? $"Neu: {source.Topic} ({dateText})" : $"Geändert: {source.Topic} ({dateText})";
DiffText = fieldDiffs is { Count: > 0 } ? string.Join("\n", fieldDiffs.Select(d => "• " + d)) : "";
MaterialPrompts = materialPrompts ?? [];
_requestExplanation = requestExplanation;
}
/// Holt den didaktischen Hintergrund erst auf Klick nach (4.5.21 "Schattenfeld") — eigener,
/// nur bei tatsächlicher Nutzung abgerechneter Endpunkt statt bei jeder Antwort mitgeneriert.
[RelayCommand]
private async Task RequestExplanation()
{
if (_requestExplanation is null || IsLoadingExplanation) return;
IsLoadingExplanation = true;
ExplanationError = "";
try { Explanation = await _requestExplanation(Source); }
catch (AiBackendException ex) { ExplanationError = ex.Message; }
finally { IsLoadingExplanation = false; }
}
}
@@ -1183,7 +1212,8 @@ public partial class AiAssistDialogViewModel : ObservableObject
.Where(p => !string.IsNullOrWhiteSpace(p.MaterialSuggestion))
.Select(p => new MaterialPromptItem(p.Name, p.MaterialSuggestion!, _aiPlanning.BuildMaterialPrompt(_unit, l, p)))
.ToList();
ReviewItems.Add(new AiLessonReviewItem(l, isNew: !isExisting, fieldDiffs, materialPrompts));
ReviewItems.Add(new AiLessonReviewItem(l, isNew: !isExisting, fieldDiffs, materialPrompts,
requestExplanation: aiLesson => _aiPlanning.RequestExplanationAsync(_unit, aiLesson, token)));
}
Summary = response.Summary;
HasResults = true;
@@ -38,6 +38,20 @@
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="💡 Didaktischen Hintergrund erklären" FontSize="11" Margin="24,2,0,0"
HorizontalAlignment="Left"
Command="{Binding RequestExplanationCommand}"
IsVisible="{Binding ShowExplanationButton}"
IsEnabled="{Binding !IsLoadingExplanation}"/>
<TextBlock Text="Lädt…" FontSize="11" Opacity="0.6" Margin="24,0,0,0"
IsVisible="{Binding IsLoadingExplanation}"/>
<TextBlock Text="{Binding ExplanationError}" Foreground="Red" FontSize="11" TextWrapping="Wrap"
Margin="24,0,0,0"
IsVisible="{Binding ExplanationError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Border Background="#0A000000" CornerRadius="4" Padding="6" Margin="24,2,0,0"
IsVisible="{Binding Explanation, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Explanation}" FontSize="11" TextWrapping="Wrap"/>
</Border>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
+16 -8
View File
@@ -824,14 +824,22 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
(extern erzeugen)" (Avalonia-`IClipboard`, erste Zwischenablage-Nutzung in der App), den die
Lehrkraft in eine eigene Claude-Sitzung einfügt. Kein neuer Server-Roundtrip: nur ein kurzer
zusätzlicher Textabschnitt je Phase in der ohnehin laufenden Planungsantwort.
- [ ] **4.5.21** (Zurückgestellt, Nutzer-Idee neben 4.5.20) "Schattenfeld" mit didaktischem
Hintergrund/Begründung je KI-Stunde bzw. -Phase, einklappbar/auf Nachfrage sichtbar — bei
einer KI-generierten Stunde fehlt anders als bei einer selbst geschriebenen das eigene
Vorwissen zum "Warum", das für einen sicheren Unterrichtseinsatz hilft. Zwei Bauweisen
abzuwägen: immer mitgeneriert (höhere Kosten bei jeder Anfrage, auch wenn ungenutzt) vs. nur
auf Klick nachgeladen (wiederverwendet den Nachfassen-Mechanismus aus 4.5.17, kostet nur bei
tatsächlicher Nutzung) — Letzteres passt besser zum bestehenden Guthabenmodell. Noch nicht
entschieden/umgesetzt.
- [x] **4.5.21** "Schattenfeld" mit didaktischem Hintergrund/Begründung je KI-Stunde, auf Nachfrage
sichtbar (Nutzer-Idee neben 4.5.20) — bei einer KI-generierten Stunde fehlt anders als bei
einer selbst geschriebenen das eigene Vorwissen zum "Warum", das für einen sicheren
Unterrichtseinsatz hilft. **Umsetzung:** entschieden für "nur auf Klick nachgeladen" statt
immer mitgeneriert — passt zum bestehenden Guthabenmodell (keine Kosten für ungenutzte
Erklärungen). Neuer, eigener Endpunkt `ai-backend/explain.php` statt Zusatzfeld in jeder
`plan.php`-Antwort: eigener statischer Systemprompt (Begründung des Phasenaufbaus, mögliche
Stolpersteine/Schülermissverständnisse, Differenzierungsideen — kurz gehalten, kein Roman),
eigene Abrechnung. Die Guthaben-Abzugslogik aus `plan.php` (SELECT-FOR-UPDATE gegen Race
Conditions, Transaktions-Insert) wurde dafür nach `ai_backend_call_and_charge` in `db.php`
ausgelagert, damit sie nicht doppelt gepflegt werden muss. Neue DTOs `AiExplainRequest`/
`AiExplainResponse`, `AiPlanningService.RequestExplanationAsync`. Im `AiAssistDialog`
erscheint je Stunde ein Button "💡 Didaktischen Hintergrund erklären"
(`AiLessonReviewItem.RequestExplanationCommand`), der nach Laden durch den Text ersetzt
wird — dieselbe Anfrage wird nicht zweimal angeboten. Kein neues DB-Schema nötig (nutzt
dieselben `users`/`tokens`/`transactions`-Tabellen wie `plan.php`).
---
+4 -3
View File
@@ -1,6 +1,7 @@
# 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.
# Sperrt alles, was kein öffentlicher Endpunkt ist. Nur login.php / status.php / plan.php /
# explain.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
+7
View File
@@ -72,6 +72,13 @@ funktionieren.
Danach alle geänderten Dateien (`plan.php`, `db.php`, `providers/`, `.htaccess`, falls noch nicht
aktuell) erneut hochladen.
## Update für bereits deployte Installationen ("Schattenfeld"-Nachtrag, `explain.php`)
Kein neues DB-Schema nötig (nutzt dieselben `users`/`tokens`/`transactions`-Tabellen wie `plan.php`).
Einfach die neue Datei `explain.php` sowie die aktualisierten `db.php` und `plan.php` hochladen
(die Abrechnungslogik wurde aus `plan.php` in eine gemeinsame Funktion `ai_backend_call_and_charge`
in `db.php` verschoben, damit `explain.php` sie mitverwenden kann, ohne sie zu duplizieren).
## Prompt Caching
Der Systemprompt in `plan.php` ist vollständig statisch (identisch bei jeder Anfrage, jedes
+80
View File
@@ -1,6 +1,9 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/providers/AnthropicProvider.php';
require_once __DIR__ . '/providers/FakeProvider.php';
/** Baut eine PDO-Verbindung aus config.php auf. */
function ai_backend_db(array $config): PDO
{
@@ -73,3 +76,80 @@ function ai_backend_fail(int $httpStatus, string $message): never
echo json_encode(['error' => $message]);
exit;
}
/**
* Ruft das konfigurierte LLM (oder den FakeProvider für lokale Tests, siehe README.md) auf und
* verrechnet die echten Token-Kosten gegen das Guthaben des Nutzers. Gemeinsame Logik für jeden
* Endpunkt, der einen LLM-Call abrechnet (plan.php, explain.php) — die SELECT-FOR-UPDATE-
* Absicherung gegen Race Conditions bei gleichzeitigen Anfragen desselben Nutzers soll nicht an
* mehreren Stellen gepflegt werden müssen. Gibt bei Erfolg das Provider-Ergebnis unverändert
* zurück (inkl. "content", das der Aufrufer je nach Endpunkt selbst auswertet).
*/
function ai_backend_call_and_charge(PDO $pdo, array $config, array $user, string $systemPrompt, string $userContent): array
{
$useFake = getenv('AI_BACKEND_FAKE_PROVIDER') === '1';
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());
}
$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'])
+ ($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.
$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,
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) {
$pdo->rollBack();
throw $e;
}
return $result;
}
+68
View File
@@ -0,0 +1,68 @@
<?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);
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'], $body['lesson'])) {
ai_backend_fail(400, 'Ungültige Anfrage.');
}
// Fester, eigener Systemprompt (4.5.21 "Schattenfeld") — anders als plan.php ändert dieser
// Endpunkt nichts an der Planung, sondern liefert nur eine erklärende Einordnung dazu, damit die
// Lehrkraft eine von der KI vorgeschlagene Stunde souverän halten kann, auch wenn sie nicht die
// eigene Idee war. Bewusst als eigener Endpunkt statt als Zusatzfeld in jeder plan.php-Antwort:
// die Erklärung wird nur auf Nachfrage abgerufen und damit auch nur dann abgerechnet.
$systemPrompt = <<<PROMPT
Du bist ein Assistent für die Unterrichtsplanung einer Lehrkraft an einer deutschen Schule. Du
bekommst eine bereits vorgeschlagene oder geplante Unterrichtsstunde ("lesson", mit ihrem
Verlaufsplan "phases") im Kontext ihrer Einheit ("unit"). Ändere NICHTS an der Planung — erkläre
ausschließlich den didaktischen Hintergrund dazu, damit die Lehrkraft die Stunde souverän im
Unterricht einsetzen kann, auch wenn sie nicht ihre eigene Idee war.
## Eingabeschema
{
"unit": { "title": "...", "subjectName": "...", "gradeLevel": <Zahl>, "groupName": "..." , ...weitere Felder als Lesekontext },
"lesson": {
"topic": "<Thema>", "date": "<TT.MM.JJJJ oder null>",
"phases": [
{ "name": "<Phasenname>", "durationMinutes": <Zahl>, "activity": "<Tätigkeit>", "material": "<Material>", "shorthand": "<Kurzsymbol>" }
],
"homework": "<Hausaufgabe oder null>", "reflection": "<Reflexion oder null>"
}
}
## Antwortformat
Antworte AUSSCHLIESSLICH mit gültigem JSON (kein Freitext davor/danach) in genau diesem Schema:
{ "explanation": "<Text>" }
Der Text sollte knapp, aber konkret sein (kein Roman) und dabei, soweit für diese Stunde relevant,
folgende Aspekte abdecken:
- Warum dieser Aufbau/diese Reihenfolge der Phasen (kurze didaktische Begründung)
- Mögliche Stolpersteine oder typische Schülerfehler/-missverständnisse bei diesem Thema
- Ansatzpunkte zur Differenzierung (schwächere/stärkere Schüler)
Kurze Absätze oder eine kurze Liste, keine Überschriften nötig — die Lehrkraft soll das in ein bis
zwei Minuten überfliegen können, nicht einen Aufsatz lesen.
PROMPT;
$userContent = json_encode($body);
$result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userContent);
$parsed = json_decode($result['content'], true);
if (!is_array($parsed) || !isset($parsed['explanation'])) {
ai_backend_fail(502, 'Die KI hat kein gültiges JSON im erwarteten Schema zurückgegeben.');
}
echo json_encode($parsed);
+1 -66
View File
@@ -2,8 +2,6 @@
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');
@@ -152,70 +150,7 @@ deine Annahme kurz im "summary"-Feld.
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());
}
$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'])
+ ($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.
$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,
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) {
$pdo->rollBack();
throw $e;
}
$result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userContent);
// 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).