PluginProbe
SupportCandy – AI Customer Support Ticket System & Live Chatbot Agent / 3.5.4
SupportCandy – AI Customer Support Ticket System & Live Chatbot Agent v3.5.4
3.5.4 3.5.3 3.5.2 3.5.1 3.4.9 3.5.0 3.4.8 3.4.7 trunk 2.3.1 3.3.6 3.3.7 3.3.8 3.3.9 3.4.0 3.4.1 3.4.2 3.4.3 3.4.4 3.4.5 3.4.6
supportcandy / includes / admin / ai-chatbot / providers / class-wpsc-aibot-gemini.php

class-wpsc-aibot-gemini.php in SupportCandy – AI Customer Support Ticket System & Live Chatbot Agent 3.5.4, at includes/admin/ai-chatbot/providers/class-wpsc-aibot-gemini.php

840 lines 28.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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