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 List<AiLesson> Lessons { get; set; } = [];
|
||||||
public string? Summary { 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>
|
/// <summary>
|
||||||
/// Rein (nur Repository-Lesezugriff für den Alternativpfad-Katalog, kein Schreiben) — testbar
|
/// 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
|
/// 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.
|
/// AiPhaseStep.MaterialSuggestion) — leer, wenn die KI für keine Phase einen Vorschlag hatte.
|
||||||
public IReadOnlyList<MaterialPromptItem> MaterialPrompts { get; }
|
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 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,
|
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;
|
Source = source;
|
||||||
IsNew = isNew;
|
IsNew = isNew;
|
||||||
@@ -1118,6 +1133,20 @@ public partial class AiLessonReviewItem : ObservableObject
|
|||||||
DisplayLabel = isNew ? $"Neu: {source.Topic} ({dateText})" : $"Geändert: {source.Topic} ({dateText})";
|
DisplayLabel = isNew ? $"Neu: {source.Topic} ({dateText})" : $"Geändert: {source.Topic} ({dateText})";
|
||||||
DiffText = fieldDiffs is { Count: > 0 } ? string.Join("\n", fieldDiffs.Select(d => "• " + d)) : "";
|
DiffText = fieldDiffs is { Count: > 0 } ? string.Join("\n", fieldDiffs.Select(d => "• " + d)) : "";
|
||||||
MaterialPrompts = materialPrompts ?? [];
|
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))
|
.Where(p => !string.IsNullOrWhiteSpace(p.MaterialSuggestion))
|
||||||
.Select(p => new MaterialPromptItem(p.Name, p.MaterialSuggestion!, _aiPlanning.BuildMaterialPrompt(_unit, l, p)))
|
.Select(p => new MaterialPromptItem(p.Name, p.MaterialSuggestion!, _aiPlanning.BuildMaterialPrompt(_unit, l, p)))
|
||||||
.ToList();
|
.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;
|
Summary = response.Summary;
|
||||||
HasResults = true;
|
HasResults = true;
|
||||||
|
|||||||
@@ -38,6 +38,20 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</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>
|
</StackPanel>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</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
|
(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
|
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.
|
zusätzlicher Textabschnitt je Phase in der ohnehin laufenden Planungsantwort.
|
||||||
- [ ] **4.5.21** (Zurückgestellt, Nutzer-Idee neben 4.5.20) "Schattenfeld" mit didaktischem
|
- [x] **4.5.21** "Schattenfeld" mit didaktischem Hintergrund/Begründung je KI-Stunde, auf Nachfrage
|
||||||
Hintergrund/Begründung je KI-Stunde bzw. -Phase, einklappbar/auf Nachfrage sichtbar — bei
|
sichtbar (Nutzer-Idee neben 4.5.20) — bei einer KI-generierten Stunde fehlt anders als bei
|
||||||
einer KI-generierten Stunde fehlt anders als bei einer selbst geschriebenen das eigene
|
einer selbst geschriebenen das eigene Vorwissen zum "Warum", das für einen sicheren
|
||||||
Vorwissen zum "Warum", das für einen sicheren Unterrichtseinsatz hilft. Zwei Bauweisen
|
Unterrichtseinsatz hilft. **Umsetzung:** entschieden für "nur auf Klick nachgeladen" statt
|
||||||
abzuwägen: immer mitgeneriert (höhere Kosten bei jeder Anfrage, auch wenn ungenutzt) vs. nur
|
immer mitgeneriert — passt zum bestehenden Guthabenmodell (keine Kosten für ungenutzte
|
||||||
auf Klick nachgeladen (wiederverwendet den Nachfassen-Mechanismus aus 4.5.17, kostet nur bei
|
Erklärungen). Neuer, eigener Endpunkt `ai-backend/explain.php` statt Zusatzfeld in jeder
|
||||||
tatsächlicher Nutzung) — Letzteres passt besser zum bestehenden Guthabenmodell. Noch nicht
|
`plan.php`-Antwort: eigener statischer Systemprompt (Begründung des Phasenaufbaus, mögliche
|
||||||
entschieden/umgesetzt.
|
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
|
# Sperrt alles, was kein öffentlicher Endpunkt ist. Nur login.php / status.php / plan.php /
|
||||||
# von außen aufrufbar sein. Siehe README.md — noch robuster ist es, config.php/db.php/schema.sql/
|
# explain.php sollen von außen aufrufbar sein. Siehe README.md — noch robuster ist es,
|
||||||
# providers/ komplett außerhalb des Webroots abzulegen, falls das Hosting das erlaubt.
|
# config.php/db.php/schema.sql/providers/ komplett außerhalb des Webroots abzulegen, falls das
|
||||||
|
# Hosting das erlaubt.
|
||||||
|
|
||||||
<FilesMatch "^(config(\.example)?\.php|db\.php)$">
|
<FilesMatch "^(config(\.example)?\.php|db\.php)$">
|
||||||
Require all denied
|
Require all denied
|
||||||
|
|||||||
@@ -72,6 +72,13 @@ funktionieren.
|
|||||||
Danach alle geänderten Dateien (`plan.php`, `db.php`, `providers/`, `.htaccess`, falls noch nicht
|
Danach alle geänderten Dateien (`plan.php`, `db.php`, `providers/`, `.htaccess`, falls noch nicht
|
||||||
aktuell) erneut hochladen.
|
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
|
## Prompt Caching
|
||||||
|
|
||||||
Der Systemprompt in `plan.php` ist vollständig statisch (identisch bei jeder Anfrage, jedes
|
Der Systemprompt in `plan.php` ist vollständig statisch (identisch bei jeder Anfrage, jedes
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/providers/AnthropicProvider.php';
|
||||||
|
require_once __DIR__ . '/providers/FakeProvider.php';
|
||||||
|
|
||||||
/** Baut eine PDO-Verbindung aus config.php auf. */
|
/** Baut eine PDO-Verbindung aus config.php auf. */
|
||||||
function ai_backend_db(array $config): PDO
|
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]);
|
echo json_encode(['error' => $message]);
|
||||||
exit;
|
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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once __DIR__ . '/db.php';
|
require_once __DIR__ . '/db.php';
|
||||||
require_once __DIR__ . '/providers/AnthropicProvider.php';
|
|
||||||
require_once __DIR__ . '/providers/FakeProvider.php';
|
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
@@ -152,70 +150,7 @@ deine Annahme kurz im "summary"-Feld.
|
|||||||
PROMPT;
|
PROMPT;
|
||||||
|
|
||||||
$userContent = json_encode($body);
|
$userContent = json_encode($body);
|
||||||
|
$result = ai_backend_call_and_charge($pdo, $config, $user, $systemPrompt, $userContent);
|
||||||
$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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Erst NACH der Abrechnung validieren: die Token wurden real verbraucht, das wird auch dann
|
// 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).
|
// verrechnet, wenn die KI kein valides JSON geliefert hat (siehe Planungsdokument).
|
||||||
|
|||||||
Reference in New Issue
Block a user