<?php
declare(strict_types=1);
namespace Infrastructure\AI;
// @responsibility: Anthropic Claude API für RAG-Antworten
use Infrastructure\Config\CredentialService;
use RuntimeException;
final readonly class ClaudeService
{
/**
* Default timeout for HTTP requests in seconds (LLM responses can take time).
*/
private const int DEFAULT_TIMEOUT = 120;
/**
* Health check timeout in seconds.
*/
private const int HEALTH_CHECK_TIMEOUT = 5;
/**
* Anthropic API base URL.
*/
private const string API_BASE_URL = 'https://api.anthropic.com/v1';
/**
* Anthropic API version header.
*/
private const string API_VERSION = '2023-06-01';
/**
* Constructs a new ClaudeService instance.
*
* @param string $apiKey The Anthropic API key (sk-ant-...)
*/
public function __construct(
private string $apiKey
) {
}
/**
* Sends a prompt to Claude and receives a text response.
*
* Makes a request to the Anthropic Messages API with the specified prompt,
* optional system prompt, model, token limit, and temperature. Returns the
* generated text along with token usage statistics.
*
* @param string $prompt The user prompt to send
* @param string|null $systemPrompt Optional system prompt for instructions (default: null)
* @param string $model The Claude model to use (default: claude-opus-4-5-20251101)
* @param int $maxTokens Maximum tokens in response (default: 4000)
* @param float $temperature Sampling temperature 0.0-1.0 (default: 0.7)
*
* @return array{text: string, usage: array{input_tokens: int, output_tokens: int}} Response text and token usage
*
* @throws RuntimeException If the API request fails or returns invalid data
*
* @example
* $service = new ClaudeService('sk-ant-...');
* $result = $service->ask('Explain quantum computing', 'You are a physics teacher.', 'claude-opus-4-5-20251101', 4000, 0.7);
* // Returns: [
* // 'text' => 'Quantum computing is...',
* // 'usage' => ['input_tokens' => 123, 'output_tokens' => 456]
* // ]
*/
public function ask(
string $prompt,
?string $systemPrompt = null,
string $model = 'claude-opus-4-5-20251101',
int $maxTokens = 4000,
float $temperature = 0.7
): array {
$url = self::API_BASE_URL . '/messages';
$payload = [
'model' => $model,
'max_tokens' => $maxTokens,
'temperature' => max(0.0, min(1.0, $temperature)),
'messages' => [
[
'role' => 'user',
'content' => $prompt,
],
],
];
if ($systemPrompt !== null) {
$payload['system'] = $systemPrompt;
}
$response = $this->makeRequest($url, $payload, self::DEFAULT_TIMEOUT);
if (!isset($response['content']) || !is_array($response['content'])) {
throw new RuntimeException('Invalid response structure from Claude API');
}
if (!isset($response['content'][0]['text'])) {
throw new RuntimeException('Missing text content in Claude API response');
}
$text = (string) $response['content'][0]['text'];
$usage = [
'input_tokens' => (int) ($response['usage']['input_tokens'] ?? 0),
'output_tokens' => (int) ($response['usage']['output_tokens'] ?? 0),
];
return [
'text' => $text,
'usage' => $usage,
];
}
/**
* Builds a RAG-specific user prompt combining question and context.
*
* Creates a formatted prompt that presents retrieved context to Claude
* along with the user's question, suitable for RAG (Retrieval-Augmented Generation).
*
* @param string $question The user's question
* @param string $context The retrieved context from vector search
*
* @return string The formatted RAG prompt
*
* @example
* $service = new ClaudeService('sk-ant-...');
* $prompt = $service->buildRagPrompt(
* 'Was ist systemisches Coaching?',
* 'Kontext: Systemisches Coaching betrachtet...'
* );
* // Returns: "Kontext aus den Dokumenten:\n\n...\n\n---\n\nFrage: Was ist systemisches Coaching?"
*/
public function buildRagPrompt(string $question, string $context): string
{
return sprintf(
"Kontext aus den Dokumenten:\n\n%s\n\n---\n\nFrage: %s",
$context,
$question
);
}
/**
* Returns the default system prompt for RAG use cases.
*
* Provides a standard German system prompt instructing Claude to answer
* questions based on provided context for teamcoaching and team development topics.
*
* @return string The default system prompt
*
* @example
* $service = new ClaudeService('sk-ant-...');
* $systemPrompt = $service->getDefaultSystemPrompt();
* $result = $service->ask($userPrompt, $systemPrompt);
*/
public function getDefaultSystemPrompt(): string
{
return <<<'PROMPT'
Du bist ein hilfreicher Assistent für Fragen zu systemischem Teamcoaching und Teamentwicklung.
Beantworte die Frage des Nutzers basierend auf dem bereitgestellten Kontext.
- Antworte auf Deutsch
- Sei präzise und hilfreich
- Wenn der Kontext die Frage nicht beantwortet, sage das ehrlich
- Verweise auf die Quellen wenn passend
PROMPT;
}
/**
* Checks if the Claude API is available and responding.
*
* Performs a minimal health check by attempting to connect to the API.
* Note: This is optional as Claude is an external service with its own status page.
* Returns false on any error to avoid exposing API key in logs.
*
* @return bool True if the API appears available, false otherwise
*
* @example
* $service = new ClaudeService('sk-ant-...');
* if ($service->isAvailable()) {
* echo "Claude API is accessible";
* } else {
* echo "Claude API is not available";
* }
*/
public function isAvailable(): bool
{
try {
$ch = curl_init(self::API_BASE_URL . '/messages');
if ($ch === false) {
return false;
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => self::HEALTH_CHECK_TIMEOUT,
CURLOPT_CONNECTTIMEOUT => self::HEALTH_CHECK_TIMEOUT,
CURLOPT_CUSTOMREQUEST => 'HEAD',
CURLOPT_NOBODY => true,
]);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// API returns 4xx for HEAD without auth, but connectivity is proven
return $result !== false && ($httpCode === 400 || $httpCode === 401);
} catch (\Throwable) {
return false;
}
}
/**
* Makes an HTTP POST request to the Claude API.
*
* Internal helper method that handles cURL initialization, request execution,
* error handling, and response parsing. API key is sent via x-api-key header
* and is never logged or included in exception messages.
*
* @param string $url The API endpoint URL
* @param array<string, mixed> $payload The JSON payload to send
* @param int $timeout The request timeout in seconds
*
* @return array<string, mixed> The decoded JSON response
*
* @throws RuntimeException If the request fails or returns an error (without exposing API key)
*/
private function makeRequest(string $url, array $payload, int $timeout): array
{
$ch = curl_init($url);
if ($ch === false) {
throw new RuntimeException('Failed to initialize cURL');
}
$jsonPayload = json_encode($payload);
if ($jsonPayload === false) {
curl_close($ch);
throw new RuntimeException('Failed to encode JSON payload');
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $jsonPayload,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-api-key: ' . $this->apiKey,
'anthropic-version: ' . self::API_VERSION,
],
]);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($result === false) {
throw new RuntimeException(
sprintf('cURL request failed: %s', $curlError !== '' ? $curlError : 'Unknown error')
);
}
if ($httpCode !== 200) {
// Never expose API key in error messages - sanitize response
$sanitizedResponse = $this->sanitizeErrorResponse((string) $result);
throw new RuntimeException(
sprintf('Claude API returned HTTP %d: %s', $httpCode, $sanitizedResponse)
);
}
$decoded = json_decode((string) $result, true);
if (!is_array($decoded)) {
throw new RuntimeException('Failed to decode JSON response from Claude API');
}
return $decoded;
}
/**
* Sanitizes error responses to prevent API key leakage.
*
* Removes or masks any potential API key references from error messages
* before they are included in exceptions or logs.
*
* @param string $response The raw error response
*
* @return string The sanitized error response
*/
private function sanitizeErrorResponse(string $response): string
{
// Limit response length to prevent verbose error dumps
if (strlen($response) > 500) {
$response = substr($response, 0, 500) . '... (truncated)';
}
// Remove any potential API key patterns (sk-ant-...)
return preg_replace('/sk-ant-[a-zA-Z0-9_-]+/', '[API_KEY_REDACTED]', $response) ?? $response;
}
}