PluginProbe
AI / trunk
AI vtrunk
1.3.0 1.2.0 1.1.0 1.0.2 1.0.1 1.0.0 0.9.0 trunk 0.1.1 0.2.0 0.2.1 0.3.0 0.3.1 0.4.0 0.4.1 0.5.0 0.6.0 0.7.0 0.8.0
ai / includes / Abilities / Content_Classification / Content_Classification.php

Content_Classification.php in AI trunk, at includes/Abilities/Content_Classification/Content_Classification.php

747 lines 24.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Content classification WordPress Ability implementation.
4 *
5 * @package WordPress\AI
6 */
7
8 declare( strict_types=1 );
9
10 namespace WordPress\AI\Abilities\Content_Classification;
11
12 use WP_Error;
13 use WP_Post;
14 use WP_Post_Type;
15 use WP_Taxonomy;
16 use WordPress\AI\Abstracts\Abstract_Ability;
17 use WordPress\AI\Experiments\Content_Classification\Content_Classification as Content_Classification_Experiment;
18
19 use function WordPress\AI\get_post_context;
20 use function WordPress\AI\normalize_content;
21
22 /**
23 * Content classification WordPress Ability.
24 *
25 * Generates taxonomy term suggestions based on post content analysis.
26 *
27 * @since 0.7.0
28 */
29 class Content_Classification extends Abstract_Ability {
30
31 /**
32 * Default minimum confidence below which a suggestion is dropped.
33 *
34 * The system prompt instructs the model to use 0.5 = "somewhat
35 * relevant" / 1.0 = "perfect match". In practice true positives
36 * cluster at ≥0.85 and generic / popularity-driven false positives
37 * sit in the 0.5–0.78 range, so filtering at 0.6 removes most
38 * noise without meaningfully impacting recall. Filterable via
39 * `wpai_content_classification_min_confidence`.
40 *
41 * @since 1.3.0
42 *
43 * @var float
44 */
45 public const MIN_CONFIDENCE = 0.6;
46
47 /**
48 * {@inheritDoc}
49 *
50 * @since 0.8.0
51 */
52 protected function guideline_categories(): array {
53 return array( 'site', 'copy' );
54 }
55
56 /**
57 * Returns the input schema of the ability.
58 *
59 * @since 0.7.0
60 *
61 * @return array<string, mixed> The input schema of the ability.
62 */
63 protected function input_schema(): array {
64 return array(
65 'type' => 'object',
66 'properties' => array(
67 'content' => array(
68 'type' => 'string',
69 'description' => esc_html__( 'Content to generate taxonomy suggestions for.', 'ai' ),
70 ),
71 'post_id' => array(
72 'type' => 'integer',
73 'description' => esc_html__( 'Content from this post will be used to generate taxonomy suggestions. This overrides the content parameter if both are provided.', 'ai' ),
74 ),
75 'taxonomy' => array(
76 'type' => 'string',
77 'default' => 'post_tag',
78 'description' => esc_html__( 'The taxonomy to generate suggestions for (e.g., post_tag, category).', 'ai' ),
79 ),
80 'strategy' => array(
81 'type' => 'string',
82 'default' => Content_Classification_Experiment::STRATEGY_EXISTING_ONLY,
83 'description' => esc_html__( 'The suggestion strategy: existing_only or allow_new.', 'ai' ),
84 ),
85 'max_suggestions' => array(
86 'type' => 'integer',
87 'minimum' => 1,
88 'maximum' => 10,
89 'default' => Content_Classification_Experiment::DEFAULT_MAX_SUGGESTIONS,
90 'description' => esc_html__( 'Maximum number of suggestions to generate.', 'ai' ),
91 ),
92 ),
93 );
94 }
95
96 /**
97 * Returns the output schema of the ability.
98 *
99 * @since 0.7.0
100 *
101 * @return array<string, mixed> The output schema of the ability.
102 */
103 protected function output_schema(): array {
104 return array(
105 'type' => 'object',
106 'properties' => array(
107 'suggestions' => array(
108 'type' => 'array',
109 'description' => esc_html__( 'Generated taxonomy term suggestions.', 'ai' ),
110 'items' => array(
111 'type' => 'object',
112 'properties' => array(
113 'term' => array(
114 'type' => 'string',
115 'description' => esc_html__( 'The suggested term name.', 'ai' ),
116 ),
117 'confidence' => array(
118 'type' => 'number',
119 'description' => esc_html__( 'Confidence score between 0 and 1.', 'ai' ),
120 ),
121 'is_new' => array(
122 'type' => 'boolean',
123 'description' => esc_html__( 'Whether this is a new term or an existing one.', 'ai' ),
124 ),
125 'parent' => array(
126 'type' => 'string',
127 'description' => esc_html__( 'Parent term name for hierarchical taxonomies.', 'ai' ),
128 ),
129 ),
130 ),
131 ),
132 ),
133 );
134 }
135
136 /**
137 * Executes the ability with the given input arguments.
138 *
139 * @since 0.7.0
140 *
141 * @param mixed $input The input arguments to the ability.
142 * @return array{suggestions: array<array{term: string, confidence: float, is_new: bool, parent?: string}>}|\WP_Error The result of the ability execution, or a WP_Error on failure.
143 */
144 protected function execute_callback( $input ) {
145 // Default arguments.
146 $args = wp_parse_args(
147 $input,
148 array(
149 'content' => null,
150 'post_id' => null,
151 'taxonomy' => 'post_tag',
152 'strategy' => Content_Classification_Experiment::STRATEGY_EXISTING_ONLY,
153 'max_suggestions' => (int) Content_Classification_Experiment::DEFAULT_MAX_SUGGESTIONS,
154 ),
155 );
156
157 // Validate taxonomy.
158 if ( ! taxonomy_exists( $args['taxonomy'] ) ) {
159 return new WP_Error(
160 'invalid_taxonomy',
161 /* translators: %s: Taxonomy name. */
162 sprintf( esc_html__( 'Taxonomy "%s" does not exist.', 'ai' ), sanitize_key( $args['taxonomy'] ) )
163 );
164 }
165
166 $assigned_terms = array();
167
168 // If a post ID is provided, ensure the post exists before using its content.
169 if ( $args['post_id'] ) {
170 $post = get_post( (int) $args['post_id'] );
171
172 if ( ! $post instanceof WP_Post ) {
173 return new WP_Error(
174 'post_not_found',
175 /* translators: %d: Post ID. */
176 sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), absint( $args['post_id'] ) )
177 );
178 }
179
180 // Get the post context.
181 $context = get_post_context( (int) $args['post_id'] );
182
183 // Default to the passed in content if it exists.
184 if ( $args['content'] ) {
185 $context['content'] = normalize_content( $args['content'] );
186 }
187
188 // Get terms already assigned to this post for the taxonomy.
189 $assigned = wp_get_object_terms( (int) $args['post_id'], $args['taxonomy'], array( 'fields' => 'names' ) );
190 if ( ! is_wp_error( $assigned ) ) {
191 $assigned_terms = (array) $assigned;
192 }
193 } else {
194 $context = array(
195 'content' => normalize_content( $args['content'] ?? '' ),
196 );
197 }
198
199 // If we have no content, return an error.
200 if ( empty( $context['content'] ) ) {
201 return new WP_Error(
202 'content_not_provided',
203 esc_html__( 'Content is required to generate taxonomy suggestions.', 'ai' )
204 );
205 }
206
207 // Generate the suggestions.
208 $result = $this->generate_suggestions(
209 $context,
210 $args['taxonomy'],
211 $args['strategy'],
212 (int) $args['max_suggestions'],
213 $assigned_terms
214 );
215
216 // If we have an error, return it.
217 if ( is_wp_error( $result ) ) {
218 return $result;
219 }
220
221 // If we have no results, return an error.
222 if ( empty( $result ) ) {
223 return new WP_Error(
224 'no_results',
225 esc_html__( 'No taxonomy suggestions were generated.', 'ai' )
226 );
227 }
228
229 return array(
230 'suggestions' => $result,
231 );
232 }
233
234 /**
235 * Returns the permission callback of the ability.
236 *
237 * @since 0.7.0
238 *
239 * @param mixed $args The input arguments to the ability.
240 * @return bool|\WP_Error True if the user has permission, WP_Error otherwise.
241 */
242 protected function permission_callback( $args ) {
243 $post_id = isset( $args['post_id'] ) ? absint( $args['post_id'] ) : null;
244
245 if ( $post_id ) {
246 $post = get_post( $post_id );
247
248 // Ensure the post exists.
249 if ( ! $post instanceof WP_Post ) {
250 return new WP_Error(
251 'post_not_found',
252 /* translators: %d: Post ID. */
253 sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), $post_id )
254 );
255 }
256
257 // Ensure the user has permission to edit this particular post.
258 if ( ! current_user_can( 'edit_post', $post_id ) ) {
259 return new WP_Error(
260 'insufficient_capabilities',
261 esc_html__( 'You do not have permission to generate taxonomy suggestions for this post.', 'ai' )
262 );
263 }
264
265 $post_type_obj = get_post_type_object( $post->post_type );
266 if ( ! $post_type_obj instanceof WP_Post_Type || empty( $post_type_obj->show_in_rest ) ) {
267 return false;
268 }
269 } elseif ( ! current_user_can( 'edit_posts' ) ) {
270 // Ensure the user has permission to edit posts in general.
271 return new WP_Error(
272 'insufficient_capabilities',
273 esc_html__( 'You do not have permission to generate taxonomy suggestions.', 'ai' )
274 );
275 }
276
277 return true;
278 }
279
280 /**
281 * Returns the meta of the ability.
282 *
283 * @since 0.7.0
284 *
285 * @return array<string, mixed> The meta of the ability.
286 */
287 protected function meta(): array {
288 return array(
289 'show_in_rest' => true,
290 );
291 }
292
293 /**
294 * Generates taxonomy term suggestions from the given content.
295 *
296 * The LLM generates suggestions based purely on content analysis
297 * and the currently assigned terms. Post-processing then matches
298 * suggestions against existing terms and applies the strategy.
299 *
300 * @since 0.7.0
301 *
302 * @param string|array<string, string> $context The context to generate suggestions from.
303 * @param string $taxonomy The taxonomy to suggest terms for.
304 * @param string $strategy The suggestion strategy.
305 * @param int $max_suggestions The maximum number of suggestions.
306 * @param array<string> $assigned_terms Terms already assigned to the post.
307 * @return array<array{term: string, confidence: float, is_new: bool, parent?: string}>|\WP_Error The generated suggestions, or a WP_Error if there was an error.
308 */
309 protected function generate_suggestions( $context, string $taxonomy, string $strategy, int $max_suggestions, array $assigned_terms = array() ) {
310 // Convert the context to a string if it's an array.
311 if ( is_array( $context ) ) {
312 $context = implode(
313 "\n",
314 array_map(
315 static function ( $key, $value ) {
316 return sprintf(
317 '%s: %s',
318 ucwords( str_replace( '_', ' ', $key ) ),
319 $value
320 );
321 },
322 array_keys( $context ),
323 $context
324 )
325 );
326 }
327
328 // When using existing_only strategy, send the top terms to the LLM
329 // so it can select from actual terms rather than guessing.
330 $available_terms = array();
331 if ( Content_Classification_Experiment::STRATEGY_EXISTING_ONLY === $strategy ) {
332 $available_terms = $this->get_top_terms( $taxonomy );
333 }
334
335 /**
336 * Filters the candidate pool of existing terms surfaced to the model as
337 * `<available-terms>`.
338 *
339 * Default behaviour: the top 100 terms ordered by usage count when the
340 * `existing_only` strategy is in use; an empty array otherwise. The
341 * system instruction treats this pool as candidates ("use only when
342 * they genuinely fit; relevance outweighs popularity") rather than as
343 * required choices, so the popularity ordering is not load-bearing for
344 * relevance — it's just the default. Sites can return a relevance-
345 * ranked pool (e.g. via embeddings) here without touching prompt code.
346 *
347 * Returning an empty array suppresses the `<available-terms>` block
348 * entirely, which lets the model rely purely on content + assigned
349 * terms.
350 *
351 * @since 1.3.0
352 *
353 * @param array<string> $available_terms The default candidate pool (term names).
354 * @param string $taxonomy The taxonomy slug being suggested for.
355 * @param string $strategy The suggestion strategy
356 * (`existing_only` or `allow_new`).
357 * @return array<string> The filtered candidate pool.
358 */
359 $available_terms = (array) apply_filters(
360 'wpai_content_classification_available_terms',
361 $available_terms,
362 $taxonomy,
363 $strategy
364 );
365
366 // Piece together the various prompt parts.
367 $prompt_parts = array();
368
369 $prompt_parts[] = $this->build_taxonomy_descriptor( $taxonomy );
370 $prompt_parts[] = '<content>' . $context . '</content>';
371
372 // If we have currently assigned terms, add them to the prompt to avoid redundant suggestions.
373 if ( ! empty( $assigned_terms ) ) {
374 $prompt_parts[] = '<assigned-terms>' . implode( ', ', $assigned_terms ) . '</assigned-terms>';
375 }
376
377 // Surface the candidate pool: the existing terms fetched for the
378 // existing_only strategy by default, or whatever the
379 // `wpai_content_classification_available_terms` filter injected.
380 if ( ! empty( $available_terms ) ) {
381 $prompt_parts[] = '<available-terms>' . implode( ', ', $available_terms ) . '</available-terms>';
382 }
383
384 $prompt = implode( "\n", $prompt_parts );
385
386 /**
387 * Filters the prompt string before it is sent to the AI model for taxonomy suggestion generation.
388 *
389 * Allows developers to modify, augment, or replace the prompt that the AI analyzes
390 * when generating taxonomy term suggestions.
391 *
392 * @since 0.7.0
393 *
394 * @param string $prompt The prompt string to be sent to the AI model.
395 * @param string|array<string, string> $context The context to generate suggestions from.
396 * @param string $taxonomy The taxonomy slug being suggested for (e.g., 'post_tag', 'category').
397 * @param array<string> $assigned_terms Terms already assigned to the post.
398 * @param array<string> $available_terms Available terms to suggest from.
399 */
400 $prompt = (string) apply_filters( 'wpai_content_classification_prompt', $prompt, $context, $taxonomy, $assigned_terms, $available_terms );
401
402 $prompt_builder = $this->get_prompt_builder( $prompt );
403
404 if ( is_wp_error( $prompt_builder ) ) {
405 return $prompt_builder;
406 }
407
408 // Generate the suggestions using the AI client with structured output.
409 $result = $prompt_builder->generate_text();
410
411 if ( is_wp_error( $result ) ) {
412 return $result;
413 }
414
415 // Parse, match against existing terms, filter, and limit.
416 $suggestions = $this->parse_suggestions( $result, $strategy, $assigned_terms, $taxonomy, $max_suggestions );
417
418 if ( is_wp_error( $suggestions ) ) {
419 return $suggestions;
420 }
421
422 /**
423 * Filters the parsed taxonomy suggestions before they are returned to the client.
424 *
425 * Allows developers to modify, reorder, add, or remove suggestions after the AI
426 * has generated them and they have been parsed into structured data.
427 *
428 * Each suggestion is an associative array with the keys:
429 * - 'term' (string) The suggested term name.
430 * - 'confidence' (float) Confidence score between 0 and 1.
431 * - 'is_new' (bool) Whether the term is new or already exists on the site.
432 * - 'parent' (string) Optional. Parent term name for hierarchical taxonomies.
433 *
434 * @since 0.7.0
435 *
436 * @param array<array{term: string, confidence: float, is_new: bool, parent?: string}> $suggestions The parsed suggestions.
437 * @param string $taxonomy The taxonomy slug (e.g., 'post_tag', 'category').
438 * @param string $strategy The suggestion strategy ('existing_only' or 'allow_new').
439 */
440 return (array) apply_filters( 'wpai_content_classification_suggestions', $suggestions, $taxonomy, $strategy );
441 }
442
443 /**
444 * Builds the `<taxonomy …/>` descriptor block sent to the model.
445 *
446 * Surfaces the human label, the description, whether the taxonomy is
447 * hierarchical, and a coarse `kind` (`category` vs `tag`) so the
448 * model can reason about intent (broad/thematic vs specific) rather
449 * than guessing from the raw slug. The system instruction branches
450 * on `kind`.
451 *
452 * @since 1.3.0
453 *
454 * @param string $taxonomy The taxonomy slug.
455 * @return string The descriptor block (e.g.
456 * `<taxonomy name="category" label="Categories" kind="category" hierarchical="true">…description…</taxonomy>`),
457 * or an empty string when the taxonomy does not exist.
458 */
459 private function build_taxonomy_descriptor( string $taxonomy ): string {
460 $taxonomy = sanitize_key( $taxonomy );
461
462 if ( '' === $taxonomy || ! taxonomy_exists( $taxonomy ) ) {
463 return '';
464 }
465
466 $tax_object = get_taxonomy( $taxonomy );
467
468 if ( ! $tax_object instanceof WP_Taxonomy ) {
469 return '';
470 }
471
472 $is_hierarchical = is_taxonomy_hierarchical( $taxonomy );
473 $kind = $is_hierarchical ? 'category' : 'tag';
474
475 $label = ! empty( $tax_object->labels->name )
476 ? wp_strip_all_tags( (string) $tax_object->labels->name )
477 : $taxonomy;
478
479 $description = trim( wp_strip_all_tags( (string) $tax_object->description ) );
480
481 // esc_attr() guards the attribute values so a label containing quotes or
482 // angle brackets can't break the pseudo-XML the model reads.
483 return sprintf(
484 '<taxonomy name="%1$s" label="%2$s" kind="%3$s" hierarchical="%4$s">%5$s</taxonomy>',
485 esc_attr( $taxonomy ),
486 esc_attr( $label ),
487 esc_attr( $kind ),
488 $is_hierarchical ? 'true' : 'false',
489 $description
490 );
491 }
492
493 /**
494 * Get the prompt builder for generating taxonomy term suggestions.
495 *
496 * @since 0.7.0
497 *
498 * @param string $prompt The prompt to use for generating taxonomy term suggestions.
499 * @return \WP_AI_Client_Prompt_Builder|\WP_Error The prompt builder, or a WP_Error on failure.
500 */
501 private function get_prompt_builder( string $prompt ) {
502 $prompt_builder = wp_ai_client_prompt( $prompt )
503 ->using_system_instruction( $this->get_system_instruction() )
504 ->as_json_response( $this->suggestions_schema() );
505
506 $prompt_builder = $this->filter_prompt_builder( $prompt_builder, Content_Classification_Experiment::class, array(), $prompt );
507
508 return $this->ensure_text_generation_supported(
509 $prompt_builder,
510 esc_html__( 'Term generation failed. Please ensure you have a connected provider that supports text generation.', 'ai' )
511 );
512 }
513
514 /**
515 * Returns the JSON schema for structured output from the AI model.
516 *
517 * @since 0.7.0
518 *
519 * @return array<string, mixed> The JSON schema for structured output.
520 */
521 protected function suggestions_schema(): array {
522 return array(
523 'type' => 'object',
524 'properties' => array(
525 'suggestions' => array(
526 'type' => 'array',
527 'items' => array(
528 'type' => 'object',
529 'properties' => array(
530 'term' => array( 'type' => 'string' ),
531 'confidence' => array( 'type' => 'number' ),
532 ),
533 'required' => array( 'term', 'confidence' ),
534 'additionalProperties' => false,
535 ),
536 ),
537 ),
538 'required' => array( 'suggestions' ),
539 'additionalProperties' => false,
540 );
541 }
542
543 /**
544 * Parses the AI response into structured suggestions.
545 *
546 * Matches LLM suggestions against existing terms (case-insensitive),
547 * filters out assigned terms, applies the strategy, sorts by confidence,
548 * and limits to the requested number of suggestions.
549 *
550 * @since 0.7.0
551 *
552 * @param string $response The raw AI response.
553 * @param string $strategy The suggestion strategy ('existing_only' or 'allow_new').
554 * @param array<string> $assigned_terms Terms already assigned to the post.
555 * @param string $taxonomy The taxonomy to suggest terms for.
556 * @param int $max_suggestions The maximum number of suggestions to return.
557 * @return array<array{term: string, confidence: float, is_new: bool, parent?: string}>|\WP_Error Parsed suggestions or error.
558 */
559 private function parse_suggestions( string $response, string $strategy, array $assigned_terms, string $taxonomy, int $max_suggestions ) {
560 $decoded = json_decode( $response, true );
561
562 if ( ! is_array( $decoded ) || ! isset( $decoded['suggestions'] ) || ! is_array( $decoded['suggestions'] ) ) {
563 return new WP_Error(
564 'invalid_response',
565 esc_html__( 'Could not parse AI response as valid suggestions.', 'ai' )
566 );
567 }
568
569 // Only fetch existing terms when we need them for post-processing (existing_only strategy).
570 $existing_terms = Content_Classification_Experiment::STRATEGY_EXISTING_ONLY === $strategy
571 ? $this->get_existing_terms( $taxonomy )
572 : array();
573
574 // Build a lowercase → original name lookup for existing terms.
575 // We don't use slugs here because the LLM may generate terms that don't match the taxonomy slug.
576 if ( ! empty( $existing_terms ) ) {
577 $existing_terms = array_combine( array_map( 'strtolower', $existing_terms ), $existing_terms );
578 }
579
580 /**
581 * Filters the minimum confidence threshold for a suggestion to be
582 * returned. Suggestions with confidence below this value are dropped
583 * before sorting and limiting to `max_suggestions`.
584 *
585 * @since 1.3.0
586 *
587 * @param float $min_confidence The minimum confidence (0.0–1.0).
588 * @param string $taxonomy The taxonomy slug being suggested for.
589 * @param string $strategy The suggestion strategy.
590 * @return float The filtered minimum confidence.
591 */
592 $min_confidence = (float) apply_filters(
593 'wpai_content_classification_min_confidence',
594 self::MIN_CONFIDENCE,
595 $taxonomy,
596 $strategy
597 );
598 // Clamp to the valid range so a stray filter return can't disable the floor entirely.
599 $min_confidence = max( 0.0, min( 1.0, $min_confidence ) );
600
601 // Build a lowercase set of assigned terms for filtering.
602 $assigned_terms = array_map( 'strtolower', $assigned_terms );
603 $suggestions = array();
604 foreach ( $decoded['suggestions'] as $item ) {
605 if ( ! is_array( $item ) || empty( $item['term'] ) ) {
606 continue;
607 }
608
609 $term = sanitize_text_field( trim( $item['term'] ) );
610 $term_lower = strtolower( $term );
611 $is_new = ! isset( $existing_terms[ $term_lower ] );
612 $confidence = isset( $item['confidence'] ) ? (float) $item['confidence'] : 0.5;
613 // Clamp first so the floor compares against the effective confidence
614 // rather than malformed model output.
615 $confidence = max( 0.0, min( 1.0, $confidence ) );
616
617 // Drop suggestions below the relevance floor.
618 if ( $confidence < $min_confidence ) {
619 continue;
620 }
621
622 // Skip terms already assigned to the post.
623 // The agent should avoid suggesting these, but just in case we'll check here as well.
624 if ( in_array( $term_lower, $assigned_terms, true ) ) {
625 continue;
626 }
627
628 // For existing_only strategy, skip terms that don't exist.
629 if ( Content_Classification_Experiment::STRATEGY_EXISTING_ONLY === $strategy && $is_new ) {
630 continue;
631 }
632
633 // Use the original capitalized name for existing terms.
634 if ( ! $is_new ) {
635 $term = $existing_terms[ $term_lower ];
636 }
637
638 $suggestion = array(
639 'term' => $term,
640 'confidence' => $confidence,
641 'is_new' => $is_new,
642 );
643
644 // Only preserve parent for hierarchical taxonomies, and strip it
645 // when the AI returns the taxonomy slug itself as the parent.
646 if (
647 ! empty( $item['parent'] )
648 && is_taxonomy_hierarchical( $taxonomy )
649 && strtolower( trim( $item['parent'] ) ) !== strtolower( $taxonomy )
650 ) {
651 $suggestion['parent'] = sanitize_text_field( trim( $item['parent'] ) );
652 }
653
654 $suggestions[] = $suggestion;
655 }
656
657 // Sort by confidence descending.
658 usort(
659 $suggestions,
660 static function ( $a, $b ) {
661 return $b['confidence'] <=> $a['confidence'];
662 }
663 );
664
665 // Limit to max suggestions.
666 return array_slice( $suggestions, 0, $max_suggestions );
667 }
668
669 /**
670 * Gets existing terms for a taxonomy.
671 *
672 * @since 0.7.0
673 *
674 * @param string $taxonomy The taxonomy to get terms for.
675 * @return array<string> List of existing term names.
676 */
677 private function get_existing_terms( string $taxonomy ): array {
678 $terms = get_terms(
679 array(
680 'taxonomy' => $taxonomy,
681 'hide_empty' => false,
682 'fields' => 'names',
683 )
684 );
685
686 if ( is_wp_error( $terms ) ) {
687 return array();
688 }
689
690 return (array) $terms;
691 }
692
693 /**
694 * Gets the top terms for a taxonomy, ordered by usage count.
695 *
696 * Used to provide the LLM with a set of existing terms to select from
697 * when using the existing_only strategy, improving match quality.
698 *
699 * @since 0.7.0
700 *
701 * @param string $taxonomy The taxonomy to get terms for.
702 * @param int $limit Maximum number of terms to return.
703 * @return array<string> List of term names ordered by count descending.
704 */
705 private function get_top_terms( string $taxonomy, int $limit = 100 ): array {
706 /**
707 * Filters the maximum number of existing terms fetched for the
708 * candidate pool surfaced to the model under the `existing_only`
709 * strategy.
710 *
711 * The default of 100 suits most sites; large taxonomies may benefit
712 * from a higher cap, while smaller sites can lower it to reduce the
713 * prompt token count.
714 *
715 * @since 1.3.0
716 *
717 * @param int $limit The maximum number of terms to fetch.
718 * @param string $taxonomy The taxonomy slug being suggested for.
719 * @return int The filtered limit.
720 */
721 $limit = (int) apply_filters( 'wpai_content_classification_candidate_pool_size', $limit, $taxonomy );
722
723 // A non-positive limit would make get_terms() return everything;
724 // fall back to the default so the pool stays bounded.
725 if ( $limit < 1 ) {
726 $limit = 100;
727 }
728
729 $terms = get_terms(
730 array(
731 'taxonomy' => $taxonomy,
732 'hide_empty' => false,
733 'fields' => 'names',
734 'orderby' => 'count',
735 'order' => 'DESC',
736 'number' => $limit,
737 )
738 );
739
740 if ( is_wp_error( $terms ) ) {
741 return array();
742 }
743
744 return (array) $terms;
745 }
746 }
747