apiKey = $apiKey ?? CredentialService::getAnthropicApiKey(); } /** * 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 * $credentials = new CredentialService(); * $service = new ClaudeService($credentials); * $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 * $credentials = new CredentialService(); * $service = new ClaudeService($credentials); * $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 * $credentials = new CredentialService(); * $service = new ClaudeService($credentials); * $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 * $credentials = new CredentialService(); * $service = new ClaudeService($credentials); * 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 $payload The JSON payload to send * @param int $timeout The request timeout in seconds * * @return array 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; } }