diff --git a/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs b/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs index 992edac..5ab9871 100644 --- a/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs +++ b/LehrerApp.Core/AiPlanning/AiPlanningDtos.cs @@ -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 diff --git a/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs b/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs index dd51da5..022f76c 100644 --- a/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs +++ b/LehrerApp.Desktop.Tests/AiPlanningServiceTests.cs @@ -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 { 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 + { + 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() { diff --git a/LehrerApp.Desktop/Services/AiPlanningService.cs b/LehrerApp.Desktop/Services/AiPlanningService.cs index 2009d9e..a4aabad 100644 --- a/LehrerApp.Desktop/Services/AiPlanningService.cs +++ b/LehrerApp.Desktop/Services/AiPlanningService.cs @@ -131,9 +131,15 @@ public class AiPlanningService(HttpClient http, ILessonRepository lessons, }; } - public async Task RequestPlanAsync(Unit unit, string instruction, string token) + public async Task 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, /// 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 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. /// - public List ApplyResponse(Unit unit, List acceptedLessons) + public List ApplyResponse(Unit unit, List 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(); 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, diff --git a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs index ed079f5..303c9e7 100644 --- a/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs +++ b/LehrerApp.Desktop/ViewModels/Groups/PlanningViewModels.cs @@ -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; } diff --git a/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml b/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml index dfc69ab..abd9554 100644 --- a/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml +++ b/LehrerApp.Desktop/Views/Groups/AiAssistDialog.axaml @@ -13,11 +13,14 @@ - + + diff --git a/TODO.md b/TODO.md index 24cc74f..60363f6 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/ai-backend/README.md b/ai-backend/README.md index ee5d9ae..e5e5f10 100644 --- a/ai-backend/README.md +++ b/ai-backend/README.md @@ -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 -p < 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 diff --git a/ai-backend/config.example.php b/ai-backend/config.example.php index 643584c..3dbedc0 100644 --- a/ai-backend/config.example.php +++ b/ai-backend/config.example.php @@ -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], ], ]; diff --git a/ai-backend/migrations/2026-08-add-cache-tokens.sql b/ai-backend/migrations/2026-08-add-cache-tokens.sql new file mode 100644 index 0000000..e85da91 --- /dev/null +++ b/ai-backend/migrations/2026-08-add-cache-tokens.sql @@ -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 -p < 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; diff --git a/ai-backend/plan.php b/ai-backend/plan.php index ffe5463..13057fe 100644 --- a/ai-backend/plan.php +++ b/ai-backend/plan.php @@ -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 = <<", + "allowModifyingExistingLessons": , + "unit": { + "id": "", + "title": "", + "startDate": "", + "endDate": "", + "competencies": [""], + "notes": "", + "subjectName": "", + "gradeLevel": , + "groupName": "", + "competencyCatalog": [ + { "name": "", "items": [{ "code": "", "description": "" }] } + ], + "alternativePathCatalog": [ + { "id": "", "name": "" } + ], + "lessons": [ + { + "id": "", + "date": "", + "lessonNumber": , + "topic": "", + "startTime": "", + "phases": [ + { + "name": "", "durationMinutes": , "activity": "", + "material": "", "shorthand": "", + "alternativePathName": "" + } + ], + "homework": "", + "reflection": "" + } + ] + } +} + +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) { diff --git a/ai-backend/providers/AnthropicProvider.php b/ai-backend/providers/AnthropicProvider.php index 309e0e7..b4880d9 100644 --- a/ai-backend/providers/AnthropicProvider.php +++ b/ai-backend/providers/AnthropicProvider.php @@ -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), ]; } } diff --git a/ai-backend/providers/FakeProvider.php b/ai-backend/providers/FakeProvider.php index e0f96c8..6fdaea5 100644 --- a/ai-backend/providers/FakeProvider.php +++ b/ai-backend/providers/FakeProvider.php @@ -31,6 +31,8 @@ class FakeProvider implements ProviderInterface ]), 'inputTokens' => 42, 'outputTokens' => 17, + 'cacheCreationInputTokens' => 0, + 'cacheReadInputTokens' => 0, ]; } } diff --git a/ai-backend/providers/ProviderInterface.php b/ai-backend/providers/ProviderInterface.php index 41d74ae..a32e593 100644 --- a/ai-backend/providers/ProviderInterface.php +++ b/ai-backend/providers/ProviderInterface.php @@ -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; } diff --git a/ai-backend/schema.sql b/ai-backend/schema.sql index cf52e3e..8f66b79 100644 --- a/ai-backend/schema.sql +++ b/ai-backend/schema.sql @@ -21,15 +21,20 @@ CREATE TABLE IF NOT EXISTS tokens ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS transactions ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL, - type ENUM('usage', 'topup') NOT NULL, - model VARCHAR(64) NULL, -- z.B. 'claude-sonnet-5'; NULL bei topup - input_tokens INT NULL, - output_tokens INT NULL, - cost_usd DECIMAL(10,6) NOT NULL, - balance_after DECIMAL(10,4) NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + type ENUM('usage', 'topup') NOT NULL, + 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, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, INDEX idx_user_created (user_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;