| 1 |
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName |
| 2 |
if ( ! defined( 'ABSPATH' ) ) { |
| 3 |
exit; // Exit if accessed directly! |
| 4 |
} |
| 5 |
|
| 6 |
if ( ! class_exists( 'WPSC_PS_AIBOT_Gemini' ) ) : |
| 7 |
|
| 8 |
final class WPSC_PS_AIBOT_Gemini implements WPSC_PS_AIBOT_Provider_Interface { |
| 9 |
|
| 10 |
/** |
| 11 |
* Get the store ID for a given provider. |
| 12 |
* |
| 13 |
* @param string $api_key The API key for authentication. |
| 14 |
* @return string The store ID associated with the provider. |
| 15 |
*/ |
| 16 |
public function wpsc_provider_store_id( $api_key ) { |
| 17 |
|
| 18 |
return WPSC_PS_AI_Gemini::wpsc_provider_store_id( $api_key ); |
| 19 |
} |
| 20 |
|
| 21 |
/** |
| 22 |
* Get a chat response from Google Gemini API based on the provided message. |
| 23 |
* |
| 24 |
* @param array $ai_settings AI settings array. |
| 25 |
* @param string $message The user message to send to the AI. |
| 26 |
* @param string $system_prompt The system prompt to guide the AI's response. |
| 27 |
* @param array $conversation_history The conversation history to provide context to the AI. |
| 28 |
* @param array $tools Optional tools to include in the request (e.g., file search). |
| 29 |
* @param array $tool_context Optional agentic-loop continuation state; see WPSC_PS_AIBOT_Provider_Interface::wpsc_get_chat_response(). |
| 30 |
* @return string|false The response from the AI provider or false on failure. |
| 31 |
*/ |
| 32 |
public function wpsc_get_chat_response( $ai_settings, $message, $system_prompt = '', $conversation_history = array(), $tools = array(), $tool_context = array() ) { |
| 33 |
|
| 34 |
// 'response' is intentionally left empty - the caller (WPSC_ACB_Chats::get_ai_response()) |
| 35 |
// substitutes its own translated, site-locale error message whenever the response is |
| 36 |
// empty, so no hardcoded/untranslated text should be returned from here. |
| 37 |
$fallback = array( |
| 38 |
'success' => false, |
| 39 |
'response' => '', |
| 40 |
'total_tokens' => 0, |
| 41 |
'create_ticket' => false, |
| 42 |
); |
| 43 |
|
| 44 |
$message = is_string( $message ) ? trim( $message ) : ''; |
| 45 |
if ( '' === $message ) { |
| 46 |
return $fallback; |
| 47 |
} |
| 48 |
|
| 49 |
$model = ! empty( $ai_settings['model'] ) ? sanitize_text_field( $ai_settings['model'] ) : 'gemini-1.5-pro-latest'; |
| 50 |
$max_tokens = ! empty( $ai_settings['max-tokens'] ) && (int) $ai_settings['max-tokens'] > 0 ? (int) $ai_settings['max-tokens'] : 500; |
| 51 |
|
| 52 |
$store_name = $this->wpsc_provider_store_id( $ai_settings['api_key'] ); |
| 53 |
if ( is_wp_error( $store_name ) ) { |
| 54 |
return $fallback; |
| 55 |
} |
| 56 |
|
| 57 |
$gemini_tools = class_exists( 'WPSC_AIBOT_Tool_Utils' ) ? WPSC_AIBOT_Tool_Utils::build_gemini_tools( $store_name, $tools ) : array(); |
| 58 |
|
| 59 |
$known_tool_names = array(); |
| 60 |
foreach ( $gemini_tools as $tool_group ) { |
| 61 |
foreach ( $tool_group['function_declarations'] ?? array() as $declaration ) { |
| 62 |
if ( ! empty( $declaration['name'] ) ) { |
| 63 |
$known_tool_names[] = sanitize_key( (string) $declaration['name'] ); |
| 64 |
} |
| 65 |
} |
| 66 |
} |
| 67 |
|
| 68 |
$is_continuation = is_array( $tool_context ) && is_array( $tool_context['contents'] ?? null ); |
| 69 |
|
| 70 |
if ( $is_continuation ) { |
| 71 |
|
| 72 |
// Continue an agentic tool-calling loop: replay the prior turn's |
| 73 |
// contents, echo back the model's own functionCall, then append |
| 74 |
// the tool's structured result as a functionResponse part. |
| 75 |
$contents = $tool_context['contents']; |
| 76 |
$tool_call = is_array( $tool_context['tool_call'] ?? null ) ? $tool_context['tool_call'] : array(); |
| 77 |
$tool_name = sanitize_key( (string) ( $tool_call['name'] ?? '' ) ); |
| 78 |
|
| 79 |
if ( '' !== $tool_name ) { |
| 80 |
|
| 81 |
// Gemini's functionCall.args and functionResponse.response are |
| 82 |
// proto Struct (object) fields, not repeating fields - wp_json_encode() |
| 83 |
// serializes a PHP empty array as JSON `[]`, which the API rejects |
| 84 |
// with "Proto field is not repeating, cannot start list." Force an |
| 85 |
// object cast so an empty array always encodes as `{}` instead. |
| 86 |
$arguments = is_array( $tool_call['arguments'] ?? null ) ? $tool_call['arguments'] : array(); |
| 87 |
$tool_result = is_array( $tool_context['tool_result'] ?? null ) ? $tool_context['tool_result'] : array(); |
| 88 |
|
| 89 |
$contents[] = array( |
| 90 |
'role' => 'model', |
| 91 |
'parts' => array( |
| 92 |
array( |
| 93 |
'functionCall' => array( |
| 94 |
'name' => $tool_name, |
| 95 |
'args' => empty( $arguments ) ? (object) array() : $arguments, |
| 96 |
), |
| 97 |
), |
| 98 |
), |
| 99 |
); |
| 100 |
$contents[] = array( |
| 101 |
'role' => 'function', |
| 102 |
'parts' => array( |
| 103 |
array( |
| 104 |
'functionResponse' => array( |
| 105 |
'name' => $tool_name, |
| 106 |
'response' => empty( $tool_result ) ? (object) array() : $tool_result, |
| 107 |
), |
| 108 |
), |
| 109 |
), |
| 110 |
); |
| 111 |
} |
| 112 |
|
| 113 |
$requested_choice = $tool_context['tool_choice'] ?? 'auto'; |
| 114 |
if ( 'none' === $requested_choice ) { |
| 115 |
$function_calling_mode = 'NONE'; |
| 116 |
} elseif ( 'any' === $requested_choice ) { |
| 117 |
$function_calling_mode = 'ANY'; |
| 118 |
} else { |
| 119 |
$function_calling_mode = 'AUTO'; |
| 120 |
} |
| 121 |
} else { |
| 122 |
|
| 123 |
$contents = $this->wpsc_get_api_formatted_chat_messages( $conversation_history, $message ); |
| 124 |
|
| 125 |
if ( 'none' === ( $tool_context['tool_choice'] ?? null ) ) { |
| 126 |
// A plain (non-continuation), tool-free synthesis call - used for |
| 127 |
// the empty-completion retry (see WPSC_ACB_Chats::run_agentic_tool_loop()), |
| 128 |
// which deliberately avoids replaying the native functionCall/ |
| 129 |
// functionResponse continuation format: that format was found to |
| 130 |
// reliably trigger a completely empty completion from |
| 131 |
// gemini-2.5-flash-lite for certain conversations, regardless of |
| 132 |
// temperature or prompt content: this plain-text form does not. |
| 133 |
$function_calling_mode = 'NONE'; |
| 134 |
} else { |
| 135 |
$function_calling_mode = ! empty( $gemini_tools ) ? 'ANY' : 'AUTO'; |
| 136 |
} |
| 137 |
} |
| 138 |
|
| 139 |
$request_body = array( |
| 140 |
'system_instruction' => array( |
| 141 |
'parts' => array( |
| 142 |
array( |
| 143 |
'text' => $system_prompt, |
| 144 |
), |
| 145 |
), |
| 146 |
), |
| 147 |
'contents' => $contents, |
| 148 |
'generationConfig' => array( |
| 149 |
'temperature' => 0.1, |
| 150 |
'maxOutputTokens' => $max_tokens, |
| 151 |
), |
| 152 |
); |
| 153 |
|
| 154 |
// 'NONE' means no further tool call is wanted at all (the forced final |
| 155 |
// synthesis call, or the empty-text retry below it) - in that case, |
| 156 |
// deliberately omit 'tools'/'toolConfig' entirely rather than send them |
| 157 |
// with mode NONE. Observed in production: gemini-2.5-flash-lite reliably |
| 158 |
// returns a completely empty completion (0 output tokens, finishReason |
| 159 |
// STOP, no error) when continuing a functionCall/functionResponse turn |
| 160 |
// with a 'tools' declaration present, regardless of mode (ANY/AUTO/NONE) |
| 161 |
// or how small the declarations are - dropping 'tools' for this call |
| 162 |
// reliably restores real output text. |
| 163 |
if ( 'NONE' !== $function_calling_mode ) { |
| 164 |
$request_body['tools'] = $gemini_tools; |
| 165 |
$request_body['toolConfig'] = array( |
| 166 |
'function_calling_config' => array( |
| 167 |
'mode' => $function_calling_mode, |
| 168 |
), |
| 169 |
); |
| 170 |
} |
| 171 |
|
| 172 |
$max_retries = isset( $tool_context['max_retries'] ) ? max( 1, (int) $tool_context['max_retries'] ) : 3; |
| 173 |
|
| 174 |
for ( $attempt = 1; $attempt <= $max_retries; $attempt++ ) { |
| 175 |
|
| 176 |
$attempt_model = WPSC_PS_AI_Gemini::resolve_retry_model( |
| 177 |
$model, |
| 178 |
$attempt |
| 179 |
); |
| 180 |
|
| 181 |
$attempt_url = sprintf( |
| 182 |
'https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s', |
| 183 |
rawurlencode( $attempt_model ), |
| 184 |
rawurlencode( $ai_settings['api_key'] ) |
| 185 |
); |
| 186 |
|
| 187 |
$response = wp_remote_post( |
| 188 |
$attempt_url, |
| 189 |
array( |
| 190 |
'method' => 'POST', |
| 191 |
'timeout' => 60, |
| 192 |
'sslverify' => true, |
| 193 |
'headers' => array( |
| 194 |
'Content-Type' => 'application/json', |
| 195 |
), |
| 196 |
'body' => wp_json_encode( $request_body ), |
| 197 |
) |
| 198 |
); |
| 199 |
|
| 200 |
if ( WPSC_PS_AI_Gemini::is_retryable_response( $response ) ) { |
| 201 |
|
| 202 |
$status_code = wp_remote_retrieve_response_code( $response ); |
| 203 |
if ( $attempt < $max_retries ) { |
| 204 |
sleep( 1 ); |
| 205 |
continue; |
| 206 |
} |
| 207 |
} |
| 208 |
|
| 209 |
$result = $this->process_chat_response( |
| 210 |
$response, |
| 211 |
$fallback, |
| 212 |
$known_tool_names |
| 213 |
); |
| 214 |
|
| 215 |
if ( ! empty( $result['success'] ) ) { |
| 216 |
$result['contents'] = $contents; |
| 217 |
} |
| 218 |
|
| 219 |
// Observed with gemini-2.5-flash-lite: continuing a functionCall/ |
| 220 |
// functionResponse turn in AUTO mode returns a completely empty |
| 221 |
// completion (no parts, finishReason STOP) regardless of whether |
| 222 |
// its next step is a text reply or another tool call (e.g. |
| 223 |
// search_woo_products -> manage_woo_cart for "add 3 caps to |
| 224 |
// cart"). Disambiguate by re-sending the same continuation |
| 225 |
// tool-free first: that returns the real text reply when one is |
| 226 |
// due, and fails with finishReason UNEXPECTED_TOOL_CALL when the |
| 227 |
// model actually wants a tool - only on that explicit signal |
| 228 |
// re-send it in ANY mode, which reliably yields that tool call. |
| 229 |
// A tool-free retry that is merely empty (plain STOP) is left |
| 230 |
// empty for the caller's own plain-text retry: forcing ANY there |
| 231 |
// makes the model pick *some* tool, e.g. adding a product to the |
| 232 |
// cart when the customer only asked its price. |
| 233 |
if ( $is_continuation && 'AUTO' === $function_calling_mode && self::is_empty_completion( $result ) ) { |
| 234 |
|
| 235 |
$spent_tokens = (int) ( $result['total_tokens'] ?? 0 ); |
| 236 |
|
| 237 |
foreach ( array( 'none', 'any' ) as $fallback_choice ) { |
| 238 |
|
| 239 |
$fallback_context = $tool_context; |
| 240 |
$fallback_context['tool_choice'] = $fallback_choice; |
| 241 |
$fallback_result = $this->wpsc_get_chat_response( $ai_settings, $message, $system_prompt, $conversation_history, $tools, $fallback_context ); |
| 242 |
|
| 243 |
if ( ! is_array( $fallback_result ) || empty( $fallback_result['success'] ) ) { |
| 244 |
break; |
| 245 |
} |
| 246 |
|
| 247 |
$spent_tokens += (int) ( $fallback_result['total_tokens'] ?? 0 ); |
| 248 |
if ( ! self::is_empty_completion( $fallback_result ) ) { |
| 249 |
$fallback_result['total_tokens'] = $spent_tokens; |
| 250 |
return $fallback_result; |
| 251 |
} |
| 252 |
|
| 253 |
if ( 'UNEXPECTED_TOOL_CALL' !== ( $fallback_result['finish_reason'] ?? '' ) ) { |
| 254 |
break; |
| 255 |
} |
| 256 |
} |
| 257 |
|
| 258 |
$result['total_tokens'] = $spent_tokens; |
| 259 |
} |
| 260 |
|
| 261 |
return $result; |
| 262 |
} |
| 263 |
|
| 264 |
return $fallback; |
| 265 |
} |
| 266 |
|
| 267 |
/** |
| 268 |
* Whether a successful processed response carries neither a tool call |
| 269 |
* nor any reply text. |
| 270 |
* |
| 271 |
* @param array $result Result from process_chat_response(). |
| 272 |
* @return bool |
| 273 |
*/ |
| 274 |
private static function is_empty_completion( $result ) { |
| 275 |
|
| 276 |
return ! empty( $result['success'] ) |
| 277 |
&& empty( $result['tool_call'] ) |
| 278 |
&& '' === trim( (string) ( $result['response'] ?? '' ) ); |
| 279 |
} |
| 280 |
|
| 281 |
/** |
| 282 |
* Process the response from the Gemini API and extract relevant information. |
| 283 |
* |
| 284 |
* @param array $response The response from the Gemini API. |
| 285 |
* @param array $fallback The fallback response in case of errors. |
| 286 |
* @param array $known_tool_names Registered tool names for this turn, used to validate |
| 287 |
* a pseudocode-text tool-call fallback match; see |
| 288 |
* WPSC_AIBOT_Tool_Utils::extract_gemini_tool_call(). |
| 289 |
* @return array The processed response or the fallback response on error. |
| 290 |
*/ |
| 291 |
public function process_chat_response( $response, $fallback, $known_tool_names = array() ) { |
| 292 |
|
| 293 |
$status_code = wp_remote_retrieve_response_code( $response ); |
| 294 |
if ( is_wp_error( $response ) || 200 !== $status_code ) { |
| 295 |
return $fallback; |
| 296 |
} |
| 297 |
|
| 298 |
$body = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 299 |
if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $body ) ) { |
| 300 |
return $fallback; |
| 301 |
} |
| 302 |
|
| 303 |
$prompt_tokens = ! empty( $body['usageMetadata']['promptTokenCount'] ) ? (int) $body['usageMetadata']['promptTokenCount'] : 0; |
| 304 |
$completion_tokens = ! empty( $body['usageMetadata']['candidatesTokenCount'] ) ? (int) $body['usageMetadata']['candidatesTokenCount'] : 0; |
| 305 |
$total_tokens = ! empty( $body['usageMetadata']['totalTokenCount'] ) ? (int) $body['usageMetadata']['totalTokenCount'] : 0; |
| 306 |
|
| 307 |
if ( ! empty( $body['promptFeedback']['blockReason'] ) ) { |
| 308 |
return $fallback; |
| 309 |
} |
| 310 |
|
| 311 |
$tool_call = class_exists( 'WPSC_AIBOT_Tool_Utils' ) |
| 312 |
? WPSC_AIBOT_Tool_Utils::extract_gemini_tool_call( $body, $known_tool_names ) |
| 313 |
: null; |
| 314 |
|
| 315 |
if ( ! empty( $tool_call ) ) { |
| 316 |
if ( class_exists( 'WPSC_AIBOT_Tool_Utils' ) ) { |
| 317 |
return WPSC_AIBOT_Tool_Utils::make_tool_call_response( $tool_call, $prompt_tokens, $completion_tokens, $total_tokens ); |
| 318 |
} |
| 319 |
|
| 320 |
return array( |
| 321 |
'success' => true, |
| 322 |
'response' => '', |
| 323 |
'create_ticket' => false, |
| 324 |
'tool_call' => $tool_call, |
| 325 |
'prompt_tokens' => $prompt_tokens, |
| 326 |
'completion_tokens' => $completion_tokens, |
| 327 |
'total_tokens' => $total_tokens, |
| 328 |
); |
| 329 |
} |
| 330 |
|
| 331 |
return array( |
| 332 |
'success' => true, |
| 333 |
'response' => self::extract_text_reply( $body ), |
| 334 |
'finish_reason' => (string) ( $body['candidates'][0]['finishReason'] ?? '' ), |
| 335 |
'create_ticket' => false, |
| 336 |
'tool_call' => '', |
| 337 |
'prompt_tokens' => $prompt_tokens, |
| 338 |
'completion_tokens' => $completion_tokens, |
| 339 |
'total_tokens' => $total_tokens, |
| 340 |
); |
| 341 |
} |
| 342 |
|
| 343 |
/** |
| 344 |
* Format the conversation history and user message for API consumption. |
| 345 |
* |
| 346 |
* @param array $conversation_history The conversation history to provide context to the AI. |
| 347 |
* @param string $message The user message to send to the AI. |
| 348 |
* @return array The formatted chat messages ready for API consumption. |
| 349 |
*/ |
| 350 |
public function wpsc_get_api_formatted_chat_messages( $conversation_history, $message ) { |
| 351 |
|
| 352 |
$conversation_history = is_array( $conversation_history ) ? array_slice( $conversation_history, -6 ) : array(); |
| 353 |
$contents = array(); |
| 354 |
|
| 355 |
foreach ( $conversation_history as $history_item ) { |
| 356 |
|
| 357 |
$role = $history_item['role'] ?? ( $history_item['sender'] ?? '' ); |
| 358 |
$content = $history_item['content'] ?? ( $history_item['message'] ?? null ); |
| 359 |
|
| 360 |
if ( is_array( $content ) ) { |
| 361 |
$content = wp_json_encode( $content ); |
| 362 |
} |
| 363 |
|
| 364 |
if ( empty( $role ) || $content === null ) { |
| 365 |
continue; |
| 366 |
} |
| 367 |
|
| 368 |
$role = sanitize_key( (string) $role ); |
| 369 |
$role = 'assistant' === $role ? 'model' : 'user'; |
| 370 |
|
| 371 |
$text = trim( (string) $content ); |
| 372 |
if ( '' === $text ) { |
| 373 |
continue; |
| 374 |
} |
| 375 |
|
| 376 |
$contents[] = array( |
| 377 |
'role' => $role, |
| 378 |
'parts' => array( |
| 379 |
array( |
| 380 |
'text' => $text, |
| 381 |
), |
| 382 |
), |
| 383 |
); |
| 384 |
} |
| 385 |
|
| 386 |
// The current turn's message is cached before wpsc_get_chat_response() |
| 387 |
// runs, so it is typically already the last history entry verbatim - |
| 388 |
// in that common case this is a no-op. But a caller can also pass a |
| 389 |
// $message that deliberately differs from what is cached (e.g. the |
| 390 |
// empty-completion retry in WPSC_ACB_Chats::run_agentic_tool_loop(), |
| 391 |
// which passes a richer message summarizing this turn's tool results |
| 392 |
// as plain text): replace the last user turn's content with it rather |
| 393 |
// than silently ignoring $message, so that richer content is what |
| 394 |
// actually reaches the model instead of being dropped. |
| 395 |
$message = is_string( $message ) ? trim( $message ) : ''; |
| 396 |
if ( '' !== $message ) { |
| 397 |
|
| 398 |
// Guarded with >= 0 (not just "$contents non-empty") because several |
| 399 |
// callers (e.g. WPSC_ACB_Create_Support_Ticket::was_ticket_confirmation_actually_asked(), |
| 400 |
// the fabricated-ticket-claim judge, the safe-reply localizer) deliberately |
| 401 |
// pass an empty $conversation_history for a single-turn judge/utility call - |
| 402 |
// with the old "only if $contents is already non-empty" guard, $message was |
| 403 |
// silently dropped in that case, leaving 'contents' => [] in the request body, |
| 404 |
// which the Gemini API rejects outright with a 400 ("contents is not |
| 405 |
// specified") - causing wpsc_get_chat_response() to return success=false for |
| 406 |
// every one of those calls, unconditionally. |
| 407 |
$last_index = count( $contents ) - 1; |
| 408 |
if ( $last_index >= 0 && 'user' === $contents[ $last_index ]['role'] ) { |
| 409 |
$contents[ $last_index ]['parts'] = array( array( 'text' => $message ) ); |
| 410 |
} else { |
| 411 |
$contents[] = array( |
| 412 |
'role' => 'user', |
| 413 |
'parts' => array( array( 'text' => $message ) ), |
| 414 |
); |
| 415 |
} |
| 416 |
} |
| 417 |
|
| 418 |
return $contents ?? array(); |
| 419 |
} |
| 420 |
|
| 421 |
/** |
| 422 |
* Generate a subject line for a chat conversation based on the provided system prompt and conversation history. |
| 423 |
* |
| 424 |
* @param array $ai_settings AI settings array. |
| 425 |
* @param string $system_prompt The system prompt to guide the AI's response. |
| 426 |
* @param string $conversation_text The conversation history to provide context to the AI. |
| 427 |
* @return array The result array containing success status, subject, and provider. |
| 428 |
*/ |
| 429 |
public function generate_chat_conversation_subject_and_summary( $ai_settings, $system_prompt, $conversation_text ) { |
| 430 |
|
| 431 |
if ( empty( $conversation_text ) ) { |
| 432 |
return array( |
| 433 |
'success' => false, |
| 434 |
'subject' => __( 'Conversation history is empty.', 'supportcandy' ), |
| 435 |
'provider' => WPSC_PS_AIT_Provider::GOOGLE_GEMINI, |
| 436 |
); |
| 437 |
} |
| 438 |
|
| 439 |
$model = ! empty( $ai_settings['model'] ) ? sanitize_text_field( $ai_settings['model'] ) : 'gemini-1.5-pro-latest'; |
| 440 |
$max_tokens = ! empty( $ai_settings['max-tokens'] ) && (int) $ai_settings['max-tokens'] > 0 ? (int) $ai_settings['max-tokens'] : 100; |
| 441 |
|
| 442 |
$request_body = array( |
| 443 |
'system_instruction' => array( |
| 444 |
'parts' => array( |
| 445 |
array( 'text' => $system_prompt ), |
| 446 |
), |
| 447 |
), |
| 448 |
'contents' => array( |
| 449 |
array( |
| 450 |
'role' => 'user', |
| 451 |
'parts' => array( |
| 452 |
array( |
| 453 |
'text' => $conversation_text, |
| 454 |
), |
| 455 |
), |
| 456 |
), |
| 457 |
), |
| 458 |
'generationConfig' => array( |
| 459 |
'temperature' => 0.2, |
| 460 |
'maxOutputTokens' => $max_tokens, |
| 461 |
), |
| 462 |
); |
| 463 |
|
| 464 |
for ( $attempt = 1; $attempt <= 3; $attempt++ ) { |
| 465 |
|
| 466 |
$attempt_model = WPSC_PS_AI_Gemini::resolve_retry_model( |
| 467 |
$model, |
| 468 |
$attempt |
| 469 |
); |
| 470 |
|
| 471 |
$attempt_url = sprintf( |
| 472 |
'https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s', |
| 473 |
rawurlencode( $attempt_model ), |
| 474 |
rawurlencode( $ai_settings['api_key'] ) |
| 475 |
); |
| 476 |
|
| 477 |
$response = wp_remote_post( |
| 478 |
$attempt_url, |
| 479 |
array( |
| 480 |
'method' => 'POST', |
| 481 |
'timeout' => 60, |
| 482 |
'sslverify' => true, |
| 483 |
'headers' => array( |
| 484 |
'Content-Type' => 'application/json', |
| 485 |
), |
| 486 |
'body' => wp_json_encode( $request_body ), |
| 487 |
) |
| 488 |
); |
| 489 |
|
| 490 |
if ( WPSC_PS_AI_Gemini::is_retryable_response( $response ) ) { |
| 491 |
|
| 492 |
$status_code = wp_remote_retrieve_response_code( $response ); |
| 493 |
if ( $attempt < 3 ) { |
| 494 |
sleep( 1 ); |
| 495 |
continue; |
| 496 |
} |
| 497 |
} |
| 498 |
|
| 499 |
return $this->process_generate_summary_response( $response ); |
| 500 |
} |
| 501 |
|
| 502 |
return array( |
| 503 |
'success' => false, |
| 504 |
'subject' => __( 'Failed to generate subject.', 'supportcandy' ), |
| 505 |
'provider' => WPSC_PS_AIT_Provider::GOOGLE_GEMINI, |
| 506 |
); |
| 507 |
} |
| 508 |
|
| 509 |
/** |
| 510 |
* Process the response from the Gemini API for generating a summary and extract relevant information. |
| 511 |
* |
| 512 |
* @param array $response The response from the Gemini API. |
| 513 |
* @return array The processed response containing success status, subject, and provider. |
| 514 |
*/ |
| 515 |
private function process_generate_summary_response( $response ) { |
| 516 |
|
| 517 |
if ( is_wp_error( $response ) ) { |
| 518 |
return array( |
| 519 |
'success' => false, |
| 520 |
'subject' => $response->get_error_message(), |
| 521 |
'provider' => WPSC_PS_AIT_Provider::GOOGLE_GEMINI, |
| 522 |
); |
| 523 |
} |
| 524 |
|
| 525 |
$status_code = wp_remote_retrieve_response_code( $response ); |
| 526 |
if ( 200 !== $status_code ) { |
| 527 |
$body = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 528 |
$error_message = ! empty( $body['error']['message'] ) ? $body['error']['message'] : __( 'Google Gemini API request failed.', 'supportcandy' ); |
| 529 |
return array( |
| 530 |
'success' => false, |
| 531 |
'subject' => $error_message, |
| 532 |
'provider' => WPSC_PS_AIT_Provider::GOOGLE_GEMINI, |
| 533 |
); |
| 534 |
} |
| 535 |
|
| 536 |
$body = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 537 |
if ( empty( $body['candidates'][0]['content']['parts'][0]['text'] ) ) { |
| 538 |
return array( |
| 539 |
'success' => false, |
| 540 |
'subject' => __( 'No subject generated by Gemini.', 'supportcandy' ), |
| 541 |
'provider' => WPSC_PS_AIT_Provider::GOOGLE_GEMINI, |
| 542 |
); |
| 543 |
} |
| 544 |
|
| 545 |
$subject = trim( $body['candidates'][0]['content']['parts'][0]['text'] ); |
| 546 |
$subject = preg_replace( '/[\r\n]+/', ' ', $subject ); |
| 547 |
$subject = trim( wp_strip_all_tags( $subject ) ); |
| 548 |
|
| 549 |
return array( |
| 550 |
'success' => true, |
| 551 |
'subject' => $subject, |
| 552 |
'provider' => WPSC_PS_AIT_Provider::GOOGLE_GEMINI, |
| 553 |
); |
| 554 |
} |
| 555 |
|
| 556 |
/** |
| 557 |
* Analyze the conversation subject and status based on the provided system prompt and conversation history. |
| 558 |
* |
| 559 |
* @param array $ai_settings AI settings array. |
| 560 |
* @param string $system_prompt The system prompt to guide the AI's response. |
| 561 |
* @param string $conversation_text The conversation history to provide context to the AI. |
| 562 |
* @return array|false The analysis result with subject and status or false on failure. |
| 563 |
*/ |
| 564 |
public function wpsc_analyze_conversation_subject_status( $ai_settings, $system_prompt, $conversation_text ) { |
| 565 |
|
| 566 |
if ( empty( trim( $conversation_text ) ) ) { |
| 567 |
return array( |
| 568 |
'subject' => __( 'Conversation history is empty.', 'supportcandy' ), |
| 569 |
'status' => 'inactive', |
| 570 |
); |
| 571 |
} |
| 572 |
|
| 573 |
$model = ! empty( $ai_settings['model'] ) ? sanitize_text_field( $ai_settings['model'] ) : 'gemini-1.5-pro-latest'; |
| 574 |
$max_tokens = ! empty( $ai_settings['max-tokens'] ) && (int) $ai_settings['max-tokens'] > 0 ? (int) $ai_settings['max-tokens'] : 150; |
| 575 |
$prompt = $system_prompt . "\n\nConversation:\n\n" . $conversation_text; |
| 576 |
|
| 577 |
$request_body = array( |
| 578 |
'contents' => array( |
| 579 |
array( |
| 580 |
'role' => 'user', |
| 581 |
'parts' => array( |
| 582 |
array( |
| 583 |
'text' => $prompt, |
| 584 |
), |
| 585 |
), |
| 586 |
), |
| 587 |
), |
| 588 |
'generationConfig' => array( |
| 589 |
'temperature' => 0.1, |
| 590 |
'maxOutputTokens' => $max_tokens, |
| 591 |
), |
| 592 |
); |
| 593 |
|
| 594 |
for ( $attempt = 1; $attempt <= 3; $attempt++ ) { |
| 595 |
|
| 596 |
$attempt_model = WPSC_PS_AI_Gemini::resolve_retry_model( |
| 597 |
$model, |
| 598 |
$attempt |
| 599 |
); |
| 600 |
|
| 601 |
$attempt_url = sprintf( |
| 602 |
'https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s', |
| 603 |
rawurlencode( $attempt_model ), |
| 604 |
rawurlencode( $ai_settings['api_key'] ) |
| 605 |
); |
| 606 |
|
| 607 |
$response = wp_remote_post( |
| 608 |
$attempt_url, |
| 609 |
array( |
| 610 |
'method' => 'POST', |
| 611 |
'timeout' => 60, |
| 612 |
'sslverify' => true, |
| 613 |
'headers' => array( |
| 614 |
'Content-Type' => 'application/json', |
| 615 |
), |
| 616 |
'body' => wp_json_encode( $request_body ), |
| 617 |
) |
| 618 |
); |
| 619 |
|
| 620 |
if ( WPSC_PS_AI_Gemini::is_retryable_response( $response ) ) { |
| 621 |
|
| 622 |
$status_code = wp_remote_retrieve_response_code( $response ); |
| 623 |
if ( $attempt < 3 ) { |
| 624 |
sleep( 1 ); |
| 625 |
continue; |
| 626 |
} |
| 627 |
} |
| 628 |
|
| 629 |
return $this->process_subject_status_response( $response ); |
| 630 |
} |
| 631 |
|
| 632 |
return array( |
| 633 |
'subject' => __( 'Failed to analyze conversation.', 'supportcandy' ), |
| 634 |
'status' => 'inactive', |
| 635 |
); |
| 636 |
} |
| 637 |
|
| 638 |
/** |
| 639 |
* Process the response from the Gemini API for analyzing conversation subject and status. |
| 640 |
* |
| 641 |
* @param array $response The response from the Gemini API. |
| 642 |
* @return array The processed response containing subject and status. |
| 643 |
*/ |
| 644 |
private function process_subject_status_response( $response ) { |
| 645 |
|
| 646 |
if ( is_wp_error( $response ) ) { |
| 647 |
return array( |
| 648 |
'subject' => $response->get_error_message(), |
| 649 |
'status' => 'inactive', |
| 650 |
); |
| 651 |
} |
| 652 |
|
| 653 |
$status_code = wp_remote_retrieve_response_code( $response ); |
| 654 |
if ( 200 !== $status_code ) { |
| 655 |
$body = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 656 |
$error_message = ! empty( $body['error']['message'] ) ? $body['error']['message'] : __( 'Google Gemini API request failed.', 'supportcandy' ); |
| 657 |
return array( |
| 658 |
'subject' => $error_message, |
| 659 |
'status' => 'inactive', |
| 660 |
); |
| 661 |
} |
| 662 |
|
| 663 |
$body = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 664 |
if ( empty( $body['candidates'][0]['content']['parts'][0]['text'] ) ) { |
| 665 |
return array( |
| 666 |
'subject' => __( 'No analysis received from Gemini.', 'supportcandy' ), |
| 667 |
'status' => 'inactive', |
| 668 |
); |
| 669 |
} |
| 670 |
$reply = trim( $body['candidates'][0]['content']['parts'][0]['text'] ); |
| 671 |
$result = json_decode( $reply, true ); |
| 672 |
|
| 673 |
if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $result ) ) { |
| 674 |
return array( |
| 675 |
'subject' => __( 'Invalid AI response format.', 'supportcandy' ), |
| 676 |
'status' => 'inactive', |
| 677 |
); |
| 678 |
} |
| 679 |
|
| 680 |
$subject = ! empty( $result['subject'] ) ? sanitize_text_field( $result['subject'] ) : __( 'General Inquiry', 'supportcandy' ); |
| 681 |
$status = ! empty( $result['status'] ) ? sanitize_key( $result['status'] ) : 'inactive'; |
| 682 |
return array( |
| 683 |
'subject' => $subject, |
| 684 |
'status' => $status, |
| 685 |
); |
| 686 |
} |
| 687 |
|
| 688 |
/** |
| 689 |
* Search the knowledge base using the Gemini API. |
| 690 |
* |
| 691 |
* @param array $ai_settings AI settings array. |
| 692 |
* @param string $prompt The search prompt to send to the AI. |
| 693 |
* @param string $query The search query derived from the user request. |
| 694 |
* @return array The search results or an error message. |
| 695 |
*/ |
| 696 |
public function search_knowledge_base( $ai_settings, $prompt, $query ) { |
| 697 |
|
| 698 |
$not_found = array( |
| 699 |
'success' => true, |
| 700 |
'found' => false, |
| 701 |
); |
| 702 |
$error = array( |
| 703 |
'success' => false, |
| 704 |
'error' => 'knowledge_base_unavailable', |
| 705 |
); |
| 706 |
|
| 707 |
$store_name = $this->wpsc_provider_store_id( $ai_settings['api_key'] ); |
| 708 |
$store_name = is_string( $store_name ) ? $store_name : ''; |
| 709 |
$model = ! empty( $ai_settings['model'] ) ? sanitize_text_field( $ai_settings['model'] ) : 'gemini-1.5-pro-latest'; |
| 710 |
$max_tokens = ! empty( $ai_settings['max-tokens'] ) && (int) $ai_settings['max-tokens'] > 0 ? (int) $ai_settings['max-tokens'] : 450; |
| 711 |
$conversation_history = WPSC_ACB_Chats::get_conversation_history(); |
| 712 |
$contents = $this->wpsc_get_api_formatted_chat_messages( $conversation_history, '' ); |
| 713 |
|
| 714 |
$request_body = array( |
| 715 |
'system_instruction' => array( |
| 716 |
'parts' => array( |
| 717 |
array( |
| 718 |
'text' => $prompt, |
| 719 |
), |
| 720 |
), |
| 721 |
), |
| 722 |
'contents' => $contents, |
| 723 |
'generationConfig' => array( |
| 724 |
'temperature' => 0.1, |
| 725 |
'maxOutputTokens' => $max_tokens, |
| 726 |
), |
| 727 |
'tools' => array( |
| 728 |
array( |
| 729 |
'file_search' => array( |
| 730 |
'file_search_store_names' => array( sanitize_text_field( $store_name ) ), |
| 731 |
), |
| 732 |
), |
| 733 |
), |
| 734 |
); |
| 735 |
|
| 736 |
for ( $attempt = 1; $attempt <= 3; $attempt++ ) { |
| 737 |
|
| 738 |
$attempt_model = WPSC_PS_AI_Gemini::resolve_retry_model( |
| 739 |
$model, |
| 740 |
$attempt |
| 741 |
); |
| 742 |
|
| 743 |
$attempt_url = sprintf( |
| 744 |
'https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s', |
| 745 |
rawurlencode( $attempt_model ), |
| 746 |
rawurlencode( $ai_settings['api_key'] ) |
| 747 |
); |
| 748 |
|
| 749 |
$response = wp_remote_post( |
| 750 |
$attempt_url, |
| 751 |
array( |
| 752 |
'method' => 'POST', |
| 753 |
'timeout' => 60, |
| 754 |
'sslverify' => true, |
| 755 |
'headers' => array( |
| 756 |
'Content-Type' => 'application/json', |
| 757 |
), |
| 758 |
'body' => wp_json_encode( $request_body ), |
| 759 |
) |
| 760 |
); |
| 761 |
|
| 762 |
if ( WPSC_PS_AI_Gemini::is_retryable_response( $response ) ) { |
| 763 |
if ( $attempt < 3 ) { |
| 764 |
sleep( 1 ); |
| 765 |
continue; |
| 766 |
} |
| 767 |
} |
| 768 |
|
| 769 |
return $this->process_knowledge_base_response( |
| 770 |
$response, |
| 771 |
$error, |
| 772 |
$not_found |
| 773 |
); |
| 774 |
} |
| 775 |
return $error; |
| 776 |
} |
| 777 |
|
| 778 |
/** |
| 779 |
* Process the knowledge base response from the Gemini API and extract relevant information. |
| 780 |
* |
| 781 |
* Returns structured data only (no pre-rendered HTML) - this is itself a |
| 782 |
* nested LLM call whose synthesized answer is fed back as a tool result |
| 783 |
* into the outer agentic loop, which composes the final user-facing |
| 784 |
* reply in the user's own language. |
| 785 |
* |
| 786 |
* @param array $response The response from the Gemini API. |
| 787 |
* @param array $error The error response to use on request/parse failure. |
| 788 |
* @param array $not_found The response to use when no matching answer was found. |
| 789 |
* @return array |
| 790 |
*/ |
| 791 |
private function process_knowledge_base_response( $response, $error, $not_found ) { |
| 792 |
|
| 793 |
$status_code = wp_remote_retrieve_response_code( $response ); |
| 794 |
if ( is_wp_error( $response ) || 200 !== $status_code ) { |
| 795 |
return $error; |
| 796 |
} |
| 797 |
|
| 798 |
$body = json_decode( wp_remote_retrieve_body( $response ), true ); |
| 799 |
if ( ! empty( $body['promptFeedback']['blockReason'] ) ) { |
| 800 |
return $error; |
| 801 |
} |
| 802 |
|
| 803 |
$reply = self::extract_text_reply( $body ); |
| 804 |
$reply = preg_replace( '#<\s*br\s*/?\s*>#i', "\n", $reply ); |
| 805 |
$reply = preg_replace( '#</\s*(p|div|li|ul|ol|h[1-6])\s*>#i', "\n", $reply ); |
| 806 |
$reply = trim( wp_strip_all_tags( $reply ) ); |
| 807 |
$reply_check = strtoupper( $reply ); |
| 808 |
if ( '[NO_KB_FOUND]' === $reply_check || '' === $reply_check ) { |
| 809 |
return $not_found; |
| 810 |
} |
| 811 |
|
| 812 |
return array( |
| 813 |
'success' => true, |
| 814 |
'found' => true, |
| 815 |
'answer' => $reply, |
| 816 |
); |
| 817 |
} |
| 818 |
|
| 819 |
/** |
| 820 |
* Extract assistant text from Gemini response body. |
| 821 |
* |
| 822 |
* @param array $body Gemini response body. |
| 823 |
* @return string |
| 824 |
*/ |
| 825 |
private static function extract_text_reply( $body ) { |
| 826 |
|
| 827 |
$reply = ''; |
| 828 |
foreach ( $body['candidates'] ?? array() as $candidate ) { |
| 829 |
foreach ( $candidate['content']['parts'] ?? array() as $part ) { |
| 830 |
if ( ! empty( $part['text'] ) && is_string( $part['text'] ) ) { |
| 831 |
$reply .= $part['text']; |
| 832 |
} |
| 833 |
} |
| 834 |
} |
| 835 |
|
| 836 |
return trim( $reply ); |
| 837 |
} |
| 838 |
} |
| 839 |
endif; |
| 840 |
|