'Write concise alt text (max 125 chars) for this image context: {context}. Output only the alt text.', 'fix' => "Suggest how to fix this WCAG {wcag} accessibility violation. Be concise and developer-focused.\n\n1. Explain what needs to change in 1–2 sentences.\n2. Show the corrected code snippet (if applicable).\n\nIssue: {title}\nCurrent HTML:\n{html}", 'explain' => "Explain this accessibility issue to a non-technical WordPress site owner in 2 sentences.\n\nIssue: {title}\nWCAG: {wcag}", 'colour' => 'Suggest the nearest colour to #{colour} that passes WCAG 1.4.3 contrast against #{background}. Return JSON: {"suggested":"#hexcode","ratio":"X.X:1"}', 'report' => 'Write a 3-sentence plain-English compliance summary for a WordPress site with score {score}/100, {critical} critical issues, {warnings} warnings.', ]; /** * Test a provider connection using explicit credentials (no saved settings required). * Falls back to the saved encrypted key when $key is empty (key already saved, no change). */ public static function test( string $provider, string $key, string $model ): array { if ( ! AccessMate_Providers::is_valid( $provider ) ) { return [ 'error' => __( 'No AI provider configured.', 'accessmate' ) ]; } if ( empty( $key ) ) { $settings = get_option( 'accessmate_settings', [] ); $key = self::decrypt_key( $settings['ai_key'] ?? '' ); } $def = AccessMate_Providers::get( $provider ); $model = $model ?: $def['default']; $prompt = 'Reply with exactly one word: OK.'; return self::send( $def, $key, $model, $prompt ); } /** * Entry point. Reads provider config from wp_options, builds prompt, calls provider. */ public static function call( string $task, array $params ): array { if ( ! in_array( $task, self::VALID_TASKS, true ) ) { return [ 'error' => __( 'Invalid task.', 'accessmate' ) ]; } $settings = get_option( 'accessmate_settings', [] ); $provider = $settings['ai_provider'] ?? 'none'; if ( $provider === 'none' || ! AccessMate_Providers::is_valid( $provider ) ) { return [ 'error' => __( 'No AI provider configured.', 'accessmate' ) ]; } $key = self::decrypt_key( $settings['ai_key'] ?? '' ); $def = AccessMate_Providers::get( $provider ); $model = $settings['ai_model'] ?? $def['default']; $prompt = self::build_prompt( $task, $params ); return self::send( $def, $key, $model, $prompt ); } // ── Encryption ────────────────────────────────────────────────────────── public static function encrypt_key( string $key ): string { $secret = hash( 'sha256', wp_salt( 'auth' ), true ); $iv = openssl_random_pseudo_bytes( 16 ); $encrypted = openssl_encrypt( $key, 'AES-256-CBC', $secret, 0, $iv ); return base64_encode( $iv . $encrypted ); } public static function decrypt_key( string $stored ): string { if ( empty( $stored ) ) return ''; $secret = hash( 'sha256', wp_salt( 'auth' ), true ); $decoded = base64_decode( $stored ); $iv = substr( $decoded, 0, 16 ); $encrypted = substr( $decoded, 16 ); $result = openssl_decrypt( $encrypted, 'AES-256-CBC', $secret, 0, $iv ); return $result !== false ? $result : ''; } // ── Prompt builder ────────────────────────────────────────────────────── private static function build_prompt( string $task, array $params ): string { $template = self::PROMPTS[ $task ] ?? '{context}'; foreach ( $params as $k => $v ) { // Sanitise param values — they go into a prompt, not HTML output. $template = str_replace( '{' . $k . '}', (string) $v, $template ); } return $template; } // ── HTTP dispatch ─────────────────────────────────────────────────────── private static function send( array $def, string $key, string $model, string $prompt ): array { $format = $def['format']; $url = $def['endpoint']; switch ( $format ) { case 'anthropic': $body = wp_json_encode([ 'model' => $model, 'max_tokens' => 512, 'messages' => [[ 'role' => 'user', 'content' => $prompt ]], ]); $headers = [ 'Content-Type' => 'application/json', 'x-api-key' => $key, 'anthropic-version' => '2023-06-01', ]; break; case 'openai': $body = wp_json_encode([ 'model' => $model, 'messages' => [[ 'role' => 'user', 'content' => $prompt ]], ]); $headers = [ 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $key, ]; break; case 'gemini': $url = str_replace( '{model}', rawurlencode( $model ), $url ) . '?key=' . rawurlencode( $key ); $body = wp_json_encode([ 'contents' => [[ 'parts' => [[ 'text' => $prompt ]] ]], ]); $headers = [ 'Content-Type' => 'application/json' ]; break; default: // ollama $body = wp_json_encode([ 'model' => $model, 'messages' => [[ 'role' => 'user', 'content' => $prompt ]], 'stream' => false, ]); $headers = [ 'Content-Type' => 'application/json' ]; break; } $response = wp_remote_post( $url, [ 'headers' => $headers, 'body' => $body, 'timeout' => 30, ]); if ( is_wp_error( $response ) ) { return [ 'error' => $response->get_error_message() ]; } $status = wp_remote_retrieve_response_code( $response ); $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $status !== 200 ) { $msg = $data['error']['message'] ?? ( 'Provider returned status ' . $status ); return [ 'error' => $msg ]; } return [ 'text' => self::extract_text( $format, $data ) ]; } private static function extract_text( string $format, array $data ): string { switch ( $format ) { case 'anthropic': return $data['content'][0]['text'] ?? ''; case 'openai': return $data['choices'][0]['message']['content'] ?? ''; case 'gemini': return $data['candidates'][0]['content']['parts'][0]['text'] ?? ''; default: return $data['message']['content'] ?? ''; } } }