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:
@@ -9,6 +9,11 @@ public class AiPlanningRequest
|
||||
{
|
||||
public string Instruction { get; set; } = "";
|
||||
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
|
||||
|
||||
@@ -125,6 +125,52 @@ public sealed class AiPlanningServiceTests
|
||||
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]
|
||||
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")
|
||||
{
|
||||
@@ -172,16 +178,20 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
/// <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
|
||||
/// 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>
|
||||
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 existingLessons = lessons.GetByUnit(unit.Id).ToDictionary(l => l.Id);
|
||||
|
||||
var result = new List<Lesson>();
|
||||
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
|
||||
{
|
||||
Id = isUpdate ? ai.Id!.Value : Guid.NewGuid(),
|
||||
@@ -193,6 +203,10 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons,
|
||||
StartTime = ai.StartTime,
|
||||
Homework = ai.Homework,
|
||||
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
|
||||
{
|
||||
Name = p.Name,
|
||||
|
||||
@@ -1113,6 +1113,7 @@ public partial class AiAssistDialogViewModel : ObservableObject
|
||||
private readonly Unit _unit;
|
||||
|
||||
[ObservableProperty] private string _instruction = "";
|
||||
[ObservableProperty] private bool _allowModifyingExisting = true;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private string _errorMessage = "";
|
||||
[ObservableProperty] private bool _hasResults;
|
||||
@@ -1143,12 +1144,19 @@ public partial class AiAssistDialogViewModel : ObservableObject
|
||||
ErrorMessage = ""; IsBusy = true;
|
||||
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();
|
||||
|
||||
ReviewItems.Clear();
|
||||
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;
|
||||
HasResults = true;
|
||||
}
|
||||
@@ -1160,7 +1168,7 @@ public partial class AiAssistDialogViewModel : ObservableObject
|
||||
private void Apply()
|
||||
{
|
||||
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);
|
||||
Result = true;
|
||||
}
|
||||
|
||||
@@ -13,11 +13,14 @@
|
||||
<TextBlock Text="KI-Unterstützung" Classes="dialogtitle"/>
|
||||
<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"/>
|
||||
<TextBox Text="{Binding Instruction}" AcceptsReturn="True" TextWrapping="Wrap" Height="120"
|
||||
PlaceholderText="z.B. Ergänze zwei weitere Stunden zum Thema Redoxreaktionen mit steigendem Anspruch."
|
||||
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>
|
||||
|
||||
<TextBlock Text="Anfrage läuft…" FontSize="12" Opacity="0.6" IsVisible="{Binding IsBusy}"/>
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
- [ ] **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
|
||||
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.
|
||||
- [ ] **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,
|
||||
Auftragsbeschreibung) automatisch mitliefert — der Nutzer muss ihn dann nicht jedes Mal
|
||||
selbst formulieren. Ansatzweise bereits vorhanden (`ai-backend/plan.php`, `$systemPrompt`),
|
||||
hier ginge es um eine bewusstere/erweiterte Ausgestaltung statt der aktuell schlanken Variante.
|
||||
selbst formulieren. **Umsetzung:** `$systemPrompt` in `ai-backend/plan.php` zeigt der KI
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
ganze Stunde, oder eigene zwischenzeitliche Änderungen nicht versehentlich überschreiben,
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
`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.
|
||||
- TLS/`.htaccess`-Wirksamkeit und PHP-Version/Erweiterungen auf dem tatsächlichen Hosting.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -29,11 +29,16 @@ return [
|
||||
// Sicherheitsnetz gegen ausufernde Antworten (und damit Kosten) pro Anfrage.
|
||||
'max_output_tokens' => 8000,
|
||||
|
||||
// USD je 1 Million Token, getrennt nach Input/Output. Vor dem produktiven Einsatz gegen die
|
||||
// aktuelle Anthropic-Preisseite gegenprüfen — Preise ändern sich.
|
||||
// USD je 1 Million Token, getrennt nach Input/Output/Prompt-Cache. Vor dem produktiven Einsatz
|
||||
// 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' => [
|
||||
'claude-sonnet-5' => ['input' => 3.00, 'output' => 15.00],
|
||||
'claude-opus-5' => ['input' => 5.00, 'output' => 25.00],
|
||||
'claude-haiku-4-5' => ['input' => 1.00, 'output' => 5.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, 'cache_write' => 6.25, 'cache_read' => 0.50],
|
||||
'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
@@ -20,9 +20,94 @@ if (!is_array($body) || !isset($body['unit'])) {
|
||||
ai_backend_fail(400, 'Ungültige Anfrage.');
|
||||
}
|
||||
|
||||
// Fester Basiskontext (4.5.13): erklärt Domäne, Felder und Konventionen einmal grundlegend, damit
|
||||
// die Lehrkraft das im freien "instruction"-Feld nicht bei jeder Anfrage wiederholen muss — dort
|
||||
// steht dann nur noch das fachlich Konkrete ("Baue zwei Stunden zu X mit steigendem Anspruch").
|
||||
$systemPrompt = <<<PROMPT
|
||||
Du bist ein Assistent für die Unterrichtsplanung einer Lehrkraft. Du bekommst eine
|
||||
Unterrichtseinheit (JSON) mit ihren bisherigen Stunden sowie eine freie Anweisung der Lehrkraft.
|
||||
Du bist ein Assistent für die Unterrichtsplanung einer Lehrkraft an einer deutschen Schule.
|
||||
Du bekommst eine Unterrichtseinheit ("unit", mit ihren bisherigen Unterrichtsstunden "lessons")
|
||||
sowie eine freie Anweisung der Lehrkraft ("instruction") und sollst neue oder geänderte Stunden
|
||||
für diese Einheit vorschlagen.
|
||||
|
||||
## Eingabeschema
|
||||
|
||||
Die Eingabe hat exakt diese Struktur:
|
||||
{
|
||||
"instruction": "<freie Anweisung der Lehrkraft, kann auch leer sein>",
|
||||
"allowModifyingExistingLessons": <true oder false, siehe Abschnitt "Umfang dieser Anfrage">,
|
||||
"unit": {
|
||||
"id": "<GUID der Einheit>",
|
||||
"title": "<Titel der Einheit>",
|
||||
"startDate": "<TT.MM.JJJJ oder null>",
|
||||
"endDate": "<TT.MM.JJJJ oder null>",
|
||||
"competencies": ["<Kompetenz-Codes, die der Einheit bereits zugeordnet sind>"],
|
||||
"notes": "<Notizen zur Einheit oder null>",
|
||||
"subjectName": "<Fach>",
|
||||
"gradeLevel": <Klassenstufe als Zahl>,
|
||||
"groupName": "<Name der Lerngruppe>",
|
||||
"competencyCatalog": [
|
||||
{ "name": "<Kompetenzbereich>", "items": [{ "code": "<Code>", "description": "<Beschreibung>" }] }
|
||||
],
|
||||
"alternativePathCatalog": [
|
||||
{ "id": "<GUID>", "name": "<Name des alternativen Ablaufs>" }
|
||||
],
|
||||
"lessons": [
|
||||
{
|
||||
"id": "<GUID der bestehenden Stunde>",
|
||||
"date": "<TT.MM.JJJJ oder null>",
|
||||
"lessonNumber": <Zahl oder null>,
|
||||
"topic": "<Thema>",
|
||||
"startTime": "<HH:mm oder null>",
|
||||
"phases": [
|
||||
{
|
||||
"name": "<Phasenname>", "durationMinutes": <Zahl>, "activity": "<Tätigkeit>",
|
||||
"material": "<Material>", "shorthand": "<Kurzsymbol>",
|
||||
"alternativePathName": "<Name aus alternativePathCatalog oder null>"
|
||||
}
|
||||
],
|
||||
"homework": "<Hausaufgabe oder null>",
|
||||
"reflection": "<Reflexion oder null>"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Alle Felder auf Einheiten-Ebene ("unit.id"/"title"/"startDate"/"endDate"/"competencies"/"notes"/
|
||||
"subjectName"/"gradeLevel"/"groupName"/"competencyCatalog"/"alternativePathCatalog") sind reiner
|
||||
Lesekontext. Die Einheit selbst wird nicht verändert — es gibt in deiner Antwort kein Feld dafür.
|
||||
Ausschließlich "unit.lessons[]" ist das, was du vorschlägst/änderst und in der Antwort zurückgibst.
|
||||
|
||||
## Umfang dieser Anfrage
|
||||
|
||||
Das Feld "allowModifyingExistingLessons" in der Eingabe legt fest, was du vorschlagen darfst:
|
||||
- true: Du darfst sowohl neue Stunden vorschlagen ("id": null) als auch bestehende Stunden
|
||||
inhaltlich ändern (dafür deren "id" aus der Eingabe exakt übernehmen).
|
||||
- false: Du darfst AUSSCHLIESSLICH neue Stunden vorschlagen ("id": null). Ändere KEINE bestehende
|
||||
Stunde inhaltlich — gib niemals die "id" einer bereits vorhandenen Stunde zurück, auch nicht
|
||||
unverändert. Falls die Anweisung der Lehrkraft eine Änderung an einer bestehenden Stunde
|
||||
verlangt, die dadurch nicht möglich ist, erkläre das kurz im "summary"-Feld und schlage
|
||||
stattdessen sinnvolle neue Stunden vor.
|
||||
|
||||
## Fachlicher Kontext und Konventionen
|
||||
|
||||
- Richte Anspruch, Wortwahl und Methodik nach "subjectName"/"gradeLevel"/"groupName" aus.
|
||||
- "competencyCatalog" dient nur zur fachlichen Einordnung — das Schema hat kein Feld, um
|
||||
einzelnen Stunden Kompetenzen zuzuordnen, erfinde daher kein solches Feld in der Antwort.
|
||||
- Eine Stunde entspricht einer Unterrichtsstunde à i.d.R. 45 Minuten (Doppelstunden als zwei
|
||||
Lessons oder als eine mit entsprechend höherer Phasen-Gesamtdauer, je nachdem wie es in den
|
||||
bereits vorhandenen Stunden dieser Einheit gehandhabt wird).
|
||||
- Eine Phase ("phases[]") ist eine Zeile im tabellarischen Stundenverlaufsplan, wie in deutschen
|
||||
Schulen üblich: typische Phasennamen sind z.B. Einstieg, Erarbeitung, Übung, Sicherung,
|
||||
Reflexion, Vertiefung — orientiere dich an bereits in der Einheit verwendeten Phasennamen,
|
||||
wenn vorhanden, statt eigene Konventionen einzuführen.
|
||||
- "shorthand" je Phase ist ein kurzes Sozialform-/Medienkürzel, wie es in deutschen
|
||||
Stundenverlaufsplänen üblich ist (z.B. "L" Lehrervortrag, "S" Schülerbeitrag, "EA"
|
||||
Einzelarbeit, "PA" Partnerarbeit, "GA" Gruppenarbeit, "UG" Unterrichtsgespräch, "Tb" Tafelbild,
|
||||
"AB" Arbeitsblatt) — kein Fließtext.
|
||||
- "durationMinutes" je Phase sollte in Summe zur für die Stunde realistischen Zeit passen
|
||||
(i.d.R. rund 45 Minuten je Einzelstunde, abzüglich organisatorischer Zeit).
|
||||
|
||||
## Antwortformat
|
||||
|
||||
Antworte AUSSCHLIESSLICH mit gültigem JSON (kein Freitext davor/danach) in genau diesem Schema:
|
||||
{
|
||||
@@ -52,7 +137,10 @@ Antworte AUSSCHLIESSLICH mit gültigem JSON (kein Freitext davor/danach) in gena
|
||||
|
||||
WICHTIG: Um eine bestehende Stunde zu ändern, gib exakt deren "id" aus der Eingabe zurück. Für
|
||||
eine neu vorgeschlagene Stunde setze "id" auf null. Erfinde niemals eine Id, die nicht in der
|
||||
Eingabe stand. Nutze für "alternativePathName" nur Namen aus dem mitgelieferten Katalog.
|
||||
Eingabe stand. Nutze für "alternativePathName" nur Namen aus dem mitgelieferten Katalog. Wenn die
|
||||
Anweisung der Lehrkraft unklar oder zu knapp ist, triff eine plausible, fachlich begründbare
|
||||
Annahme, statt nachzufragen (eine Rückfrage ist über diese Schnittstelle nicht möglich) — beschreibe
|
||||
deine Annahme kurz im "summary"-Feld.
|
||||
PROMPT;
|
||||
|
||||
$userContent = json_encode($body);
|
||||
@@ -76,12 +164,19 @@ try {
|
||||
ai_backend_fail(502, $e->getMessage());
|
||||
}
|
||||
|
||||
$pricing = $config['pricing'][$modelKey] ?? ($useFake ? ['input' => 0, 'output' => 0] : null);
|
||||
$defaultPricing = ['input' => 0, 'output' => 0, 'cache_write' => 0, 'cache_read' => 0];
|
||||
$pricing = $config['pricing'][$modelKey] ?? ($useFake ? $defaultPricing : null);
|
||||
if ($pricing === null) {
|
||||
ai_backend_fail(500, "Kein Preis für Modell '$modelKey' konfiguriert.");
|
||||
}
|
||||
$pricing += $defaultPricing; // fehlende cache_write/cache_read in älteren config.php-Einträgen -> 0
|
||||
|
||||
$cacheCreationTokens = $result['cacheCreationInputTokens'] ?? 0;
|
||||
$cacheReadTokens = $result['cacheReadInputTokens'] ?? 0;
|
||||
$cost = ($result['inputTokens'] / 1_000_000 * $pricing['input'])
|
||||
+ ($result['outputTokens'] / 1_000_000 * $pricing['output']);
|
||||
+ ($result['outputTokens'] / 1_000_000 * $pricing['output'])
|
||||
+ ($cacheCreationTokens / 1_000_000 * $pricing['cache_write'])
|
||||
+ ($cacheReadTokens / 1_000_000 * $pricing['cache_read']);
|
||||
|
||||
// Guthaben abziehen und Transaktion protokollieren — mit Zeilensperre, damit zwei gleichzeitige
|
||||
// Anfragen desselben Nutzers das Guthaben nicht versehentlich unter 0 drücken können.
|
||||
@@ -99,9 +194,14 @@ try {
|
||||
$newBalance = $currentBalance - $cost;
|
||||
$pdo->prepare('UPDATE users SET balance_usd = ? WHERE id = ?')->execute([$newBalance, $user['id']]);
|
||||
$pdo->prepare(
|
||||
'INSERT INTO transactions (user_id, type, model, input_tokens, output_tokens, cost_usd, balance_after)
|
||||
VALUES (?, "usage", ?, ?, ?, ?, ?)'
|
||||
)->execute([$user['id'], $modelKey, $result['inputTokens'], $result['outputTokens'], $cost, $newBalance]);
|
||||
'INSERT INTO transactions
|
||||
(user_id, type, model, input_tokens, output_tokens,
|
||||
cache_creation_input_tokens, cache_read_input_tokens, cost_usd, balance_after)
|
||||
VALUES (?, "usage", ?, ?, ?, ?, ?, ?, ?)'
|
||||
)->execute([
|
||||
$user['id'], $modelKey, $result['inputTokens'], $result['outputTokens'],
|
||||
$cacheCreationTokens, $cacheReadTokens, $cost, $newBalance,
|
||||
]);
|
||||
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
|
||||
@@ -3,7 +3,17 @@ declare(strict_types=1);
|
||||
|
||||
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
|
||||
{
|
||||
public function __construct(private string $apiKey, private string $model) {}
|
||||
@@ -22,7 +32,9 @@ class AnthropicProvider implements ProviderInterface
|
||||
CURLOPT_POSTFIELDS => json_encode([
|
||||
'model' => $this->model,
|
||||
'max_tokens' => $maxTokens,
|
||||
'system' => $systemPrompt,
|
||||
'system' => [
|
||||
['type' => 'text', 'text' => $systemPrompt, 'cache_control' => ['type' => 'ephemeral']],
|
||||
],
|
||||
'messages' => [['role' => 'user', 'content' => $userContent]],
|
||||
]),
|
||||
CURLOPT_TIMEOUT => 90,
|
||||
@@ -58,6 +70,8 @@ class AnthropicProvider implements ProviderInterface
|
||||
'content' => $text,
|
||||
'inputTokens' => (int) ($data['usage']['input_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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ class FakeProvider implements ProviderInterface
|
||||
]),
|
||||
'inputTokens' => 42,
|
||||
'outputTokens' => 17,
|
||||
'cacheCreationInputTokens' => 0,
|
||||
'cacheReadInputTokens' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,12 @@ declare(strict_types=1);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ CREATE TABLE IF NOT EXISTS transactions (
|
||||
model VARCHAR(64) NULL, -- z.B. 'claude-sonnet-5'; NULL bei topup
|
||||
input_tokens INT NULL,
|
||||
output_tokens INT NULL,
|
||||
-- Prompt Caching (Nachtrag): der stark reduziert abgerechnete "Cache-Read"-Anteil bzw. der
|
||||
-- etwas teurere "Cache-Write"-Anteil beim ersten Aufruf nach TTL-Ablauf. Für Requests ohne
|
||||
-- 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,
|
||||
|
||||
Reference in New Issue
Block a user