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-tool-utils.php

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

313 lines 9.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if ( ! defined( 'ABSPATH' ) ) {
3 exit; // Exit if accessed directly!
4 }
5
6 if ( ! class_exists( 'WPSC_AIBOT_Tool_Utils' ) ) :
7
8 final class WPSC_AIBOT_Tool_Utils {
9
10 /**
11 * Build provider-agnostic tool array for model call.
12 *
13 * @param string $store_id Vector store id for retrieval tool.
14 * @param array $tools Function tool definitions.
15 * @return array
16 */
17 public static function build_openai_tools( $store_id = '', $tools = array() ) {
18
19 $openai_tools = array();
20
21 if ( is_array( $tools ) && ! empty( $tools ) ) {
22 foreach ( $tools as $tool ) {
23 if ( ! is_array( $tool ) || empty( $tool['name'] ) || empty( $tool['description'] ) ) {
24 continue;
25 }
26
27 $parameters = $tool['parameters'] ?? ( $tool['input_schema'] ?? array() );
28 if ( ! is_array( $parameters ) || empty( $parameters['type'] ) ) {
29 continue;
30 }
31
32 $openai_tools[] = array(
33 'type' => 'function',
34 'name' => sanitize_key( (string) $tool['name'] ),
35 'description' => sanitize_text_field( (string) $tool['description'] ),
36 'parameters' => $parameters,
37 'strict' => false,
38 );
39 }
40 }
41
42 return $openai_tools;
43 }
44
45 /**
46 * Extract tool call from OpenAI response output.
47 *
48 * @param array $body Response body.
49 * @return array|null
50 */
51 public static function extract_openai_tool_call( $body ) {
52
53 if ( ! is_array( $body ) ) {
54 return null;
55 }
56
57 foreach ( $body['output'] ?? array() as $output ) {
58 if ( empty( $output['type'] ) || 'function_call' !== $output['type'] || empty( $output['name'] ) ) {
59 continue;
60 }
61
62 $arguments = array();
63 if ( isset( $output['arguments'] ) ) {
64 if ( is_array( $output['arguments'] ) ) {
65 $arguments = $output['arguments'];
66 } elseif ( is_string( $output['arguments'] ) && trim( $output['arguments'] ) !== '' ) {
67 $arguments = json_decode( $output['arguments'], true );
68 if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $arguments ) ) {
69 $arguments = array();
70 }
71 }
72 }
73
74 return array(
75 'name' => sanitize_key( (string) $output['name'] ),
76 'arguments' => $arguments,
77 'call_id' => ! empty( $output['call_id'] ) ? sanitize_text_field( $output['call_id'] ) : '',
78 );
79 }
80
81 return null;
82 }
83
84 /**
85 * Build Gemini-compatible tools array for model call.
86 *
87 * @param string $store_name File search store name for retrieval tool.
88 * @param array $tools Function tool definitions.
89 * @return array
90 */
91 public static function build_gemini_tools( $store_name = '', $tools = array() ) {
92
93 $gemini_tools = array();
94 $declarations = array();
95
96 if ( is_array( $tools ) && ! empty( $tools ) ) {
97 foreach ( $tools as $tool ) {
98 if ( ! is_array( $tool ) || empty( $tool['name'] ) || empty( $tool['description'] ) ) {
99 continue;
100 }
101
102 $parameters = $tool['parameters'] ?? ( $tool['input_schema'] ?? array() );
103 $parameters = self::sanitize_gemini_schema( $parameters );
104 if ( ! is_array( $parameters ) || empty( $parameters['type'] ) ) {
105 continue;
106 }
107
108 $declarations[] = array(
109 'name' => sanitize_key( (string) $tool['name'] ),
110 'description' => sanitize_text_field( (string) $tool['description'] ),
111 'parameters' => $parameters,
112 );
113 }
114 }
115
116 if ( ! empty( $declarations ) ) {
117 $gemini_tools[] = array(
118 'function_declarations' => $declarations,
119 );
120 }
121
122 return $gemini_tools;
123 }
124
125 /**
126 * Extract tool call from Gemini response body.
127 *
128 * Some Gemini models occasionally ignore the native function-calling
129 * protocol and instead write the intended call out as pseudocode text
130 * (e.g. 'print(default_api.search_knowledge_base(query="..."))'). If no
131 * structured functionCall part is present, fall back to detecting that
132 * pattern in the response text so the call still executes instead of
133 * leaking raw pseudocode to the customer - see parse_pseudocode_tool_call().
134 *
135 * @param array $body Response body.
136 * @param array $known_tool_names Registered tool names for this turn, used
137 * to validate a pseudocode-text fallback match.
138 * @return array|null
139 */
140 public static function extract_gemini_tool_call( $body, $known_tool_names = array() ) {
141
142 if ( ! is_array( $body ) || empty( $body['candidates'] ) || ! is_array( $body['candidates'] ) ) {
143 return null;
144 }
145
146 $text_parts = array();
147
148 foreach ( $body['candidates'] as $candidate ) {
149 $parts = $candidate['content']['parts'] ?? array();
150 if ( ! is_array( $parts ) || empty( $parts ) ) {
151 continue;
152 }
153
154 foreach ( $parts as $part ) {
155 $call = array();
156 if ( ! empty( $part['functionCall'] ) && is_array( $part['functionCall'] ) ) {
157 $call = $part['functionCall'];
158 } elseif ( ! empty( $part['function_call'] ) && is_array( $part['function_call'] ) ) {
159 $call = $part['function_call'];
160 }
161
162 if ( empty( $call['name'] ) ) {
163 if ( ! empty( $part['text'] ) && is_string( $part['text'] ) ) {
164 $text_parts[] = $part['text'];
165 }
166 continue;
167 }
168
169 $args = array();
170 if ( isset( $call['args'] ) ) {
171 if ( is_array( $call['args'] ) ) {
172 $args = $call['args'];
173 } elseif ( is_string( $call['args'] ) && trim( $call['args'] ) !== '' ) {
174 $decoded = json_decode( $call['args'], true );
175 if ( json_last_error() === JSON_ERROR_NONE && is_array( $decoded ) ) {
176 $args = $decoded;
177 }
178 }
179 }
180
181 return array(
182 'name' => sanitize_key( (string) $call['name'] ),
183 'arguments' => $args,
184 'call_id' => '',
185 );
186 }
187 }
188
189 if ( ! empty( $text_parts ) ) {
190 return self::parse_pseudocode_tool_call( implode( "\n", $text_parts ), $known_tool_names );
191 }
192
193 return null;
194 }
195
196 /**
197 * Best-effort recovery for a tool call written out as pseudocode text
198 * instead of a native functionCall - e.g.
199 * 'print(default_api.search_knowledge_base(query="trial period"))' or
200 * 'default_api.detect_spam(is_spam=true)'. Only tool arguments matching
201 * the flat string/boolean shape our tools use are supported; anything
202 * else is left unparsed (returns null) rather than guessed at.
203 *
204 * @param string $text Candidate response text.
205 * @param array $known_tool_names Registered tool names to validate the match against.
206 * @return array|null
207 */
208 private static function parse_pseudocode_tool_call( $text, $known_tool_names = array() ) {
209
210 $text = trim( (string) $text );
211 if ( '' === $text ) {
212 return null;
213 }
214
215 // Unwrap a single optional print( ... ) wrapper.
216 if ( 1 === preg_match( '/^print\s*\((.*)\)\s*$/s', $text, $unwrapped ) ) {
217 $text = trim( $unwrapped[1] );
218 }
219
220 if ( 1 !== preg_match( '/^default_api\s*\.\s*([A-Za-z_][A-Za-z0-9_]*)\s*\((.*)\)\s*$/s', $text, $matches ) ) {
221 return null;
222 }
223
224 $name = sanitize_key( $matches[1] );
225 if ( ! empty( $known_tool_names ) && ! in_array( $name, $known_tool_names, true ) ) {
226 return null;
227 }
228
229 $arguments = array();
230 if ( 1 === preg_match_all( '/([A-Za-z_][A-Za-z0-9_]*)\s*=\s*("(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\'|[^,]+)/', $matches[2], $pairs, PREG_SET_ORDER ) ) {
231
232 foreach ( $pairs as $pair ) {
233 $key = sanitize_key( $pair[1] );
234 $value = trim( $pair[2] );
235 if ( strlen( $value ) >= 2 && ( ( '"' === $value[0] && '"' === substr( $value, -1 ) ) || ( "'" === $value[0] && "'" === substr( $value, -1 ) ) ) ) {
236 $value = substr( $value, 1, -1 );
237 }
238 $arguments[ $key ] = stripslashes( $value );
239 }
240 }
241
242 return array(
243 'name' => $name,
244 'arguments' => $arguments,
245 'call_id' => '',
246 );
247 }
248
249 /**
250 * Sanitize JSON schema for Gemini function declarations.
251 *
252 * Gemini schema rejects some JSON Schema keywords (e.g. additionalProperties).
253 *
254 * @param mixed $schema Schema node.
255 * @return mixed
256 */
257 private static function sanitize_gemini_schema( $schema ) {
258
259 if ( ! is_array( $schema ) ) {
260 return $schema;
261 }
262
263 foreach ( $schema as $key => $value ) {
264 if ( 'additionalProperties' === $key ) {
265 unset( $schema[ $key ] );
266 continue;
267 }
268
269 // 'properties' is a map/Struct field in Gemini's schema, not a
270 // repeating one - a parameter-less tool's empty PHP array would
271 // otherwise encode as JSON `[]`, which Gemini rejects outright
272 // ("Cannot bind a list to map for field 'properties'"), failing
273 // every tool call this turn, not just this one tool's, since all
274 // declarations are sent together. Force an object cast so it
275 // always encodes as `{}` instead.
276 if ( 'properties' === $key && is_array( $value ) && empty( $value ) ) {
277 $schema[ $key ] = (object) array();
278 continue;
279 }
280
281 if ( is_array( $value ) ) {
282 $schema[ $key ] = self::sanitize_gemini_schema( $value );
283 }
284 }
285
286 return $schema;
287 }
288
289 /**
290 * Build standard tool-call response payload.
291 *
292 * @param array $tool_call Tool call payload.
293 * @param int $prompt_tokens Prompt tokens.
294 * @param int $completion_tokens Completion tokens.
295 * @param int $total_tokens Total tokens.
296 * @return array
297 */
298 public static function make_tool_call_response( $tool_call, $prompt_tokens, $completion_tokens, $total_tokens ) {
299
300 return array(
301 'success' => true,
302 'response' => '',
303 'create_ticket' => false,
304 'tool_call' => $tool_call,
305 'prompt_tokens' => (int) $prompt_tokens,
306 'completion_tokens' => (int) $completion_tokens,
307 'total_tokens' => (int) $total_tokens,
308 );
309 }
310 }
311
312 endif;
313