Files
LehrerApp/ai-backend/providers/AnthropicProvider.php
admin 1928916eac 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.
2026-08-16 16:06:52 +02:00

78 lines
3.2 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/ProviderInterface.php';
/**
* 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) {}
public function sendMessage(string $systemPrompt, string $userContent, int $maxTokens): array
{
$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'content-type: application/json',
'x-api-key: ' . $this->apiKey,
'anthropic-version: 2023-06-01',
],
CURLOPT_POSTFIELDS => json_encode([
'model' => $this->model,
'max_tokens' => $maxTokens,
'system' => [
['type' => 'text', 'text' => $systemPrompt, 'cache_control' => ['type' => 'ephemeral']],
],
'messages' => [['role' => 'user', 'content' => $userContent]],
]),
CURLOPT_TIMEOUT => 90,
]);
$raw = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($raw === false) {
throw new RuntimeException("Anthropic-Anfrage fehlgeschlagen: $curlError");
}
if ($httpCode >= 400) {
throw new RuntimeException("Anthropic-API-Fehler (HTTP $httpCode): " . substr((string) $raw, 0, 500));
}
$data = json_decode((string) $raw, true);
if (!is_array($data)) {
throw new RuntimeException('Anthropic-Antwort konnte nicht als JSON gelesen werden.');
}
if (($data['stop_reason'] ?? null) === 'refusal') {
throw new RuntimeException('Die KI hat die Anfrage abgelehnt.');
}
$text = '';
foreach (($data['content'] ?? []) as $block) {
if (($block['type'] ?? null) === 'text') {
$text .= $block['text'];
}
}
return [
'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),
];
}
}