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
@@ -9,6 +9,11 @@ public class AiPlanningRequest
{ {
public string Instruction { get; set; } = ""; public string Instruction { get; set; } = "";
public AiUnitContext Unit { get; set; } = new(); public AiUnitContext Unit { get; set; } = new();
// Steuert, ob die KI bestehende Lessons inhaltlich ändern darf, oder nur neue Lessons
// vorschlagen soll (Einheit umplanen ohne vs. mit Ändern bestehender Stunden). Wird sowohl im
// Systemprompt des Backends durchgesetzt als auch client-seitig in ApplyResponse defensiv
// geprüft — die KI-Antwort wird dafür nicht blind vertraut.
public bool AllowModifyingExistingLessons { get; set; } = true;
} }
public class AiUnitContext public class AiUnitContext
@@ -125,6 +125,52 @@ public sealed class AiPlanningServiceTests
Assert.Equal(group.Id, lesson.GroupId); Assert.Equal(group.Id, lesson.GroupId);
} }
/// Die KI kennt/liefert keinen Status — ohne diese Absicherung würde eine bereits gehaltene
/// Stunde durch eine übernommene KI-Änderung stillschweigend auf "Geplant" zurückgesetzt.
[Fact]
public void ApplyResponse_BekannteId_BehaeltStatusDerBestehendenLessonBei()
{
var group = new LearningGroup();
var unit = new Unit { GroupId = group.Id, Title = "T" };
var existing = new Lesson { UnitId = unit.Id, GroupId = group.Id, Topic = "Alt", Status = LessonStatus.Conducted };
var lessons = new FakeLessons();
lessons.Add(existing);
var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
var accepted = new List<AiLesson> { new() { Id = existing.Id, Topic = "Nachträglich präzisiert" } };
var result = service.ApplyResponse(unit, accepted);
Assert.Equal(LessonStatus.Conducted, Assert.Single(result).Status);
}
/// Umfangs-Umschalter (Nutzer-Nachtrag): "Einheit umplanen ohne Stunden zu ändern" muss auch
/// dann greifen, wenn die KI die Anweisung im Systemprompt ignoriert und trotzdem eine
/// bestehende Id zurückgibt — client-seitig hart durchgesetzt, nicht nur per Prompt erbeten.
[Fact]
public void ApplyResponse_AenderungVerboten_VerwirftUpdateBestehenderLesson()
{
var group = new LearningGroup();
var unit = new Unit { GroupId = group.Id, Title = "T" };
var existing = new Lesson { UnitId = unit.Id, GroupId = group.Id, Topic = "Alt" };
var lessons = new FakeLessons();
lessons.Add(existing);
var service = Build(lessons, new FakeGroups([group]), new FakeSubjects([]),
new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([]));
var accepted = new List<AiLesson>
{
new() { Id = existing.Id, Topic = "Sollte verworfen werden" },
new() { Id = null, Topic = "Neue Stunde bleibt erlaubt" },
};
var result = service.ApplyResponse(unit, accepted, allowModifyingExistingLessons: false);
var lesson = Assert.Single(result);
Assert.Equal("Neue Stunde bleibt erlaubt", lesson.Topic);
}
[Fact] [Fact]
public void ApplyResponse_NullId_WirdAlsNeueLessonBehandelt() public void ApplyResponse_NullId_WirdAlsNeueLessonBehandelt()
{ {
@@ -131,9 +131,15 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
}; };
} }
public async Task<AiPlanningResponse> RequestPlanAsync(Unit unit, string instruction, string token) public async Task<AiPlanningResponse> RequestPlanAsync(Unit unit, string instruction, string token,
bool allowModifyingExistingLessons = true)
{ {
var request = new AiPlanningRequest { Instruction = instruction, Unit = BuildContext(unit, instruction) }; var request = new AiPlanningRequest
{
Instruction = instruction,
Unit = BuildContext(unit, instruction),
AllowModifyingExistingLessons = allowModifyingExistingLessons,
};
using var req = new HttpRequestMessage(HttpMethod.Post, "plan.php") using var req = new HttpRequestMessage(HttpMethod.Post, "plan.php")
{ {
@@ -172,16 +178,20 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
/// <see cref="ILessonRepository.Save"/> je Eintrag auf. Eine akzeptierte AiLesson mit einer Id, /// <see cref="ILessonRepository.Save"/> je Eintrag auf. Eine akzeptierte AiLesson mit einer Id,
/// die keiner tatsächlich zur Einheit gehörenden Lesson entspricht, wird NIE als Update /// die keiner tatsächlich zur Einheit gehörenden Lesson entspricht, wird NIE als Update
/// interpretiert, sondern immer als neue Lesson behandelt (Anti-Halluzinations-Absicherung). /// interpretiert, sondern immer als neue Lesson behandelt (Anti-Halluzinations-Absicherung).
/// Ist <paramref name="allowModifyingExistingLessons"/> false, werden Änderungen an bestehenden
/// Lessons zusätzlich hart verworfen (nicht nur per Systemprompt an die KI erbeten) — die
/// Einschränkung wird also nicht blind der KI-Antwort überlassen.
/// </summary> /// </summary>
public List<Lesson> ApplyResponse(Unit unit, List<AiLesson> acceptedLessons) public List<Lesson> ApplyResponse(Unit unit, List<AiLesson> acceptedLessons, bool allowModifyingExistingLessons = true)
{ {
var existingIds = lessons.GetByUnit(unit.Id).Select(l => l.Id).ToHashSet();
var pathIdsByName = altPaths.GetAll().ToDictionary(p => p.Name, p => p.Id); var pathIdsByName = altPaths.GetAll().ToDictionary(p => p.Name, p => p.Id);
var existingLessons = lessons.GetByUnit(unit.Id).ToDictionary(l => l.Id);
var result = new List<Lesson>(); var result = new List<Lesson>();
foreach (var ai in acceptedLessons) foreach (var ai in acceptedLessons)
{ {
var isUpdate = ai.Id is { } id && existingIds.Contains(id); var isUpdate = ai.Id is { } id && existingLessons.ContainsKey(id);
if (isUpdate && !allowModifyingExistingLessons) continue;
result.Add(new Lesson result.Add(new Lesson
{ {
Id = isUpdate ? ai.Id!.Value : Guid.NewGuid(), Id = isUpdate ? ai.Id!.Value : Guid.NewGuid(),
@@ -193,6 +203,10 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
StartTime = ai.StartTime, StartTime = ai.StartTime,
Homework = ai.Homework, Homework = ai.Homework,
Reflection = ai.Reflection, Reflection = ai.Reflection,
// Status bleibt bei einer Änderung erhalten — sonst würde eine bereits
// durchgeführte Stunde durch eine KI-Anpassung stillschweigend auf "Geplant"
// zurückgesetzt (die KI kennt/liefert diesen Status gar nicht).
Status = isUpdate ? existingLessons[ai.Id!.Value].Status : LessonStatus.Planned,
Phases = ai.Phases.Select(p => new LessonPhaseStep Phases = ai.Phases.Select(p => new LessonPhaseStep
{ {
Name = p.Name, Name = p.Name,
@@ -1113,6 +1113,7 @@ public partial class AiAssistDialogViewModel : ObservableObject
private readonly Unit _unit; private readonly Unit _unit;
[ObservableProperty] private string _instruction = ""; [ObservableProperty] private string _instruction = "";
[ObservableProperty] private bool _allowModifyingExisting = true;
[ObservableProperty] private bool _isBusy; [ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _errorMessage = ""; [ObservableProperty] private string _errorMessage = "";
[ObservableProperty] private bool _hasResults; [ObservableProperty] private bool _hasResults;
@@ -1143,12 +1144,19 @@ public partial class AiAssistDialogViewModel : ObservableObject
ErrorMessage = ""; IsBusy = true; ErrorMessage = ""; IsBusy = true;
try try
{ {
var response = await _aiPlanning.RequestPlanAsync(_unit, Instruction, token); var response = await _aiPlanning.RequestPlanAsync(_unit, Instruction, token, AllowModifyingExisting);
var existingIds = _lessons.GetByUnit(_unit.Id).Select(l => l.Id).ToHashSet(); var existingIds = _lessons.GetByUnit(_unit.Id).Select(l => l.Id).ToHashSet();
ReviewItems.Clear(); ReviewItems.Clear();
foreach (var l in response.Lessons) foreach (var l in response.Lessons)
ReviewItems.Add(new AiLessonReviewItem(l, isNew: l.Id is not { } id || !existingIds.Contains(id))); {
var isExisting = l.Id is { } id && existingIds.Contains(id);
// Falls der Modus Änderungen an bestehenden Stunden verbietet, aber die KI die
// Anweisung trotzdem ignoriert hat: gar nicht erst zur Übernahme anbieten, statt
// dem Nutzer eine Auswahl zu zeigen, die ApplyResponse ohnehin verwerfen würde.
if (isExisting && !AllowModifyingExisting) continue;
ReviewItems.Add(new AiLessonReviewItem(l, isNew: !isExisting));
}
Summary = response.Summary; Summary = response.Summary;
HasResults = true; HasResults = true;
} }
@@ -1160,7 +1168,7 @@ public partial class AiAssistDialogViewModel : ObservableObject
private void Apply() private void Apply()
{ {
var accepted = ReviewItems.Where(i => i.Accepted).Select(i => i.Source).ToList(); var accepted = ReviewItems.Where(i => i.Accepted).Select(i => i.Source).ToList();
foreach (var lesson in _aiPlanning.ApplyResponse(_unit, accepted)) foreach (var lesson in _aiPlanning.ApplyResponse(_unit, accepted, AllowModifyingExisting))
_lessons.Save(lesson); _lessons.Save(lesson);
Result = true; Result = true;
} }
@@ -13,11 +13,14 @@
<TextBlock Text="KI-Unterstützung" Classes="dialogtitle"/> <TextBlock Text="KI-Unterstützung" Classes="dialogtitle"/>
<TextBlock Text="{Binding UnitSummary}" FontSize="12" Opacity="0.6"/> <TextBlock Text="{Binding UnitSummary}" FontSize="12" Opacity="0.6"/>
<StackPanel Spacing="4" IsVisible="{Binding !HasResults}"> <StackPanel Spacing="8" IsVisible="{Binding !HasResults}">
<TextBlock Text="Anweisung" FontSize="12" Opacity="0.7"/> <TextBlock Text="Anweisung" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Instruction}" AcceptsReturn="True" TextWrapping="Wrap" Height="120" <TextBox Text="{Binding Instruction}" AcceptsReturn="True" TextWrapping="Wrap" Height="120"
PlaceholderText="z.B. Ergänze zwei weitere Stunden zum Thema Redoxreaktionen mit steigendem Anspruch." PlaceholderText="z.B. Ergänze zwei weitere Stunden zum Thema Redoxreaktionen mit steigendem Anspruch."
IsEnabled="{Binding !IsBusy}"/> IsEnabled="{Binding !IsBusy}"/>
<CheckBox Content="Auch bestehende Stundeninhalte anpassen" IsChecked="{Binding AllowModifyingExisting}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Deaktivieren, um die Einheit nur um neue Stunden zu erweitern, ohne den Inhalt bereits vorhandener Stunden zu verändern."/>
</StackPanel> </StackPanel>
<TextBlock Text="Anfrage läuft…" FontSize="12" Opacity="0.6" IsVisible="{Binding IsBusy}"/> <TextBlock Text="Anfrage läuft…" FontSize="12" Opacity="0.6" IsVisible="{Binding IsBusy}"/>
+50 -4
View File
@@ -702,15 +702,33 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
anbieten, nicht nur für Einheiten/Stunden im Verlaufsplan — z.B. Vorschläge beim Aufbau eines anbieten, nicht nur für Einheiten/Stunden im Verlaufsplan — z.B. Vorschläge beim Aufbau eines
neuen Stundenplans oder beim Ausgleich nach Änderungen. Gleiches Backend/gleiche Abrechnung neuen Stundenplans oder beim Ausgleich nach Änderungen. Gleiches Backend/gleiche Abrechnung
wie 4.5.9, aber eigenes Export/Import-Schema für die Stundenplan-Daten. wie 4.5.9, aber eigenes Export/Import-Schema für die Stundenplan-Daten.
- [ ] **4.5.12** Offene Frage: Kann ein Agent/Project bei console.claude.ai eingerichtet werden, - [x] **4.5.12** Offene Frage: Kann ein Agent/Project bei console.claude.ai eingerichtet werden,
um Standardinformationen (JSON-Schema, grundlegender Auftragskontext) dort dauerhaft zu um Standardinformationen (JSON-Schema, grundlegender Auftragskontext) dort dauerhaft zu
hinterlegen, statt sie bei jeder Anfrage über das eigene PHP-Backend mitschicken zu müssen? hinterlegen, statt sie bei jeder Anfrage über das eigene PHP-Backend mitschicken zu müssen?
Würde die Nutzer-Anweisung auf das eigentlich Fachliche reduzieren. Würde die Nutzer-Anweisung auf das eigentlich Fachliche reduzieren.
- [ ] **4.5.13** Alternative zu 4.5.12: statt eines extern gepflegten Agents ein app-interner **Antwort: nein, technisch nicht wie gedacht umsetzbar.** Projects/Agents bei console.claude.ai
sind eine Funktion der claude.ai-Chat-Oberfläche, nicht der Messages-API, die
`ai-backend/plan.php` serverseitig aufruft — es gibt keine Möglichkeit, von dort
programmatisch auf einen dort hinterlegten Kontext zuzugreifen (nutzbar wäre das nur bei
manueller Bedienung im Browser, ohne App/Abrechnung). Stattdessen wie in 4.5.13 umgesetzt.
- [x] **4.5.13** Alternative zu 4.5.12: statt eines extern gepflegten Agents ein app-interner
Standard-Prompt neben dem freien Nutzer-Prompt, der den grundlegenden Kontext (Schema, Standard-Prompt neben dem freien Nutzer-Prompt, der den grundlegenden Kontext (Schema,
Auftragsbeschreibung) automatisch mitliefert — der Nutzer muss ihn dann nicht jedes Mal Auftragsbeschreibung) automatisch mitliefert — der Nutzer muss ihn dann nicht jedes Mal
selbst formulieren. Ansatzweise bereits vorhanden (`ai-backend/plan.php`, `$systemPrompt`), selbst formulieren. **Umsetzung:** `$systemPrompt` in `ai-backend/plan.php` zeigt der KI
hier ginge es um eine bewusstere/erweiterte Ausgestaltung statt der aktuell schlanken Variante. jetzt zuerst das vollständige, literale Eingabeschema (nicht nur Fließtext-Beschreibungen
der Felder) und stellt explizit klar, dass die Einheiten-Ebene reiner Lesekontext ist —
nur `lessons[]` wird vorgeschlagen/verändert, für die Einheit selbst gibt es in der
Antwort kein Feld (Nutzer-Nachtrag, nachdem die erste Fassung Felder nur prosaisch statt
strukturell beschrieb). Danach folgen Feldbedeutungen (Fach/Stufe/Gruppe,
Kompetenz-/Alternativpfad-Katalog), deutsche Verlaufsplan-Konventionen (typische
Phasennamen, übliche Sozialform-Kürzel wie "EA"/"GA"/"UG") und Zeitrichtwerte
(≈45 Min./Einzelstunde) — die freie Nutzer-Anweisung muss dadurch nur noch das fachlich
Konkrete enthalten. Zusätzlich die Anweisung, bei unklarer Nutzer-Anweisung eine
begründete Annahme zu treffen statt zu blockieren (Rückfragen sind über diese Schnittstelle
nicht möglich), mit kurzer Begründung im `summary`-Feld. Bewusst nicht angefasst in dieser
Runde: der Kürzel-Katalog (`IShorthandCodeRepository`) selbst wird der KI noch nicht als
Kontext mitgegeben (nur die Konvention allgemein erklärt) — wäre der nächste sinnvolle Schritt,
analog zu `CompetencyCatalog`/`AlternativePathCatalog`.
- [ ] **4.5.14** Planungsdiff: KI-Vorschläge (4.5.9) mit dem bereits Geplanten auf Feldebene - [ ] **4.5.14** Planungsdiff: KI-Vorschläge (4.5.9) mit dem bereits Geplanten auf Feldebene
vergleichbar machen, nicht nur pauschal als "Neu"/"Geändert" markieren wie aktuell im vergleichbar machen, nicht nur pauschal als "Neu"/"Geändert" markieren wie aktuell im
`AiAssistDialog`. Bei geänderten Stunden sollte sichtbar sein, was sich konkret unterscheidet `AiAssistDialog`. Bei geänderten Stunden sollte sichtbar sein, was sich konkret unterscheidet
@@ -718,6 +736,34 @@ folgenden Punkte gehören direkt in `LehrerApp.Desktop`:
zusammenführen lassen — z.B. nur einzelne Phasen einer Stunde übernehmen statt zwingend die zusammenführen lassen — z.B. nur einzelne Phasen einer Stunde übernehmen statt zwingend die
ganze Stunde, oder eigene zwischenzeitliche Änderungen nicht versehentlich überschreiben, ganze Stunde, oder eigene zwischenzeitliche Änderungen nicht versehentlich überschreiben,
falls sich die Einheit seit dem Absenden der Anfrage schon geändert hat. falls sich die Einheit seit dem Absenden der Anfrage schon geändert hat.
- [x] **4.5.15** Umfangs-Umschalter im `AiAssistDialog`: "Einheit umplanen ohne Stunden zu ändern"
vs. "mit Stunden ändern" (Nutzer-Nachtrag zum Konzeptgespräch nach 4.5.9). Neue Checkbox
"Auch bestehende Stundeninhalte anpassen" (Default: an, entspricht dem bisherigen Verhalten).
Zweifach durchgesetzt statt der KI-Antwort blind vertraut: der (bewusst statisch gehaltene,
siehe 4.5.16) Systemprompt erklärt im Abschnitt "Umfang dieser Anfrage", dass die KI das
mitgesendete Feld `allowModifyingExistingLessons` selbst auswerten und befolgen muss, UND
`AiPlanningService.ApplyResponse`/`AiAssistDialogViewModel.Send` verwerfen client-seitig hart
jede zurückgegebene Änderung an einer bestehenden Lesson, falls der Umschalter das verbietet
— auch wenn die KI die Anweisung ignoriert. Noch nicht umgesetzt:
eine interaktive Rückfrage der KI selbst wäre über diese Schnittstelle (einzelne, separat
abgerechnete Anfragen) nicht sinnvoll möglich — der Umschalter muss vorher vom Nutzer gesetzt
werden, keine Rückfrage während der Anfrage.
- [x] **4.5.16** Prompt Caching für wiederkehrende Kontexte (Nutzer-Nachtrag zur Guthabenfrage):
der Systemprompt in `ai-backend/plan.php` ist bei jeder Anfrage identisch — dafür extra
der zuvor dynamisch interpolierte Umfangs-Abschnitt (4.5.15) wieder statisch gemacht, die KI
liest `allowModifyingExistingLessons` jetzt selbst aus der Eingabe statt einer variablen
Formulierung im Prompt. `AnthropicProvider.php` markiert den Systemprompt jetzt als
"cache_control: ephemeral"; wiederholte Anfragen innerhalb der Anthropic-Cache-TTL (Standard
5 Min.) zahlen dafür nur den reduzierten Cache-Read-Preis statt des vollen Input-Preises.
`transactions` um `cache_creation_input_tokens`/`cache_read_input_tokens` erweitert
(`ai-backend/migrations/2026-08-add-cache-tokens.sql` für bereits deployte Installationen),
Preistabelle in `config.example.php` um `cache_write`/`cache_read` je Modell ergänzt.
Provider-Interface entsprechend erweitert (`FakeProvider` liefert dafür einfach 0 zurück).
**Nicht ohne echten API-Key verifizierbar**, ob Caching tatsächlich greift — nur an
`cache_read_input_tokens > 0` in `transactions` bei einer zweiten Anfrage innerhalb der
TTL beobachtbar (siehe `ai-backend/README.md`).
---
## 5. Schülerdokumentation ## 5. Schülerdokumentation
+30
View File
@@ -55,6 +55,33 @@ macht der Nutzer selbst — dieses README beschreibt die nötigen Schritte.
7. In der App unter Einstellungen → KI-Unterstützung aktivieren und mit dem angelegten Nutzer 7. In der App unter Einstellungen → KI-Unterstützung aktivieren und mit dem angelegten Nutzer
anmelden. anmelden.
## Update für bereits deployte Installationen (Prompt-Caching-Nachtrag)
Falls `ai-backend/` schon einmal deployt wurde (schema.sql lief bereits, `config.php` existiert
schon): zwei Schritte nötig, damit Abrechnung und `plan.php` mit dem neuen Prompt Caching
funktionieren.
1. Migration einmalig einspielen (ergänzt zwei neue, nicht-destruktive Spalten):
```bash
mysql -u <user> -p <datenbankname> < migrations/2026-08-add-cache-tokens.sql
```
2. In der bestehenden `config.php` bei jedem Preistabellen-Eintrag `cache_write`/`cache_read`
ergänzen (siehe `config.example.php` für aktuelle Richtwerte) — ohne diese Ergänzung wird der
Cache-Anteil einfach mit 0 USD abgerechnet (kein Fehler, aber auch keine Kostenersparnis).
Danach alle geänderten Dateien (`plan.php`, `db.php`, `providers/`, `.htaccess`, falls noch nicht
aktuell) erneut hochladen.
## Prompt Caching
Der Systemprompt in `plan.php` ist vollständig statisch (identisch bei jeder Anfrage, jedes
Nutzers). `AnthropicProvider.php` markiert ihn deshalb als cacheable — wiederholte Anfragen
innerhalb der Anthropic-Cache-TTL (Standard: 5 Minuten) zahlen für diesen Anteil nur den stark
reduzierten "Cache-Read"-Preis statt des vollen Input-Preises; der erste Aufruf nach TTL-Ablauf
zahlt einen kleinen Aufpreis fürs Neuschreiben des Caches. Das passiert automatisch, ohne
Zutun — nichts an der Bedienung der App ändert sich dadurch. Beobachtbar ist der Effekt in der
`transactions`-Tabelle (`cache_creation_input_tokens` vs. `cache_read_input_tokens`).
## Smoke-Test ohne echten API-Key ## Smoke-Test ohne echten API-Key
`plan.php` liest die Umgebungsvariable `AI_BACKEND_FAKE_PROVIDER` — bei `1` wird statt eines `plan.php` liest die Umgebungsvariable `AI_BACKEND_FAKE_PROVIDER` — bei `1` wird statt eines
@@ -101,6 +128,9 @@ grundsätzlich nicht) — dann hilft nur eine serverseitige Konfiguration durch
- Ob die berechneten Kosten exakt mit der tatsächlichen Anthropic-Abrechnung übereinstimmen. - Ob die berechneten Kosten exakt mit der tatsächlichen Anthropic-Abrechnung übereinstimmen.
- TLS/`.htaccess`-Wirksamkeit und PHP-Version/Erweiterungen auf dem tatsächlichen Hosting. - TLS/`.htaccess`-Wirksamkeit und PHP-Version/Erweiterungen auf dem tatsächlichen Hosting.
- Die komplette Kette Desktop → dieses Backend → Anthropic unter echten Netzwerkbedingungen. - Die komplette Kette Desktop → dieses Backend → Anthropic unter echten Netzwerkbedingungen.
- Ob Prompt Caching tatsächlich greift (`cache_read_input_tokens` > 0 bei einer zweiten Anfrage
innerhalb von 5 Minuten) — der `FakeProvider` simuliert kein Caching, das lässt sich nur gegen
die echte Anthropic-API beobachten (z.B. per Blick in die `transactions`-Tabelle).
## Guthaben aufladen ## Guthaben aufladen
+10 -5
View File
@@ -29,11 +29,16 @@ return [
// Sicherheitsnetz gegen ausufernde Antworten (und damit Kosten) pro Anfrage. // Sicherheitsnetz gegen ausufernde Antworten (und damit Kosten) pro Anfrage.
'max_output_tokens' => 8000, 'max_output_tokens' => 8000,
// USD je 1 Million Token, getrennt nach Input/Output. Vor dem produktiven Einsatz gegen die // USD je 1 Million Token, getrennt nach Input/Output/Prompt-Cache. Vor dem produktiven Einsatz
// aktuelle Anthropic-Preisseite gegenprüfen — Preise ändern sich. // gegen die aktuelle Anthropic-Preisseite gegenprüfen — Preise ändern sich.
// "cache_write" = Aufpreis beim ersten Aufruf, der den (jetzt vollständig statischen)
// Systemprompt neu in den Cache schreibt — Anthropic-Standard: 1,25× Input-Preis.
// "cache_read" = deutlich reduzierter Preis für jeden Folgeaufruf innerhalb der Cache-TTL
// (Anthropic-Standard: 5 Minuten), der den Systemprompt aus dem Cache liest statt neu zu
// verarbeiten — Anthropic-Standard: 0,1× Input-Preis.
'pricing' => [ 'pricing' => [
'claude-sonnet-5' => ['input' => 3.00, 'output' => 15.00], 'claude-sonnet-5' => ['input' => 3.00, 'output' => 15.00, 'cache_write' => 3.75, 'cache_read' => 0.30],
'claude-opus-5' => ['input' => 5.00, 'output' => 25.00], 'claude-opus-5' => ['input' => 5.00, 'output' => 25.00, 'cache_write' => 6.25, 'cache_read' => 0.50],
'claude-haiku-4-5' => ['input' => 1.00, 'output' => 5.00], 'claude-haiku-4-5' => ['input' => 1.00, 'output' => 5.00, 'cache_write' => 1.25, 'cache_read' => 0.10],
], ],
]; ];
@@ -0,0 +1,7 @@
-- Migration für bereits deployte Installationen (schema.sql von vor dem Prompt-Caching-Nachtrag):
-- fügt die beiden neuen Spalten nachträglich hinzu, ohne bestehende Daten anzufassen.
-- Einmalig ausführen: mysql -u <user> -p <datenbankname> < migrations/2026-08-add-cache-tokens.sql
ALTER TABLE transactions
ADD COLUMN cache_creation_input_tokens INT NOT NULL DEFAULT 0 AFTER output_tokens,
ADD COLUMN cache_read_input_tokens INT NOT NULL DEFAULT 0 AFTER cache_creation_input_tokens;
+108 -8
View File
@@ -20,9 +20,94 @@ if (!is_array($body) || !isset($body['unit'])) {
ai_backend_fail(400, 'Ungültige Anfrage.'); 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 $systemPrompt = <<<PROMPT
Du bist ein Assistent für die Unterrichtsplanung einer Lehrkraft. Du bekommst eine Du bist ein Assistent für die Unterrichtsplanung einer Lehrkraft an einer deutschen Schule.
Unterrichtseinheit (JSON) mit ihren bisherigen Stunden sowie eine freie Anweisung der Lehrkraft. 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: 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 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 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; PROMPT;
$userContent = json_encode($body); $userContent = json_encode($body);
@@ -76,12 +164,19 @@ try {
ai_backend_fail(502, $e->getMessage()); 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) { if ($pricing === null) {
ai_backend_fail(500, "Kein Preis für Modell '$modelKey' konfiguriert."); 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']) $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 // Guthaben abziehen und Transaktion protokollieren — mit Zeilensperre, damit zwei gleichzeitige
// Anfragen desselben Nutzers das Guthaben nicht versehentlich unter 0 drücken können. // Anfragen desselben Nutzers das Guthaben nicht versehentlich unter 0 drücken können.
@@ -99,9 +194,14 @@ try {
$newBalance = $currentBalance - $cost; $newBalance = $currentBalance - $cost;
$pdo->prepare('UPDATE users SET balance_usd = ? WHERE id = ?')->execute([$newBalance, $user['id']]); $pdo->prepare('UPDATE users SET balance_usd = ? WHERE id = ?')->execute([$newBalance, $user['id']]);
$pdo->prepare( $pdo->prepare(
'INSERT INTO transactions (user_id, type, model, input_tokens, output_tokens, cost_usd, balance_after) 'INSERT INTO transactions
VALUES (?, "usage", ?, ?, ?, ?, ?)' (user_id, type, model, input_tokens, output_tokens,
)->execute([$user['id'], $modelKey, $result['inputTokens'], $result['outputTokens'], $cost, $newBalance]); 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(); $pdo->commit();
} catch (Throwable $e) { } catch (Throwable $e) {
+16 -2
View File
@@ -3,7 +3,17 @@ declare(strict_types=1);
require_once __DIR__ . '/ProviderInterface.php'; require_once __DIR__ . '/ProviderInterface.php';
/** Ruft die Anthropic Messages API direkt per curl auf — bewusst ohne SDK-Abhängigkeit. */ /**
* Ruft die Anthropic Messages API direkt per curl auf — bewusst ohne SDK-Abhängigkeit.
*
* Der Systemprompt wird als eigener, mit "cache_control" markierter Content-Block gesendet
* (Prompt Caching, siehe TODO 4.5.9-Nachtrag) statt als einfacher String — er ist über alle
* Anfragen hinweg identisch (siehe plan.php), also ein idealer Kandidat: wiederholte Aufrufe
* innerhalb der Cache-TTL (Anthropic-Standard: 5 Minuten) zahlen dafür nur den stark reduzierten
* "Cache-Read"-Preis statt des vollen Input-Preises. Der erste Aufruf nach TTL-Ablauf zahlt einen
* kleinen Aufpreis fürs Neuschreiben des Caches ("cache_creation_input_tokens") — siehe
* plan.php für die Abrechnung beider Fälle.
*/
class AnthropicProvider implements ProviderInterface class AnthropicProvider implements ProviderInterface
{ {
public function __construct(private string $apiKey, private string $model) {} public function __construct(private string $apiKey, private string $model) {}
@@ -22,7 +32,9 @@ class AnthropicProvider implements ProviderInterface
CURLOPT_POSTFIELDS => json_encode([ CURLOPT_POSTFIELDS => json_encode([
'model' => $this->model, 'model' => $this->model,
'max_tokens' => $maxTokens, 'max_tokens' => $maxTokens,
'system' => $systemPrompt, 'system' => [
['type' => 'text', 'text' => $systemPrompt, 'cache_control' => ['type' => 'ephemeral']],
],
'messages' => [['role' => 'user', 'content' => $userContent]], 'messages' => [['role' => 'user', 'content' => $userContent]],
]), ]),
CURLOPT_TIMEOUT => 90, CURLOPT_TIMEOUT => 90,
@@ -58,6 +70,8 @@ class AnthropicProvider implements ProviderInterface
'content' => $text, 'content' => $text,
'inputTokens' => (int) ($data['usage']['input_tokens'] ?? 0), 'inputTokens' => (int) ($data['usage']['input_tokens'] ?? 0),
'outputTokens' => (int) ($data['usage']['output_tokens'] ?? 0), 'outputTokens' => (int) ($data['usage']['output_tokens'] ?? 0),
'cacheCreationInputTokens' => (int) ($data['usage']['cache_creation_input_tokens'] ?? 0),
'cacheReadInputTokens' => (int) ($data['usage']['cache_read_input_tokens'] ?? 0),
]; ];
} }
} }
+2
View File
@@ -31,6 +31,8 @@ class FakeProvider implements ProviderInterface
]), ]),
'inputTokens' => 42, 'inputTokens' => 42,
'outputTokens' => 17, 'outputTokens' => 17,
'cacheCreationInputTokens' => 0,
'cacheReadInputTokens' => 0,
]; ];
} }
} }
+6 -1
View File
@@ -4,7 +4,12 @@ declare(strict_types=1);
interface ProviderInterface interface ProviderInterface
{ {
/** /**
* @return array{content: string, inputTokens: int, outputTokens: int} * cacheCreationInputTokens/cacheReadInputTokens sind für Provider ohne Prompt-Caching-
* Unterstützung einfach immer 0 (siehe FakeProvider) — plan.php rechnet damit trotzdem
* korrekt, ohne je nach Provider zu unterscheiden.
*
* @return array{content: string, inputTokens: int, outputTokens: int,
* cacheCreationInputTokens: int, cacheReadInputTokens: int}
*/ */
public function sendMessage(string $systemPrompt, string $userContent, int $maxTokens): array; public function sendMessage(string $systemPrompt, string $userContent, int $maxTokens): array;
} }
+14 -9
View File
@@ -21,15 +21,20 @@ CREATE TABLE IF NOT EXISTS tokens (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS transactions ( CREATE TABLE IF NOT EXISTS transactions (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL, user_id INT NOT NULL,
type ENUM('usage', 'topup') NOT NULL, type ENUM('usage', 'topup') NOT NULL,
model VARCHAR(64) NULL, -- z.B. 'claude-sonnet-5'; NULL bei topup model VARCHAR(64) NULL, -- z.B. 'claude-sonnet-5'; NULL bei topup
input_tokens INT NULL, input_tokens INT NULL,
output_tokens INT NULL, output_tokens INT NULL,
cost_usd DECIMAL(10,6) NOT NULL, -- Prompt Caching (Nachtrag): der stark reduziert abgerechnete "Cache-Read"-Anteil bzw. der
balance_after DECIMAL(10,4) NOT NULL, -- etwas teurere "Cache-Write"-Anteil beim ersten Aufruf nach TTL-Ablauf. Für Requests ohne
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, -- Caching (z.B. FakeProvider) immer 0, nicht NULL — vereinfacht Summenbildung in Auswertungen.
cache_creation_input_tokens INT NOT NULL DEFAULT 0,
cache_read_input_tokens INT NOT NULL DEFAULT 0,
cost_usd DECIMAL(10,6) NOT NULL,
balance_after DECIMAL(10,4) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_created (user_id, created_at) INDEX idx_user_created (user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;