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:
@@ -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>
|
||||
|
||||
@@ -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`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user