Plugin Directory

source: plugin-check/trunk/includes/Traits/AI_Check_Names.php

Last change on this file was 3483193, checked in by githubsync, 13 days ago

Update to version 1.9.0 from GitHub

File size: 28.9 KB
Line 
1<?php
2/**
3 * Trait WordPress\Plugin_Check\Traits\AI_Check_Names
4 *
5 * @package plugin-check
6 */
7
8namespace WordPress\Plugin_Check\Traits;
9
10use WP_Error;
11
12/**
13 * Trait for the Plugin Check Namer tool logic.
14 *
15 * @since 1.8.0
16 */
17trait AI_Check_Names {
18
19        /**
20         * Runs the name analysis via AI (makes two queries like internal scanner).
21         *
22         * @since 1.8.0
23         *
24         * @param string $model_preference Model preference (optional).
25         * @param string $name             Plugin name to evaluate.
26         * @param string $author           Optional author/brand name.
27         * @return array|WP_Error Array with 'text' and 'token_usage' keys, or WP_Error.
28         */
29        protected function run_name_analysis( $model_preference, $name, $author = '' ) {
30                // First query: Similar name search.
31                $similar_name_result = $this->run_similar_name_query( $model_preference, $name );
32                if ( is_wp_error( $similar_name_result ) ) {
33                        return $similar_name_result;
34                }
35
36                // Build additional context from similar name results.
37                $additional_context = $this->build_similar_name_context( $similar_name_result['text'] );
38
39                // Second query: Pre-review with similar name results as context.
40                $prereview_result = $this->run_prereview_query( $model_preference, $name, $additional_context, $author );
41                if ( is_wp_error( $prereview_result ) ) {
42                        return $prereview_result;
43                }
44
45                // Combine token usage from both queries.
46                $prereview_result['token_usage']['similar_name'] = $similar_name_result['token_usage'];
47
48                return $prereview_result;
49        }
50
51        /**
52         * Runs the similar name query (first query).
53         *
54         * @since 1.8.0
55         *
56         * @param string $model_preference Model preference (optional).
57         * @param string $name             Plugin name to evaluate.
58         * @return array|WP_Error Array with 'text' and 'token_usage' keys, or WP_Error.
59         */
60        protected function run_similar_name_query( $model_preference, $name ) {
61                $prompt_template = $this->get_prompt_template( 'ai-check-similar-name.md' );
62                if ( is_wp_error( $prompt_template ) ) {
63                        return $prompt_template;
64                }
65
66                $prompt = $prompt_template . "\n\nPlugin name: {$name}\nPlugin description: (not provided)\n";
67
68                // Execute AI request with structured output configuration.
69                return $this->execute_ai_request(
70                        $prompt,
71                        $model_preference,
72                        function ( $builder ) {
73                                $this->maybe_set_structured_output( $builder, 'similar_name' );
74                        }
75                );
76        }
77
78        /**
79         * Runs the pre-review query (second query).
80         *
81         * @since 1.8.0
82         *
83         * @param string $model_preference   Model preference (optional).
84         * @param string $name               Plugin name to evaluate.
85         * @param string $additional_context Additional context from similar name query.
86         * @param string $author             Optional author/brand name.
87         * @return array|WP_Error Array with 'text' and 'token_usage' keys, or WP_Error.
88         */
89        protected function run_prereview_query( $model_preference, $name, $additional_context = '', $author = '' ) {
90                $prompt_template = $this->get_prompt_template( 'ai-check-prereview.md' );
91                if ( is_wp_error( $prompt_template ) ) {
92                        return $prompt_template;
93                }
94
95                $output_template = $this->get_prompt_template( 'ai-check-prereview-output.md' );
96                if ( is_wp_error( $output_template ) ) {
97                        return $output_template;
98                }
99
100                // Combine developer prompt (system instructions).
101                $developer_prompt = $prompt_template . "\n\n" . $output_template;
102
103                // Build user prompt with plugin information.
104                $user_prompt  = "# Plugin basic information\n\n";
105                $user_prompt .= "- Display name for the plugin: {$name}\n";
106
107                // Add author/brand name if provided.
108                if ( ! empty( $author ) ) {
109                        $user_prompt .= "- Author/Brand name: {$author}\n";
110                        $user_prompt .= "\nNote: The author/brand name provided indicates that the submitter owns or represents this brand. If the plugin name matches or is related to this brand, do not suggest changing the plugin name unless there are other significant conflicts.\n";
111                }
112
113                // Add additional context from similar name query if available.
114                if ( ! empty( $additional_context ) ) {
115                        $user_prompt .= "\n\n" . $additional_context;
116                }
117
118                // Combine both prompts for the AI call.
119                $full_prompt = $developer_prompt . "\n\n---\n\n" . $user_prompt;
120
121                // Execute AI request with structured output configuration.
122                return $this->execute_ai_request(
123                        $full_prompt,
124                        $model_preference,
125                        function ( $builder ) {
126                                $this->maybe_set_structured_output( $builder, 'prereview' );
127                        }
128                );
129        }
130
131        /**
132         * Executes an AI request with the provided parameters.
133         *
134         * @since 1.9.0
135         *
136         * @SuppressWarnings(PHPMD.NPathComplexity)
137         *
138         * @param string        $prompt           The prompt to send to the AI.
139         * @param string        $model_preference Model preference (optional).
140         * @param callable|null $builder_config   Optional callback to configure the prompt builder before execution.
141         * @return array|WP_Error Array with 'text' and optional 'token_usage', or WP_Error on failure.
142         */
143        protected function execute_ai_request( $prompt, $model_preference = '', $builder_config = null ) {
144                if ( ! function_exists( 'wp_ai_client_prompt' ) ) {
145                        return new WP_Error(
146                                'ai_client_not_available',
147                                __( 'AI client is not available. This feature requires WordPress 7.0 or newer.', 'plugin-check' )
148                        );
149                }
150
151                $builder = wp_ai_client_prompt( $prompt );
152                if ( is_wp_error( $builder ) ) {
153                        return $builder;
154                }
155
156                $builder = $this->apply_model_preference( $builder, $model_preference );
157                if ( is_wp_error( $builder ) ) {
158                        return $builder;
159                }
160
161                if ( is_callable( $builder_config ) ) {
162                        call_user_func( $builder_config, $builder );
163                }
164
165                // Try to generate a rich result first.
166                $result = null;
167                if ( method_exists( $builder, 'generate_text_result' ) ) {
168                        $result = $builder->generate_text_result();
169                } elseif ( method_exists( $builder, 'generateTextResult' ) ) {
170                        $result = $builder->generateTextResult();
171                }
172
173                if ( $result ) {
174                        if ( is_wp_error( $result ) ) {
175                                return $result;
176                        }
177
178                        $text  = method_exists( $result, 'to_text' ) ? $result->to_text() : ( method_exists( $result, 'toText' ) ? $result->toText() : '' );
179                        $usage = $this->extract_token_usage( $result );
180
181                        return array_filter(
182                                array(
183                                        'text'        => $text,
184                                        'token_usage' => $usage,
185                                )
186                        );
187                }
188
189                $text = $builder->generate_text();
190                if ( is_wp_error( $text ) ) {
191                        return $text;
192                }
193
194                return array(
195                        'text' => (string) $text,
196                );
197        }
198
199        /**
200         * Applies a model preference to the prompt builder if supported.
201         *
202         * @since 1.9.0
203         *
204         * @param object $builder Prompt builder instance.
205         * @param string $model_preference Model preference.
206         * @return object|WP_Error Updated builder or WP_Error.
207         */
208        protected function apply_model_preference( $builder, $model_preference ) {
209                if ( empty( $model_preference ) ) {
210                        return $builder;
211                }
212
213                $preference = $this->normalize_model_preference( $model_preference );
214
215                try {
216                        $result = $builder->using_model_preference( $preference );
217                        return $result ? $result : $builder;
218                } catch ( \Exception $e ) {
219                        // If method doesn't exist or fails, return WP_Error.
220                        return new WP_Error(
221                                'model_preference_error',
222                                sprintf(
223                                        /* translators: %s: Exception message */
224                                        __( 'Failed to apply model preference: %s', 'plugin-check' ),
225                                        $e->getMessage()
226                                )
227                        );
228                }
229        }
230
231        /**
232         * Normalizes a model preference string into a supported preference format.
233         *
234         * @since 1.9.0
235         *
236         * @param string $model_preference Model preference string.
237         * @return string|array Normalized preference.
238         */
239        protected function normalize_model_preference( $model_preference ) {
240                $trimmed = trim( (string) $model_preference );
241                if ( '' === $trimmed ) {
242                        return '';
243                }
244
245                foreach ( array( '::', '|', ':' ) as $separator ) {
246                        if ( false !== strpos( $trimmed, $separator ) ) {
247                                list( $provider, $model ) = array_map( 'trim', explode( $separator, $trimmed, 2 ) );
248                                if ( '' !== $provider && '' !== $model ) {
249                                        return array( $provider, $model );
250                                }
251                        }
252                }
253
254                return $trimmed;
255        }
256
257        /**
258         * Extracts token usage from a result object, if available.
259         *
260         * @since 1.9.0
261         *
262         * @SuppressWarnings(PHPMD.NPathComplexity)
263         *
264         * @param object $result Result object.
265         * @return array|null Token usage array or null.
266         */
267        protected function extract_token_usage( $result ) {
268                $usage = null;
269
270                if ( method_exists( $result, 'get_token_usage' ) ) {
271                        $usage = $result->get_token_usage();
272                } elseif ( method_exists( $result, 'getTokenUsage' ) ) {
273                        $usage = $result->getTokenUsage();
274                }
275
276                if ( ! $usage || ! is_object( $usage ) ) {
277                        return null;
278                }
279
280                $prompt_tokens     = method_exists( $usage, 'get_prompt_tokens' ) ? $usage->get_prompt_tokens() : ( method_exists( $usage, 'getPromptTokens' ) ? $usage->getPromptTokens() : null );
281                $completion_tokens = method_exists( $usage, 'get_completion_tokens' ) ? $usage->get_completion_tokens() : ( method_exists( $usage, 'getCompletionTokens' ) ? $usage->getCompletionTokens() : null );
282                $total_tokens      = method_exists( $usage, 'get_total_tokens' ) ? $usage->get_total_tokens() : ( method_exists( $usage, 'getTotalTokens' ) ? $usage->getTotalTokens() : null );
283
284                if ( null === $prompt_tokens && null === $completion_tokens && null === $total_tokens ) {
285                        return null;
286                }
287
288                return array_filter(
289                        array(
290                                'prompt_tokens'     => $prompt_tokens,
291                                'completion_tokens' => $completion_tokens,
292                                'total_tokens'      => $total_tokens,
293                        ),
294                        static function ( $value ) {
295                                return null !== $value;
296                        }
297                );
298        }
299
300        /**
301         * Builds additional context from similar name results.
302         *
303         * @since 1.8.0
304         *
305         * @param string $similar_name_result Similar name query result.
306         * @return string Additional context text.
307         */
308        protected function build_similar_name_context( $similar_name_result ) {
309                if ( empty( $similar_name_result ) ) {
310                        return '';
311                }
312
313                $context  = "# Possible similarity to other plugins, trademarks and project names.\n\n";
314                $context .= "We've detected the following possible similarities. Check them and determine if there is a high similarity. This is not an exhaustive list. It is only the result of an internet search, so you need to check its validity for this case. Do not mention them in your reply.\n\n";
315                $context .= $similar_name_result;
316
317                return $context;
318        }
319
320        /**
321         * Loads the AI prompt template.
322         *
323         * @since 1.8.0
324         *
325         * @param string $filename Optional filename to load. Default 'ai-check-similar-name.md'.
326         * @return string|WP_Error Prompt template or error.
327         */
328        protected function get_prompt_template( $filename = 'ai-check-similar-name.md' ) {
329                if ( ! defined( 'WP_PLUGIN_CHECK_PLUGIN_DIR_PATH' ) ) {
330                        return new WP_Error( 'plugin_constant_not_defined', __( 'Plugin constant not defined.', 'plugin-check' ) );
331                }
332
333                $path = WP_PLUGIN_CHECK_PLUGIN_DIR_PATH . 'prompts/' . $filename;
334                if ( ! file_exists( $path ) ) {
335                        return new WP_Error( 'prompt_not_found', __( 'Prompt template not found.', 'plugin-check' ) );
336                }
337
338                $contents = (string) file_get_contents( $path );
339                $contents = trim( $contents );
340
341                if ( empty( $contents ) ) {
342                        return new WP_Error( 'prompt_empty', __( 'Prompt template is empty.', 'plugin-check' ) );
343                }
344
345                return $contents;
346        }
347
348        /**
349         * Parses the analysis into a verdict and explanation.
350         *
351         * @since 1.8.0
352         *
353         * @param array|string $analysis AI output (array with 'text' and 'token_usage', or string for backward compat).
354         * @return array
355         */
356        protected function parse_analysis( $analysis ) {
357                // Extract text from array format (new format with token usage).
358                $analysis_text = is_array( $analysis ) && isset( $analysis['text'] ) ? $analysis['text'] : $analysis;
359
360                if ( empty( $analysis_text ) ) {
361                        return array(
362                                'verdict'     => '❓ ' . __( 'Empty Response', 'plugin-check' ),
363                                'explanation' => __( 'The AI did not return any analysis. Please try again.', 'plugin-check' ),
364                        );
365                }
366
367                $analysis_trim = trim( (string) $analysis_text );
368
369                // Try parsing as JSON first (structured output format).
370                $parsed_data = $this->parse_json_response( $analysis_trim );
371
372                // If JSON parsing failed, try markdown format.
373                if ( empty( $parsed_data ) || ! isset( $parsed_data['possible_naming_issues'] ) ) {
374                        $parsed_data = $this->parse_markdown_format( $analysis_trim );
375                }
376
377                if ( ! empty( $parsed_data ) && isset( $parsed_data['possible_naming_issues'] ) ) {
378                        $result = $this->parse_prereview_response( $parsed_data );
379
380                        // Add token usage info if available.
381                        if ( is_array( $analysis ) && isset( $analysis['token_usage'] ) ) {
382                                $result['token_usage'] = $analysis['token_usage'];
383                        }
384
385                        return $result;
386                }
387
388                // Unable to parse format.
389                return array(
390                        'verdict'     => '❓ ' . __( 'Unable to Parse', 'plugin-check' ),
391                        'explanation' => wp_kses_post( __( 'The AI response could not be parsed. Raw response:', 'plugin-check' ) . '<br><br>' . esc_html( substr( $analysis_trim, 0, 500 ) ) ),
392                        'raw'         => $analysis_trim,
393                );
394        }
395
396        /**
397         * Parses JSON response from AI.
398         *
399         * @since 1.8.0
400         *
401         * @param string $text AI response text.
402         * @return array Parsed data array or empty array if not valid JSON.
403         */
404        protected function parse_json_response( $text ) {
405                if ( empty( $text ) ) {
406                        return array();
407                }
408
409                $trimmed = trim( $text );
410
411                // Remove markdown code fences if present.
412                $trimmed = preg_replace( '/^```(?:json)?\s*\n?/m', '', $trimmed );
413                $trimmed = preg_replace( '/\n?```\s*$/m', '', $trimmed );
414                $trimmed = trim( $trimmed );
415
416                // Try to find JSON object boundaries.
417                $first_brace = strpos( $trimmed, '{' );
418                if ( false !== $first_brace ) {
419                        $last_brace = strrpos( $trimmed, '}' );
420                        if ( false !== $last_brace && $last_brace > $first_brace ) {
421                                $json_text = substr( $trimmed, $first_brace, $last_brace - $first_brace + 1 );
422                        } else {
423                                $json_text = $trimmed;
424                        }
425                } else {
426                        $json_text = $trimmed;
427                }
428
429                // Try to decode as JSON.
430                $decoded = json_decode( $json_text, true );
431
432                if ( JSON_ERROR_NONE === json_last_error() && is_array( $decoded ) && isset( $decoded['possible_naming_issues'] ) ) {
433                        return $decoded;
434                }
435
436                return array();
437        }
438
439        /**
440         * Parses markdown/YAML-like format from AI response.
441         *
442         * Format: - key: value
443         *
444         * @since 1.8.0
445         *
446         * @param string $text AI response text.
447         * @return array Parsed data array.
448         */
449        protected function parse_markdown_format( $text ) {
450                $result = array();
451                $lines  = explode( "\n", $text );
452
453                foreach ( $lines as $line ) {
454                        $parsed = $this->parse_markdown_line( trim( $line ) );
455                        if ( null !== $parsed ) {
456                                $result[ $parsed['key'] ] = $parsed['value'];
457                        }
458                }
459
460                return $result;
461        }
462
463        /**
464         * Parses a single markdown line into key-value pair.
465         *
466         * @since 1.8.0
467         *
468         * @param string $line Line to parse.
469         * @return array|null Array with 'key' and 'value', or null if line should be skipped.
470         */
471        protected function parse_markdown_line( $line ) {
472                if ( empty( $line ) ) {
473                        return null;
474                }
475
476                $line      = ltrim( $line, '- ' );
477                $colon_pos = strpos( $line, ':' );
478
479                if ( false === $colon_pos ) {
480                        return null;
481                }
482
483                $key   = trim( substr( $line, 0, $colon_pos ) );
484                $value = trim( substr( $line, $colon_pos + 1 ) );
485
486                if ( empty( $key ) ) {
487                        return null;
488                }
489
490                return array(
491                        'key'   => $key,
492                        'value' => $this->parse_markdown_value( $key, $value ),
493                );
494        }
495
496        /**
497         * Parses markdown value based on format.
498         *
499         * @since 1.8.0
500         *
501         * @param string $key   Field key.
502         * @param string $value Field value.
503         * @return mixed Parsed value (string, bool, or array).
504         */
505        protected function parse_markdown_value( $key, $value ) {
506                // Try JSON array.
507                if ( 0 === strpos( $value, '[' ) ) {
508                        $decoded = json_decode( $value, true );
509                        if ( is_array( $decoded ) ) {
510                                return $decoded;
511                        }
512                }
513
514                // Parse booleans.
515                $lower = strtolower( $value );
516                if ( 'true' === $lower ) {
517                        return true;
518                }
519                if ( 'false' === $lower ) {
520                        return false;
521                }
522
523                // Parse comma-separated for disallowed_type.
524                if ( 'disallowed_type' === $key && false !== strpos( $value, ',' ) ) {
525                        return array_map( 'trim', explode( ',', $value ) );
526                }
527
528                return $value;
529        }
530
531        /**
532         * Parses pre-review response format into user-friendly output.
533         *
534         * @since 1.8.0
535         *
536         * @param array $decoded Decoded JSON response.
537         * @return array{verdict:string,explanation:string,processed_data:array} Parsed result.
538         */
539        protected function parse_prereview_response( $decoded ) {
540                $verdict           = $this->build_verdict( $decoded );
541                $explanation_parts = $this->build_explanation_parts( $decoded );
542                $explanation       = ! empty( $explanation_parts ) ? implode( '<br><br>', $explanation_parts ) : __( 'No detailed analysis available.', 'plugin-check' );
543
544                return array(
545                        'verdict'        => $verdict,
546                        'explanation'    => wp_kses_post( $explanation ),
547                        'processed_data' => $decoded,
548                );
549        }
550
551        /**
552         * Builds verdict from decoded data.
553         *
554         * @since 1.8.0
555         *
556         * @param array $decoded Decoded data.
557         * @return string Verdict string.
558         */
559        protected function build_verdict( $decoded ) {
560                $issues        = $this->collect_issues( $decoded );
561                $is_disallowed = ! empty( $decoded['disallowed'] );
562
563                if ( $is_disallowed ) {
564                        return '❌ ' . __( 'Disallowed', 'plugin-check' );
565                }
566
567                if ( ! empty( $issues ) ) {
568                        return '⚠️ ' . __( 'Issues Found', 'plugin-check' ) . ': ' . implode( ', ', $issues );
569                }
570
571                // Check for suggestions, trademarks, or other indicators that suggest it's not clearly OK.
572                $has_suggestions = ! empty( $decoded['suggested_display_name'] ) || ! empty( $decoded['suggested_slug'] );
573                $has_trademarks  = ! empty( $decoded['trademarks_or_project_names_array'] ) && is_array( $decoded['trademarks_or_project_names_array'] ) && count( $decoded['trademarks_or_project_names_array'] ) > 0;
574
575                if ( $has_suggestions || $has_trademarks ) {
576                        return 'ℹ️ ' . __( 'Generally Allowable', 'plugin-check' );
577                }
578
579                return '✅ ' . __( 'No Issues Detected', 'plugin-check' );
580        }
581
582        /**
583         * Collects issues from decoded data.
584         *
585         * @since 1.8.0
586         *
587         * @param array $decoded Decoded data.
588         * @return array List of issues.
589         */
590        protected function collect_issues( $decoded ) {
591                $issues = array();
592
593                if ( ! empty( $decoded['possible_naming_issues'] ) ) {
594                        $issues[] = __( 'Naming', 'plugin-check' );
595                }
596                if ( ! empty( $decoded['possible_owner_issues'] ) ) {
597                        $issues[] = __( 'Owner/Trademark', 'plugin-check' );
598                }
599                if ( ! empty( $decoded['possible_description_issues'] ) ) {
600                        $issues[] = __( 'Description', 'plugin-check' );
601                }
602
603                return $issues;
604        }
605
606        /**
607         * Builds explanation parts from decoded data.
608         *
609         * @since 1.8.0
610         *
611         * @param array $decoded Decoded data.
612         * @return array Explanation parts.
613         */
614        protected function build_explanation_parts( $decoded ) {
615                $parts = array();
616
617                $this->add_disallowed_section( $parts, $decoded );
618                $this->add_naming_section( $parts, $decoded );
619                $this->add_owner_section( $parts, $decoded );
620                $this->add_description_section( $parts, $decoded );
621                $this->add_trademarks_section( $parts, $decoded );
622                $this->add_suggestions_section( $parts, $decoded );
623                $this->add_language_section( $parts, $decoded );
624
625                return $parts;
626        }
627
628        /**
629         * Adds disallowed section to explanation parts.
630         *
631         * @since 1.8.0
632         *
633         * @param array $parts   Explanation parts array (passed by reference).
634         * @param array $decoded Decoded data.
635         * @return void
636         */
637        protected function add_disallowed_section( &$parts, $decoded ) {
638                if ( empty( $decoded['disallowed'] ) ) {
639                        return;
640                }
641
642                $text = '';
643                if ( ! empty( $decoded['disallowed_explanation'] ) ) {
644                        $text .= $decoded['disallowed_explanation'];
645                }
646                if ( ! empty( $decoded['disallowed_type'] ) && is_array( $decoded['disallowed_type'] ) ) {
647                        $text .= ' (' . implode( ', ', $decoded['disallowed_type'] ) . ')';
648                }
649                if ( ! empty( $text ) ) {
650                        $parts[] = '<strong>' . __( '🚫 Disallowed:', 'plugin-check' ) . '</strong> ' . $text;
651                }
652        }
653
654        /**
655         * Adds naming section to explanation parts.
656         *
657         * @since 1.8.0
658         *
659         * @param array $parts   Explanation parts array (passed by reference).
660         * @param array $decoded Decoded data.
661         * @return void
662         */
663        protected function add_naming_section( &$parts, $decoded ) {
664                if ( ! empty( $decoded['possible_naming_issues'] ) && ! empty( $decoded['naming_explanation'] ) ) {
665                        $parts[] = '<strong>' . __( '📝 Naming:', 'plugin-check' ) . '</strong> ' . $decoded['naming_explanation'];
666                }
667        }
668
669        /**
670         * Adds owner/trademark section to explanation parts.
671         *
672         * @since 1.8.0
673         *
674         * @param array $parts   Explanation parts array (passed by reference).
675         * @param array $decoded Decoded data.
676         * @return void
677         */
678        protected function add_owner_section( &$parts, $decoded ) {
679                if ( ! empty( $decoded['possible_owner_issues'] ) && ! empty( $decoded['owner_explanation'] ) ) {
680                        $parts[] = '<strong>' . __( '©️ Owner/Trademark:', 'plugin-check' ) . '</strong> ' . $decoded['owner_explanation'];
681                }
682        }
683
684        /**
685         * Adds description section to explanation parts.
686         *
687         * @since 1.8.0
688         *
689         * @param array $parts   Explanation parts array (passed by reference).
690         * @param array $decoded Decoded data.
691         * @return void
692         */
693        protected function add_description_section( &$parts, $decoded ) {
694                if ( ! empty( $decoded['possible_description_issues'] ) && ! empty( $decoded['description_explanation'] ) ) {
695                        $parts[] = '<strong>' . __( '📄 Description:', 'plugin-check' ) . '</strong> ' . $decoded['description_explanation'];
696                }
697        }
698
699        /**
700         * Adds trademarks section to explanation parts.
701         *
702         * @since 1.8.0
703         *
704         * @param array $parts   Explanation parts array (passed by reference).
705         * @param array $decoded Decoded data.
706         * @return void
707         */
708        protected function add_trademarks_section( &$parts, $decoded ) {
709                if ( ! empty( $decoded['trademarks_or_project_names_array'] ) && is_array( $decoded['trademarks_or_project_names_array'] ) ) {
710                        $trademarks = implode( ', ', array_map( 'esc_html', $decoded['trademarks_or_project_names_array'] ) );
711                        $parts[]    = '<strong>' . __( '™️ Trademarks Detected:', 'plugin-check' ) . '</strong> ' . $trademarks;
712                }
713        }
714
715        /**
716         * Adds suggestions section to explanation parts.
717         *
718         * @since 1.8.0
719         *
720         * @param array $parts   Explanation parts array (passed by reference).
721         * @param array $decoded Decoded data.
722         * @return void
723         */
724        protected function add_suggestions_section( &$parts, $decoded ) {
725                $suggestions = array();
726
727                if ( ! empty( $decoded['suggested_display_name'] ) ) {
728                        $suggestions[] = '<strong>' . __( 'Display Name:', 'plugin-check' ) . '</strong> ' . esc_html( $decoded['suggested_display_name'] );
729                }
730                if ( ! empty( $decoded['suggested_slug'] ) ) {
731                        $suggestions[] = '<strong>' . __( 'Slug:', 'plugin-check' ) . '</strong> ' . esc_html( $decoded['suggested_slug'] );
732                }
733                if ( ! empty( $decoded['short_description'] ) ) {
734                        $suggestions[] = '<strong>' . __( 'Description:', 'plugin-check' ) . '</strong> ' . esc_html( $decoded['short_description'] );
735                }
736                if ( ! empty( $decoded['plugin_category'] ) ) {
737                        $suggestions[] = '<strong>' . __( 'Category:', 'plugin-check' ) . '</strong> ' . esc_html( $decoded['plugin_category'] );
738                }
739
740                if ( ! empty( $suggestions ) ) {
741                        $parts[] = '<br><strong>' . __( '💡 Suggestions:', 'plugin-check' ) . '</strong><br>' . implode( '<br>', $suggestions );
742                }
743        }
744
745        /**
746         * Adds language section to explanation parts.
747         *
748         * @since 1.8.0
749         *
750         * @param array $parts   Explanation parts array (passed by reference).
751         * @param array $decoded Decoded data.
752         * @return void
753         */
754        protected function add_language_section( &$parts, $decoded ) {
755                if ( isset( $decoded['description_language_is_in_english'] ) && false === $decoded['description_language_is_in_english'] ) {
756                        if ( ! empty( $decoded['description_what_is_not_in_english'] ) ) {
757                                $parts[] = '<strong>' . __( '🌐 Language:', 'plugin-check' ) . '</strong> ' . $decoded['description_what_is_not_in_english'];
758                        }
759                }
760        }
761
762        /**
763         * Attempts to set structured output on the builder if supported.
764         *
765         * @since 1.8.0
766         *
767         * @param object $builder The prompt builder instance.
768         * @param string $query_type Type of query: 'similar_name' or 'prereview'.
769         * @return void
770         */
771        protected function maybe_set_structured_output( $builder, $query_type = 'similar_name' ) {
772                // Define the JSON schema based on query type.
773                if ( 'prereview' === $query_type ) {
774                        $json_schema = $this->get_prereview_schema();
775                } else {
776                        $json_schema = $this->get_similar_name_schema();
777                }
778
779                // Try different method names that might be used for structured output.
780                $methods = array(
781                        'withStructuredOutput',
782                        'setResponseFormat',
783                        'usingResponseFormat',
784                        'withJsonSchema',
785                        'with_structured_output',
786                        'set_response_format',
787                        'using_response_format',
788                        'with_json_schema',
789                );
790
791                foreach ( $methods as $method ) {
792                        if ( method_exists( $builder, $method ) ) {
793                                call_user_func( array( $builder, $method ), $json_schema );
794                                break;
795                        }
796                }
797
798                // Try setting response format as a property if it exists.
799                // Note: Using reflection to set property as it may not be public.
800                // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
801                if ( property_exists( $builder, 'responseFormat' ) || property_exists( $builder, 'response_format' ) ) {
802                        $prop_name = property_exists( $builder, 'responseFormat' ) ? 'responseFormat' : 'response_format';
803                        try {
804                                $reflection = new \ReflectionClass( $builder );
805                                $property   = $reflection->getProperty( $prop_name );
806                                $property->setAccessible( true );
807                                $property->setValue(
808                                        $builder,
809                                        array(
810                                                'type'   => 'json_schema',
811                                                'schema' => $json_schema,
812                                        )
813                                );
814                        } catch ( \Exception $e ) {
815                                // If reflection fails, try direct assignment.
816                                if ( property_exists( $builder, $prop_name ) ) {
817                                        $builder->$prop_name = array(
818                                                'type'   => 'json_schema',
819                                                'schema' => $json_schema,
820                                        );
821                                }
822                        }
823                }
824                // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
825        }
826
827        /**
828         * Gets the JSON schema for similar name query.
829         *
830         * @since 1.8.0
831         *
832         * @return array JSON schema array.
833         */
834        protected function get_similar_name_schema() {
835                return array(
836                        'type'                 => 'object',
837                        'properties'           => array(
838                                'name_similarity_percentage' => array( 'type' => 'number' ),
839                                'similarity_explanation'     => array( 'type' => 'string' ),
840                                'confusion_existing_plugins' => array(
841                                        'type'  => 'array',
842                                        'items' => array(
843                                                'type'                 => 'object',
844                                                'properties'           => array(
845                                                        'name'                 => array( 'type' => 'string' ),
846                                                        'similarity_level'     => array( 'type' => 'string' ),
847                                                        'explanation'          => array( 'type' => 'string' ),
848                                                        'active_installations' => array( 'type' => 'string' ),
849                                                        'link'                 => array( 'type' => 'string' ),
850                                                ),
851                                                'required'             => array( 'name', 'similarity_level', 'explanation', 'active_installations', 'link' ),
852                                                'additionalProperties' => false,
853                                        ),
854                                ),
855                                'confusion_existing_others'  => array(
856                                        'type'  => 'array',
857                                        'items' => array(
858                                                'type'                 => 'object',
859                                                'properties'           => array(
860                                                        'name'             => array( 'type' => 'string' ),
861                                                        'similarity_level' => array( 'type' => 'string' ),
862                                                        'explanation'      => array( 'type' => 'string' ),
863                                                        'link'             => array( 'type' => 'string' ),
864                                                ),
865                                                'required'             => array( 'name', 'similarity_level', 'explanation', 'link' ),
866                                                'additionalProperties' => false,
867                                        ),
868                                ),
869                        ),
870                        'required'             => array(
871                                'name_similarity_percentage',
872                                'similarity_explanation',
873                                'confusion_existing_plugins',
874                                'confusion_existing_others',
875                        ),
876                        'additionalProperties' => false,
877                );
878        }
879
880        /**
881         * Gets the JSON schema for pre-review query.
882         *
883         * @since 1.8.0
884         *
885         * @return array JSON schema array.
886         */
887        protected function get_prereview_schema() {
888                return array(
889                        'type'                 => 'object',
890                        'properties'           => array(
891                                'possible_naming_issues'            => array( 'type' => 'boolean' ),
892                                'naming_explanation'                => array( 'type' => 'string' ),
893                                'disallowed'                        => array( 'type' => 'boolean' ),
894                                'disallowed_explanation'            => array( 'type' => 'string' ),
895                                'disallowed_type'                   => array(
896                                        'type'  => 'array',
897                                        'items' => array(
898                                                'type' => 'string',
899                                        ),
900                                ),
901                                'trademarks_or_project_names_array' => array(
902                                        'type'  => 'array',
903                                        'items' => array(
904                                                'type' => 'string',
905                                        ),
906                                ),
907                                'suggested_display_name'            => array( 'type' => 'string' ),
908                                'suggested_slug'                    => array( 'type' => 'string' ),
909                        ),
910                        'required'             => array(
911                                'possible_naming_issues',
912                                'naming_explanation',
913                                'disallowed',
914                                'disallowed_explanation',
915                                'disallowed_type',
916                                'trademarks_or_project_names_array',
917                                'suggested_display_name',
918                                'suggested_slug',
919                                'short_description',
920                                'plugin_category',
921                        ),
922                        'additionalProperties' => false,
923                );
924        }
925
926        /**
927         * Stores a transient result.
928         *
929         * @since 1.8.0
930         *
931         * @param int   $user_id User ID.
932         * @param array $data    Result data.
933         */
934        protected function store_result( $user_id, $data ) {
935                set_transient( $this->get_result_transient_key( $user_id ), $data, 10 * MINUTE_IN_SECONDS );
936        }
937
938        /**
939         * Gets the transient key.
940         *
941         * @since 1.8.0
942         *
943         * @param int $user_id User ID.
944         * @return string
945         */
946        protected function get_result_transient_key( $user_id ) {
947                return 'plugin_check_namer_result_' . (int) $user_id;
948        }
949}
Note: See TracBrowser for help on using the repository browser.