PluginProbe
wpForo Forum / 3.0.8
wpForo Forum v3.0.8
3.1.5 3.1.4 3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.1.1 1.1.2 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.10 1.4.11 1.4.12 1.4.13 1.4.2 All 137 releases
wpforo / classes / VectorStorageManager.php

VectorStorageManager.php in wpForo Forum 3.0.8, at classes/VectorStorageManager.php

3,316 lines 106.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace wpforo\classes;
4
5 // Exit if accessed directly
6 if ( ! defined( 'ABSPATH' ) ) exit;
7
8 /**
9 * Vector Storage Manager - Unified Abstraction Layer
10 *
11 * Central manager that routes all vector storage operations to the appropriate
12 * backend (Local WordPress DB or Cloud Storage) based on the current storage mode.
13 *
14 * All AI features that use indexed content should go through this abstraction:
15 * - Content indexing
16 * - Semantic search
17 * - Statistics and status
18 * - Similar content recommendations
19 *
20 * @since 3.0.0
21 */
22 class VectorStorageManager {
23
24 /**
25 * Storage mode constants
26 */
27 const MODE_LOCAL = 'local';
28 const MODE_CLOUD = 'cloud';
29
30 /**
31 * Local storage instance
32 *
33 * @var VectorStorageLocal|null
34 */
35 private $local_storage = null;
36
37 /**
38 * AI Client instance (for cloud operations)
39 *
40 * @var AIClient|null
41 */
42 private $ai_client = null;
43
44 /**
45 * Current board ID
46 *
47 * @var int
48 */
49 private $board_id = 0;
50
51 /**
52 * Cached storage mode
53 *
54 * @var string|null
55 */
56 private $storage_mode = null;
57
58 /**
59 * Singleton instance
60 *
61 * @var VectorStorageManager|null
62 */
63 private static $instance = null;
64
65 /**
66 * Get singleton instance
67 *
68 * @return VectorStorageManager
69 */
70 public static function instance() {
71 if ( self::$instance === null ) {
72 self::$instance = new self();
73 }
74 return self::$instance;
75 }
76
77 /**
78 * Constructor
79 */
80 public function __construct() {
81 $this->board_id = WPF()->board->get_current( 'boardid' );
82 $this->register_cron_hooks();
83
84 // Update board_id when WPF()->change_board() is called
85 add_action( 'wpforo_after_change_board', [ $this, 'on_board_change' ] );
86 }
87
88 /**
89 * Handle board change event
90 *
91 * Updates internal board_id to match the new board context.
92 * Called by wpforo_after_change_board hook.
93 *
94 * @return void
95 */
96 public function on_board_change() {
97 $this->board_id = WPF()->board->get_current( 'boardid' );
98 $this->storage_mode = null; // Reset cached storage mode
99 }
100
101 /**
102 * Register cron hooks for local storage maintenance
103 */
104 private function register_cron_hooks() {
105 // Register cleanup action - must be done here so callback exists when cron fires
106 add_action( 'wpforo_ai_cleanup_expired_cache', [ $this, 'cleanup_expired_cache' ] );
107
108 // Schedule if not already scheduled
109 if ( ! wp_next_scheduled( 'wpforo_ai_cleanup_expired_cache' ) ) {
110 wp_schedule_event( time(), 'hourly', 'wpforo_ai_cleanup_expired_cache' );
111 }
112 }
113
114 /**
115 * Cleanup expired cache entries (cron callback)
116 */
117 public function cleanup_expired_cache() {
118 if ( $this->is_local_mode() ) {
119 $local = $this->get_local_storage();
120 $local->cleanup_expired_cache();
121 }
122 }
123
124 /**
125 * Get the current storage mode for the board
126 *
127 * @param int|null $board_id Optional board ID (uses current if not specified)
128 * @return string 'local' or 'cloud'
129 */
130 public function get_storage_mode( $board_id = null ) {
131 if ( $board_id === null ) {
132 $board_id = $this->board_id;
133 }
134
135 // Return cached value if same board
136 if ( $this->storage_mode !== null && $board_id === $this->board_id ) {
137 return $this->storage_mode;
138 }
139
140 $this->storage_mode = get_option( 'wpforo_ai_storage_mode_' . $board_id, self::MODE_LOCAL );
141 return $this->storage_mode;
142 }
143
144 /**
145 * Check if using local storage mode
146 *
147 * @param int|null $board_id Optional board ID
148 * @return bool
149 */
150 public function is_local_mode( $board_id = null ) {
151 return $this->get_storage_mode( $board_id ) === self::MODE_LOCAL;
152 }
153
154 /**
155 * Check if using cloud storage mode
156 *
157 * @param int|null $board_id Optional board ID
158 * @return bool
159 */
160 public function is_cloud_mode( $board_id = null ) {
161 return $this->get_storage_mode( $board_id ) === self::MODE_CLOUD;
162 }
163
164 /**
165 * Check if any content has been indexed in the current storage mode
166 *
167 * Lightweight check using SELECT EXISTS — no API calls.
168 *
169 * @return bool
170 */
171 public function has_indexed_content() {
172 global $wpdb;
173
174 $column = $this->is_local_mode() ? 'local' : 'cloud';
175
176 return (bool) $wpdb->get_var(
177 "SELECT EXISTS( SELECT 1 FROM `" . WPF()->tables->topics . "` WHERE `{$column}` = 1 LIMIT 1 )"
178 );
179 }
180
181 /**
182 * Get local storage instance
183 *
184 * @return VectorStorageLocal
185 */
186 public function get_local_storage() {
187 if ( $this->local_storage === null ) {
188 $this->local_storage = new VectorStorageLocal();
189 }
190 return $this->local_storage;
191 }
192
193 /**
194 * Get AI client instance (for cloud operations)
195 *
196 * @return AIClient
197 */
198 public function get_ai_client() {
199 if ( $this->ai_client === null ) {
200 $this->ai_client = WPF()->ai_client;
201 }
202 return $this->ai_client;
203 }
204
205 /**
206 * Set the board context
207 *
208 * @param int $board_id Board ID
209 * @return $this
210 */
211 public function for_board( $board_id ) {
212 $this->board_id = (int) $board_id;
213 $this->storage_mode = null; // Reset cache
214 return $this;
215 }
216
217 /**
218 * Reset cached storage mode
219 *
220 * Call this after changing the storage mode option to ensure
221 * subsequent calls use the new mode.
222 *
223 * @return void
224 */
225 public function reset_storage_mode_cache() {
226 $this->storage_mode = null;
227 }
228
229 /**
230 * Clear indexing statistics caches
231 *
232 * Call this after batch indexing completes, after clearing embeddings,
233 * or after any bulk operation that changes indexed topic counts.
234 * Individual topic indexing does NOT need to call this - the 5-minute
235 * TTL handles gradual updates during indexing.
236 *
237 * @param int|null $board_id Specific board ID, or null for current board
238 * @return void
239 */
240 public function clear_indexing_stats_cache( $board_id = null ) {
241 $board_id = $board_id ?? $this->board_id;
242
243 // Clear cloud indexed count cache
244 delete_transient( 'wpforo_ai_cloud_indexed_count_' . $board_id );
245
246 // Clear storage recommendation cache
247 delete_transient( 'wpforo_ai_srec_' . $board_id . '_local' );
248 delete_transient( 'wpforo_ai_srec_' . $board_id . '_cloud' );
249
250 // Clear total topics count cache (from admin page, includes storage mode suffix)
251 delete_transient( 'wpforo_ai_ttc_' . $board_id . '_local' );
252 delete_transient( 'wpforo_ai_ttc_' . $board_id . '_cloud' );
253
254 // Clear forum topic counts cache (from admin page)
255 delete_transient( 'wpforo_ai_ftc_' . $board_id );
256 }
257
258 // =========================================================================
259 // STATISTICS & STATUS
260 // =========================================================================
261
262 /**
263 * Get indexing statistics
264 *
265 * Returns unified statistics regardless of storage mode.
266 *
267 * @return array {
268 * @type int $total_indexed Total number of indexed items
269 * @type int $total_topics Total topics with embeddings
270 * @type int $indexing_progress Progress percentage (0-100)
271 * @type bool $is_indexing Whether indexing is in progress
272 * @type string $last_indexed_at ISO 8601 timestamp of last index
273 * @type string $storage_mode Current storage mode
274 * @type string $storage_size Storage size (local only)
275 * }
276 */
277 public function get_indexing_stats() {
278 if ( $this->is_local_mode() ) {
279 return $this->get_local_stats();
280 } else {
281 return $this->get_cloud_stats();
282 }
283 }
284
285 /**
286 * Get local storage statistics
287 *
288 * @return array
289 */
290 private function get_local_stats() {
291 $local = $this->get_local_storage();
292 $stats = $local->get_stats();
293
294 // Only report is_indexing when a batch is actually being processed
295 // (lock transient is held), not when topics are merely queued.
296 $board_id = $this->board_id;
297 $is_actively_indexing = (bool) get_transient( 'wpforo_ai_indexing_lock_local_' . $board_id )
298 || (bool) get_transient( 'wpforo_ai_indexing_lock_' . $board_id );
299
300 return [
301 'total_indexed' => (int) ( $stats['total_embeddings'] ?? 0 ),
302 'total_topics' => (int) ( $stats['total_topics'] ?? 0 ),
303 'indexing_progress' => $this->calculate_local_indexing_progress( $board_id ),
304 'is_indexing' => $is_actively_indexing,
305 'last_indexed_at' => $stats['last_indexed_at'] ?? null,
306 'storage_mode' => self::MODE_LOCAL,
307 'storage_size' => $stats['storage_size_mb'] ?? '0',
308 'storage_size_mb' => $stats['storage_size_mb'] ?? '0',
309 ];
310 }
311
312 /**
313 * Calculate local indexing progress percentage.
314 *
315 * Reads the queue and settings options to calculate how many topics
316 * have been processed out of the total queued for indexing.
317 *
318 * @param int $board_id Board ID
319 * @return int Progress percentage (0-100), or 0 if no indexing in progress
320 */
321 private function calculate_local_indexing_progress( $board_id ) {
322 $settings_key = 'wpforo_ai_indexing_settings_' . $board_id;
323 $settings = get_option( $settings_key, [] );
324
325 // No settings = no indexing session started
326 $total = (int) ( $settings['total_topics'] ?? 0 );
327 if ( $total <= 0 ) {
328 return 0;
329 }
330
331 // Count remaining topics in queue
332 $queue_key = 'wpforo_ai_indexing_queue_' . $board_id;
333 $pending = get_option( $queue_key, [] );
334 $remaining = is_array( $pending ) ? count( $pending ) : 0;
335
336 // Calculate progress
337 $processed = $total - $remaining;
338 if ( $processed < 0 ) {
339 $processed = 0; // Safety: queue grew larger than original total
340 }
341
342 return (int) round( ( $processed / $total ) * 100 );
343 }
344
345 /**
346 * Get cloud storage statistics
347 *
348 * @return array
349 */
350 private function get_cloud_stats() {
351 global $wpdb;
352
353 $ai_client = $this->get_ai_client();
354 $rag_status = $ai_client->get_rag_status( $this->board_id );
355
356 if ( is_wp_error( $rag_status ) ) {
357 $rag_status = [];
358 }
359
360 // Only report is_indexing when the backend is actively processing.
361 // Queued WP Cron topics are reported separately via pending_cron_jobs.
362 $is_indexing = (bool) ( $rag_status['is_indexing'] ?? false );
363
364 // Use local cloud column for accurate count (reflects manual changes)
365 // Cache for 5 minutes to avoid heavy COUNT on large forums
366 $cache_key = 'wpforo_ai_cloud_indexed_count_' . $this->board_id;
367 $total_indexed = get_transient( $cache_key );
368 if ( false === $total_indexed ) {
369 $total_indexed = (int) $wpdb->get_var(
370 "SELECT COUNT(*) FROM `" . WPF()->tables->topics . "` WHERE `cloud` = 1"
371 );
372 set_transient( $cache_key, $total_indexed, 5 * MINUTE_IN_SECONDS );
373 }
374 $total_indexed = (int) $total_indexed;
375
376 return [
377 'total_indexed' => $total_indexed,
378 'total_topics' => $total_indexed,
379 'indexing_progress' => (int) ( $rag_status['indexing_progress'] ?? 0 ),
380 'is_indexing' => $is_indexing,
381 'last_indexed_at' => $rag_status['last_indexed_at'] ?? null,
382 'storage_mode' => self::MODE_CLOUD,
383 'storage_size' => null, // Cloud doesn't expose size
384 'storage_size_mb' => null,
385 // Async media (image/document) sub-progress from the backend.
386 // Present when the image_worker has queued work for this tenant.
387 // See GET /v1/rag/status and the admin UI sub-progress bar.
388 'media_progress' => $rag_status['media_progress'] ?? null,
389 ];
390 }
391
392 /**
393 * Get pending WP Cron jobs info
394 *
395 * @return array
396 */
397 public function get_pending_cron_jobs() {
398 $ai_client = $this->get_ai_client();
399 return $ai_client->get_pending_cron_jobs();
400 }
401
402 /**
403 * Get storage recommendation for current forum size
404 *
405 * @return array {
406 * @type string $status 'good', 'warning', or 'critical'
407 * @type string $message Human-readable recommendation
408 * @type string $icon Dashicon name
409 * }
410 */
411 public function get_storage_recommendation() {
412 // Cache the recommendation to avoid heavy COUNT(*) on posts table
413 // Board and storage mode specific to prevent stale data across switches
414 $cache_key = 'wpforo_ai_srec_' . $this->board_id . '_' . $this->get_storage_mode();
415 $cached = get_transient( $cache_key );
416 if ( false !== $cached && is_array( $cached ) ) {
417 return $cached;
418 }
419
420 $local = $this->get_local_storage();
421 $result = $local->get_storage_recommendation();
422 set_transient( $cache_key, $result, 10 * MINUTE_IN_SECONDS );
423 return $result;
424 }
425
426 /**
427 * Get indexed counts grouped by forum
428 *
429 * Cached for 5 minutes to avoid heavy GROUP BY queries on large forums.
430 *
431 * @return array Forum ID => count mapping
432 */
433 public function get_indexed_counts_by_forum() {
434 if ( $this->is_local_mode() ) {
435 return $this->get_local_indexed_counts_by_forum();
436 } else {
437 return $this->get_cloud_indexed_counts_by_forum();
438 }
439 }
440
441 /**
442 * Get local indexed counts by forum
443 *
444 * @return array
445 */
446 private function get_local_indexed_counts_by_forum() {
447 global $wpdb;
448
449 $results = $wpdb->get_results(
450 "SELECT forumid, COUNT(DISTINCT topicid) as count
451 FROM " . WPF()->tables->ai_embeddings . "
452 GROUP BY forumid",
453 ARRAY_A
454 );
455
456 $counts = [];
457 foreach ( $results as $row ) {
458 $counts[ (int) $row['forumid'] ] = (int) $row['count'];
459 }
460
461 return $counts;
462 }
463
464 /**
465 * Get cloud indexed counts by forum
466 *
467 * Uses the local `cloud` column from wpforo_topics table which is
468 * synced from the cloud API when switching storage modes.
469 *
470 * @return array
471 */
472 private function get_cloud_indexed_counts_by_forum() {
473 global $wpdb;
474
475 $results = $wpdb->get_results(
476 "SELECT forumid, COUNT(*) as count
477 FROM " . WPF()->tables->topics . "
478 WHERE `cloud` = 1
479 GROUP BY forumid",
480 ARRAY_A
481 );
482
483 $counts = [];
484 foreach ( $results as $row ) {
485 $counts[ (int) $row['forumid'] ] = (int) $row['count'];
486 }
487
488 return $counts;
489 }
490
491 // =========================================================================
492 // INDEXING OPERATIONS
493 // =========================================================================
494
495 /**
496 * Index a single topic with all its posts
497 *
498 * @param int $topicid Topic ID
499 * @param array $options Optional indexing options
500 * @return array|WP_Error Result or error
501 */
502 public function index_topic( $topicid, $options = [] ) {
503 if ( $this->is_local_mode() ) {
504 return $this->index_topic_local( $topicid, $options );
505 } else {
506 return $this->index_topic_cloud( $topicid, $options );
507 }
508 }
509
510 /**
511 * Index topic to local storage
512 *
513 * @param int $topicid Topic ID
514 * @param array $options Options
515 * @return array|WP_Error
516 */
517 private function index_topic_local( $topicid, $options = [] ) {
518 $topic = WPF()->topic->get_topic( $topicid );
519 if ( ! $topic ) {
520 return new \WP_Error( 'topic_not_found', wpforo_phrase( 'Topic not found', false ) );
521 }
522
523 // Skip private topics - they should never be indexed
524 if ( ! empty( $topic['private'] ) ) {
525 return new \WP_Error( 'private_topic', wpforo_phrase( 'Private topics cannot be indexed', false ) );
526 }
527
528 // Skip unapproved topics
529 if ( isset( $topic['status'] ) && (int) $topic['status'] !== 0 ) {
530 return new \WP_Error( 'unapproved_topic', wpforo_phrase( 'Unapproved topics cannot be indexed', false ) );
531 }
532
533 // Get all posts for this topic ordered by creation date
534 $posts = WPF()->post->get_posts( [
535 'topicid' => $topicid,
536 'orderby' => 'created',
537 'order' => 'ASC',
538 ] );
539 if ( empty( $posts ) ) {
540 return new \WP_Error( 'no_posts', wpforo_phrase( 'No posts found for topic', false ) );
541 }
542
543 // Pre-fetch forum name for enrichment tags (Phase 2.1)
544 // Added to $topic array so prepare_content_for_embedding() doesn't need wpforo_forum()
545 if ( ! empty( $topic['forumid'] ) && empty( $topic['forum_name'] ) ) {
546 $forum = wpforo_forum( $topic['forumid'] );
547 $topic['forum_name'] = is_array( $forum ) ? ( $forum['title'] ?? '' ) : '';
548 }
549
550 // Check if image/document indexing is enabled (Professional+ plans)
551 $ai_client = $this->get_ai_client();
552 $include_images = $ai_client->is_image_indexing_enabled();
553 $include_documents = $ai_client->is_document_indexing_enabled();
554 $images_processed = 0;
555 $documents_processed = 0;
556
557 $local = $this->get_local_storage();
558 $indexed_count = 0;
559 $noise_filtered_count = 0;
560 $errors = [];
561 $is_first = true;
562
563 foreach ( $posts as $post ) {
564 // Mark first post for special handling
565 $post['is_first_post'] = $is_first;
566 $current_is_first = $is_first;
567 $is_first = false;
568
569 // Extract images if image indexing is enabled
570 $images = [];
571 if ( $include_images && ! empty( $post['body'] ) ) {
572 $images = $ai_client->extract_post_images( $post['body'] );
573 }
574
575 // Extract documents if document indexing is enabled
576 $documents = [];
577 if ( $include_documents && ! empty( $post['body'] ) ) {
578 $documents = $ai_client->extract_post_documents( $post['body'] );
579 }
580
581 // Skip noise posts, but NEVER skip:
582 // - First post (topic body is always indexed)
583 // - Posts with images/documents that WILL be indexed (attachments have value even with short text)
584 // Note: $images/$documents are only populated if indexing is enabled, so this check is correct
585 $has_indexable_attachments = ! empty( $images ) || ! empty( $documents );
586 if ( ! $current_is_first && ! $has_indexable_attachments && $this->is_noise_post( $post['body'] ?? '' ) ) {
587 $noise_filtered_count++;
588 continue;
589 }
590
591 // Generate content for embedding
592 $content = $this->prepare_content_for_embedding( $post, $topic );
593
594 // Use content hash for deduplication
595 // Include image and document counts so re-index happens when attachments change
596 $hash_input = $content . '|images:' . count( $images ) . '|docs:' . count( $documents );
597 $content_hash = md5( $hash_input );
598
599 // Check if already indexed with same content
600 $existing = $local->get_embedding( $post['postid'] );
601 if ( $existing && $existing['content_hash'] === $content_hash ) {
602 continue; // Skip, already indexed
603 }
604
605 // Generate embedding via cloud API
606 // If images or documents provided, API will process them and return processed_content
607 if ( ! empty( $images ) || ! empty( $documents ) ) {
608 $embedding_result = $this->generate_embedding(
609 $content,
610 $images,
611 $topic['title'],
612 true, // Return full response to get processed_content
613 $documents
614 );
615
616 if ( is_wp_error( $embedding_result ) ) {
617 $errors[] = $embedding_result->get_error_message();
618 continue;
619 }
620
621 // Track image processing stats
622 if ( ! empty( $embedding_result['image_processing']['images_processed'] ) ) {
623 $images_processed += $embedding_result['image_processing']['images_processed'];
624 }
625
626 // Track document processing stats
627 if ( ! empty( $embedding_result['document_processing']['documents_processed'] ) ) {
628 $documents_processed += $embedding_result['document_processing']['documents_processed'];
629 }
630
631 $embedding = $embedding_result['embedding'];
632 // Build clean preview with document/image summary
633 $preview = $this->build_content_preview( $content, $embedding_result );
634 } else {
635 // No images or documents, simple embedding generation
636 $embedding = $this->generate_embedding( $content );
637 if ( is_wp_error( $embedding ) ) {
638 $errors[] = $embedding->get_error_message();
639 continue;
640 }
641 $preview = $this->build_content_preview( $content );
642 }
643
644 // Store locally
645 $result = $local->store_embedding(
646 $topicid,
647 $post['postid'],
648 $topic['forumid'],
649 $post['userid'],
650 $embedding,
651 $content_hash,
652 $preview
653 );
654
655 if ( $result ) {
656 $indexed_count++;
657 }
658 }
659
660 $response = [
661 'success' => true,
662 'indexed_count' => $indexed_count,
663 'noise_filtered_count' => $noise_filtered_count,
664 'total_posts' => count( $posts ),
665 'errors' => $errors,
666 ];
667
668 // Add image processing stats if images were processed
669 if ( $images_processed > 0 ) {
670 $response['images_processed'] = $images_processed;
671 }
672
673 // Add document processing stats if documents were processed
674 if ( $documents_processed > 0 ) {
675 $response['documents_processed'] = $documents_processed;
676 }
677
678 return $response;
679 }
680
681 /**
682 * Index topic to cloud storage
683 *
684 * @param int $topicid Topic ID
685 * @param array $options Options
686 * @return array|WP_Error
687 */
688 private function index_topic_cloud( $topicid, $options = [] ) {
689 $ai_client = $this->get_ai_client();
690
691 // Use existing cloud indexing flow via cron
692 $topic = WPF()->topic->get_topic( $topicid );
693 if ( ! $topic ) {
694 return new \WP_Error( 'topic_not_found', wpforo_phrase( 'Topic not found', false ) );
695 }
696
697 // Skip private topics - they should never be indexed
698 if ( ! empty( $topic['private'] ) ) {
699 return new \WP_Error( 'private_topic', wpforo_phrase( 'Private topics cannot be indexed', false ) );
700 }
701
702 // Skip unapproved topics
703 if ( isset( $topic['status'] ) && (int) $topic['status'] !== 0 ) {
704 return new \WP_Error( 'unapproved_topic', wpforo_phrase( 'Unapproved topics cannot be indexed', false ) );
705 }
706
707 // Queue for background processing (existing behavior)
708 $result = $ai_client->queue_topic_for_indexing( $topicid, $this->board_id );
709
710 return $result;
711 }
712
713 /**
714 * Index multiple topics using batch embedding API
715 *
716 * Efficient method that collects all posts from multiple topics,
717 * generates embeddings in a single API call, and stores locally.
718 * This matches the cloud indexing pattern for efficiency.
719 *
720 * @param array $topic_ids Array of topic IDs to index
721 * @param array $options Optional indexing options
722 * @return array Result with counts
723 */
724 public function index_topics_batch_local( $topic_ids, $options = [] ) {
725 if ( empty( $topic_ids ) ) {
726 return [
727 'success' => true,
728 'indexed_count' => 0,
729 'skipped_count' => 0,
730 'noise_filtered_count' => 0,
731 'total_posts' => 0,
732 'errors' => [],
733 ];
734 }
735
736 $local = $this->get_local_storage();
737 $ai_client = $this->get_ai_client();
738
739 // One-time hash migration (v1 → v2): updates existing hashes to new format
740 // that is independent of enrichment tag formatting. No API calls, no credit cost.
741 $this->maybe_migrate_content_hashes();
742
743 // Check if image/document indexing is enabled (Professional+ plans)
744 $include_images = $ai_client->is_image_indexing_enabled();
745 $include_documents = $ai_client->is_document_indexing_enabled();
746
747 // Collect all posts from all topics
748 $items_to_embed = []; // Items without images/documents (batch endpoint)
749 $items_with_images = []; // Items with images or documents (single endpoint)
750 $post_metadata = []; // Metadata for storing after embedding
751 $skipped_count = 0;
752 $noise_filtered_count = 0; // Posts filtered as noise (short, gratitude, etc.)
753 $topics_with_embeddings = []; // Topics that have at least one indexed post
754
755 // Batch-fetch all topics in one query to avoid N+1
756 $_items_count = 0;
757 $all_topics = WPF()->topic->get_topics( [ 'include' => $topic_ids, 'row_count' => count( $topic_ids ), 'access_filter' => false ], $_items_count, false );
758 $topics_map = [];
759 foreach ( $all_topics as $t ) {
760 $topics_map[ (int) $t['topicid'] ] = $t;
761 }
762
763 // Batch-fetch forum names for all unique forum IDs
764 $forum_ids = array_unique( array_filter( array_column( $all_topics, 'forumid' ) ) );
765 $forums_map = [];
766 foreach ( $forum_ids as $fid ) {
767 $forums_map[ (int) $fid ] = wpforo_forum( $fid );
768 }
769
770 foreach ( $topic_ids as $topicid ) {
771 $topic = isset( $topics_map[ (int) $topicid ] ) ? $topics_map[ (int) $topicid ] : null;
772 if ( ! $topic ) {
773 continue;
774 }
775
776 // Use pre-fetched forum name for enrichment tags
777 if ( ! empty( $topic['forumid'] ) && empty( $topic['forum_name'] ) ) {
778 $forum = isset( $forums_map[ (int) $topic['forumid'] ] ) ? $forums_map[ (int) $topic['forumid'] ] : null;
779 $topic['forum_name'] = is_array( $forum ) ? ( isset( $forum['title'] ) ? $forum['title'] : '' ) : '';
780 }
781
782 // Skip private topics - they should never be indexed
783 if ( ! empty( $topic['private'] ) ) {
784 $skipped_count++;
785 continue;
786 }
787
788 // Skip unapproved topics
789 if ( isset( $topic['status'] ) && (int) $topic['status'] !== 0 ) {
790 $skipped_count++;
791 continue;
792 }
793
794 // Bypass user permission check - this is admin-initiated backend indexing.
795 // Private/unapproved topics are already filtered above.
796 $posts = WPF()->post->get_posts( [
797 'topicid' => $topicid,
798 'orderby' => 'created',
799 'order' => 'ASC',
800 'check_private' => false,
801 ] );
802
803 if ( empty( $posts ) ) {
804 continue;
805 }
806
807 $is_first = true;
808 foreach ( $posts as $post ) {
809 $post['is_first_post'] = $is_first;
810 $current_is_first = $is_first;
811 $is_first = false;
812
813 // Extract images if image indexing is enabled
814 $images = [];
815 if ( $include_images && ! empty( $post['body'] ) ) {
816 $images = $ai_client->extract_post_images( $post['body'] );
817 }
818
819 // Extract documents if document indexing is enabled
820 $documents = [];
821 if ( $include_documents && ! empty( $post['body'] ) ) {
822 $documents = $ai_client->extract_post_documents( $post['body'] );
823 }
824
825 // Skip noise posts, but NEVER skip:
826 // - First post (topic body is always indexed)
827 // - Posts with images/documents that WILL be indexed (attachments have value even with short text)
828 // Note: Only exempt if indexing is enabled AND post actually has attachments
829 $has_indexable_attachments = ! empty( $images ) || ! empty( $documents );
830 if ( ! $current_is_first && ! $has_indexable_attachments && $this->is_noise_post( $post['body'] ?? '' ) ) {
831 $noise_filtered_count++;
832 continue;
833 }
834
835 // Prepare content for embedding
836 $content = $this->prepare_content_for_embedding( $post, $topic );
837 $post_id = $post['postid'];
838
839 // Stable hash from raw content — not from prepare_content_for_embedding() output.
840 // This ensures enrichment formatting changes don't invalidate all existing hashes.
841 $content_hash = $this->compute_content_hash( $post, $topic, count( $images ), count( $documents ) );
842
843 // Check if already indexed with same content (deduplication)
844 $existing = $local->get_embedding( $post_id );
845 if ( $existing && $existing['content_hash'] === $content_hash ) {
846 $skipped_count++;
847 // Track that this topic has at least one indexed post
848 $topics_with_embeddings[ $topicid ] = true;
849 continue;
850 }
851
852 // Store metadata for later
853 $item_id = 'post_' . $post_id;
854 $post_metadata[ $item_id ] = [
855 'topicid' => $topicid,
856 'postid' => $post_id,
857 'forumid' => $topic['forumid'],
858 'userid' => $post['userid'],
859 'content_hash' => $content_hash,
860 'preview' => $this->build_content_preview( $content ),
861 'topic_title' => $topic['title'],
862 ];
863
864 // Separate posts with images/documents from text-only posts
865 // Single endpoint handles image analysis and document processing
866 if ( ! empty( $images ) || ! empty( $documents ) ) {
867 $items_with_images[] = [
868 'id' => $item_id,
869 'content' => $content,
870 'images' => $images,
871 'documents' => $documents,
872 ];
873 } else {
874 $items_to_embed[] = [
875 'id' => $item_id,
876 'content' => $content,
877 ];
878 }
879 }
880 }
881
882 // If nothing to embed, update indexed hashes for topics with existing embeddings and return
883 if ( empty( $items_to_embed ) && empty( $items_with_images ) ) {
884 // Still update indexed hashes for topics that have existing embeddings
885 // This ensures stats show correct count even if re-indexing finds no changes
886 if ( ! empty( $topics_with_embeddings ) ) {
887 $this->update_topics_indexed_hash( array_keys( $topics_with_embeddings ) );
888 }
889
890 return [
891 'success' => true,
892 'indexed_count' => 0,
893 'skipped_count' => $skipped_count,
894 'noise_filtered_count' => $noise_filtered_count,
895 'total_posts' => $skipped_count + $noise_filtered_count,
896 'errors' => [],
897 'message' => sprintf( wpforo_phrase( 'All %d posts already indexed (unchanged).', false ), $skipped_count ),
898 ];
899 }
900
901 $all_results = [];
902 $errors = [];
903 $total_credits_used = 0;
904 $images_processed = 0;
905 $documents_processed = 0;
906
907 // =================================================================
908 // STEP 1: Process posts WITH images/documents via single endpoint
909 // Single endpoint supports image analysis and document processing
910 // =================================================================
911 if ( ! empty( $items_with_images ) ) {
912 \wpforo_ai_log( 'debug', sprintf(
913 'Processing %d posts with images/documents via single endpoint',
914 count( $items_with_images )
915 ), 'VectorStorage' );
916
917 foreach ( $items_with_images as $item ) {
918 $item_id = $item['id'];
919 $meta = $post_metadata[ $item_id ] ?? null;
920
921 if ( ! $meta ) {
922 continue;
923 }
924
925 // Generate embedding with images/documents via single endpoint
926 $embedding_result = $this->generate_embedding(
927 $item['content'],
928 $item['images'] ?? [],
929 $meta['topic_title'],
930 true, // Return full response to get processed_content
931 $item['documents'] ?? []
932 );
933
934 if ( is_wp_error( $embedding_result ) ) {
935 $errors[] = sprintf( 'Failed to embed %s: %s', $item_id, $embedding_result->get_error_message() );
936 continue;
937 }
938
939 // Track image processing stats
940 if ( ! empty( $embedding_result['image_processing']['images_processed'] ) ) {
941 $images_processed += $embedding_result['image_processing']['images_processed'];
942 }
943
944 // Track document processing stats
945 if ( ! empty( $embedding_result['document_processing']['documents_processed'] ) ) {
946 $documents_processed += $embedding_result['document_processing']['documents_processed'];
947 }
948
949 // Track credits from API response
950 if ( isset( $embedding_result['credits_used'] ) ) {
951 $total_credits_used += (int) $embedding_result['credits_used'];
952 }
953
954 // Build clean preview with document/image summary
955 $clean_preview = $this->build_content_preview( $item['content'], $embedding_result );
956
957 // Add to results in same format as batch endpoint
958 $all_results[] = [
959 'id' => $item_id,
960 'success' => true,
961 'embedding' => $embedding_result['embedding'],
962 'preview' => $clean_preview,
963 ];
964
965 // Update metadata preview with clean content
966 $post_metadata[ $item_id ]['preview'] = $clean_preview;
967 }
968 }
969
970 // =================================================================
971 // STEP 2: Process posts WITHOUT images via batch endpoint
972 // More efficient for text-only content
973 // =================================================================
974 if ( ! empty( $items_to_embed ) ) {
975 // Chunk items to max 100 per API call (API limit)
976 $item_chunks = array_chunk( $items_to_embed, 100, true );
977 $api_call_index = 0;
978
979 // Count unique topics that have text-only items (for credit calculation)
980 $text_only_topic_ids = array_unique( array_map( function( $item ) use ( $post_metadata ) {
981 return $post_metadata[ $item['id'] ]['topicid'] ?? 0;
982 }, $items_to_embed ) );
983
984 // Exclude topics that already had image posts processed (they were already charged)
985 $image_topic_ids = array_unique( array_map( function( $item ) use ( $post_metadata ) {
986 return $post_metadata[ $item['id'] ]['topicid'] ?? 0;
987 }, $items_with_images ) );
988 $text_only_topic_ids = array_diff( $text_only_topic_ids, $image_topic_ids );
989 $actual_new_topics_count = count( $text_only_topic_ids );
990
991 foreach ( $item_chunks as $chunk ) {
992 // For credit charging: only charge topic_count on first chunk to avoid double-charging
993 // Subsequent chunks are "free" since they're part of the same topic batch
994 $chunk_topic_count = ( $api_call_index === 0 ) ? $actual_new_topics_count : 0;
995
996 // DEBUG: Log what we're passing
997 \wpforo_ai_log( 'debug', sprintf(
998 'index_topics_batch_local chunk %d: batch_topics=%d, new_topics=%d, chunk_items=%d, chunk_topic_count=%d',
999 $api_call_index,
1000 count( $topic_ids ),
1001 $actual_new_topics_count,
1002 count( $chunk ),
1003 $chunk_topic_count
1004 ), 'VectorStorage' );
1005
1006 $response = $ai_client->generate_embeddings_batch( array_values( $chunk ), $chunk_topic_count );
1007
1008 if ( is_wp_error( $response ) ) {
1009 $errors[] = sprintf( 'Chunk %d failed: %s', $api_call_index + 1, $response->get_error_message() );
1010 } elseif ( ! empty( $response['results'] ) ) {
1011 $all_results = array_merge( $all_results, $response['results'] );
1012 // Accumulate credits from each successful API call
1013 $credits_from_api = (int) ( $response['credits_used'] ?? 0 );
1014 $total_credits_used += $credits_from_api;
1015
1016 \wpforo_ai_log( 'debug', sprintf(
1017 'API response chunk %d: credits_used=%d, successful=%d, failed=%d',
1018 $api_call_index,
1019 $credits_from_api,
1020 $response['successful_items'] ?? 0,
1021 $response['failed_items'] ?? 0
1022 ), 'VectorStorage' );
1023 }
1024
1025 $api_call_index++;
1026 }
1027 }
1028
1029 // If all processing failed, return error
1030 $total_items = count( $items_to_embed ) + count( $items_with_images );
1031 if ( empty( $all_results ) && ! empty( $errors ) ) {
1032 // Log error to AILogs database
1033 if ( isset( WPF()->ai_logs ) ) {
1034 $user_type = AILogs::USER_TYPE_USER;
1035 if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
1036 $user_type = AILogs::USER_TYPE_CRON;
1037 } elseif ( ! get_current_user_id() ) {
1038 $user_type = AILogs::USER_TYPE_SYSTEM;
1039 }
1040
1041 WPF()->ai_logs->log( [
1042 'action_type' => AILogs::ACTION_CONTENT_INDEXING,
1043 'user_type' => $user_type,
1044 'credits_used' => 0,
1045 'status' => AILogs::STATUS_ERROR,
1046 'content_type' => 'topic',
1047 'request_summary' => sprintf( 'Local indexing: %d topics', count( $topic_ids ) ),
1048 'error_message' => implode( '; ', array_slice( $errors, 0, 5 ) ), // First 5 errors
1049 'extra_data' => wp_json_encode( [
1050 'storage_mode' => 'local',
1051 'topic_ids' => array_slice( $topic_ids, 0, 20 ),
1052 'errors_count' => count( $errors ),
1053 ] ),
1054 ] );
1055 }
1056
1057 return [
1058 'success' => false,
1059 'indexed_count' => 0,
1060 'skipped_count' => $skipped_count,
1061 'noise_filtered_count' => $noise_filtered_count,
1062 'total_posts' => $total_items + $skipped_count + $noise_filtered_count,
1063 'errors' => $errors,
1064 ];
1065 }
1066
1067 // Store all embeddings locally
1068 $indexed_count = 0;
1069
1070 // Track which topics had posts successfully indexed
1071 $topics_indexed = [];
1072
1073 foreach ( $all_results as $result ) {
1074 $item_id = $result['id'];
1075
1076 if ( ! $result['success'] || empty( $result['embedding'] ) ) {
1077 $errors[] = sprintf( 'Failed to embed %s: %s', $item_id, $result['error'] ?? 'Unknown error' );
1078 continue;
1079 }
1080
1081 $meta = $post_metadata[ $item_id ] ?? null;
1082 if ( ! $meta ) {
1083 continue;
1084 }
1085
1086 $stored = $local->store_embedding(
1087 $meta['topicid'],
1088 $meta['postid'],
1089 $meta['forumid'],
1090 $meta['userid'],
1091 $result['embedding'],
1092 $meta['content_hash'],
1093 $meta['preview']
1094 );
1095
1096 if ( $stored ) {
1097 $indexed_count++;
1098 // Track this topic as having indexed posts
1099 $topics_indexed[ $meta['topicid'] ] = true;
1100 }
1101 }
1102
1103 // Update indexed hash in wpforo_topics table for all topics with indexed content
1104 // This includes both newly indexed topics AND topics with existing embeddings (skipped)
1105 // Needed for:
1106 // 1. Statistics to show correct indexed topic count
1107 // 2. Summarization feature to know which topics have indexed content
1108 // 3. Deduplication - to skip unchanged topics on re-indexing
1109 $all_topics_with_content = array_keys( $topics_indexed + $topics_with_embeddings );
1110 if ( ! empty( $all_topics_with_content ) ) {
1111 $this->update_topics_indexed_hash( $all_topics_with_content );
1112 }
1113
1114 $response = [
1115 'success' => true,
1116 'indexed_count' => $indexed_count,
1117 'skipped_count' => $skipped_count,
1118 'noise_filtered_count' => $noise_filtered_count,
1119 'total_posts' => $total_items + $skipped_count + $noise_filtered_count,
1120 'credits_used' => $total_credits_used > 0 ? $total_credits_used : count( $topic_ids ),
1121 'errors' => $errors,
1122 ];
1123
1124 // Add image processing stats if images were processed
1125 if ( $images_processed > 0 ) {
1126 $response['images_processed'] = $images_processed;
1127 }
1128
1129 // Add document processing stats if documents were processed
1130 if ( $documents_processed > 0 ) {
1131 $response['documents_processed'] = $documents_processed;
1132 }
1133
1134 // Log to AILogs database for tracking
1135 if ( $indexed_count > 0 && isset( WPF()->ai_logs ) ) {
1136 $user_type = AILogs::USER_TYPE_USER;
1137 if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
1138 $user_type = AILogs::USER_TYPE_CRON;
1139 } elseif ( ! get_current_user_id() ) {
1140 $user_type = AILogs::USER_TYPE_SYSTEM;
1141 }
1142
1143 WPF()->ai_logs->log( [
1144 'action_type' => AILogs::ACTION_CONTENT_INDEXING,
1145 'user_type' => $user_type,
1146 'credits_used' => $response['credits_used'] ?? 0,
1147 'status' => AILogs::STATUS_SUCCESS,
1148 'content_type' => 'topic',
1149 'request_summary' => sprintf( 'Local indexing: %d topics', count( $topic_ids ) ),
1150 'response_summary' => sprintf(
1151 'Indexed %d posts from %d topics (%d skipped)',
1152 $indexed_count,
1153 count( $all_topics_with_content ),
1154 $skipped_count
1155 ),
1156 'extra_data' => wp_json_encode( [
1157 'storage_mode' => 'local',
1158 'topic_ids' => array_slice( $topic_ids, 0, 20 ), // Limit to first 20 for log size
1159 'topics_indexed' => count( $all_topics_with_content ),
1160 'posts_indexed' => $indexed_count,
1161 'skipped' => $skipped_count,
1162 'errors_count' => count( $errors ),
1163 ] ),
1164 ] );
1165 }
1166
1167 return $response;
1168 }
1169
1170 /**
1171 * Prepare content for embedding
1172 *
1173 * Combines post content with relevant metadata for better semantic matching.
1174 *
1175 * @param array $post Post data
1176 * @param array $topic Topic data
1177 * @return string Prepared content
1178 */
1179 /**
1180 * Build a clean content preview for search result display
1181 *
1182 * Strips Lambda processing markers (--- Document Content ---, [DOCUMENT: ...], [IMAGE: ...])
1183 * and appends a human-friendly document/image summary.
1184 *
1185 * @param string $content Original post content (before Lambda processing)
1186 * @param array|null $embedding_result Full Lambda response (with document_processing/image_processing stats)
1187 * @param int $max_length Maximum preview length
1188 * @return string Clean preview text
1189 */
1190 private function build_content_preview( $content, $embedding_result = null, $max_length = 1000 ) {
1191 // Start with clean post text (no Lambda markers or shortcodes)
1192 $preview = strip_tags( $content );
1193 // Strip "Topic: Title" prefix — embedding content includes topic title for better
1194 // vector similarity, but previews should show only the post body text.
1195 $preview = preg_replace( '/^Topic:\s*[^\n]*\n*/i', '', $preview );
1196 // Strip trailing "Topic: Title" (first posts repeat title at end for embedding weight)
1197 $preview = preg_replace( '/\n*\s*Topic:\s*[^\n]*$/i', '', $preview );
1198 // Strip enrichment tags added for embedding quality: [FORUM: name], [SOLVED], [BEST ANSWER]
1199 $preview = preg_replace( '/\[(?:FORUM|SOLVED|BEST ANSWER)[^\]]*\]/', '', $preview );
1200 // Strip wpForo shortcodes: [attach]N[/attach], [attach]N,M[/attach]
1201 $preview = preg_replace( '/\[attach\]\d+(?:,\d+)?\[\/attach\]/', '', $preview );
1202 // Strip any remaining shortcode-like patterns
1203 $preview = preg_replace( '/\[(?:\/)?[a-zA-Z0-9_-]+(?:\s[^\]]*?)?\]/', '', $preview );
1204 $preview = preg_replace( '/\s+/', ' ', $preview );
1205 $preview = trim( $preview );
1206
1207 // Build document/image summary suffix
1208 $attachments = [];
1209
1210 if ( ! empty( $embedding_result['document_processing']['documents_processed'] ) ) {
1211 $doc_count = (int) $embedding_result['document_processing']['documents_processed'];
1212 $page_count = (int) ( $embedding_result['document_processing']['total_pages'] ?? 0 );
1213 $total_chunks = (int) ( $embedding_result['total_chunks'] ?? 1 );
1214
1215 if ( $page_count > 0 ) {
1216 // If content was split into multiple chunks, we only store chunk 0
1217 // so not all pages are fully represented — show "X+ pages"
1218 if ( $total_chunks > 1 ) {
1219 $attachments[] = sprintf( '%d %s, %d+ %s',
1220 $doc_count,
1221 $doc_count === 1 ? 'document' : 'documents',
1222 $page_count,
1223 $page_count === 1 ? 'page' : 'pages'
1224 );
1225 } else {
1226 $attachments[] = sprintf( '%d %s, %d %s',
1227 $doc_count,
1228 $doc_count === 1 ? 'document' : 'documents',
1229 $page_count,
1230 $page_count === 1 ? 'page' : 'pages'
1231 );
1232 }
1233 } else {
1234 $attachments[] = sprintf( '%d %s',
1235 $doc_count,
1236 $doc_count === 1 ? 'document' : 'documents'
1237 );
1238 }
1239 }
1240
1241 if ( ! empty( $embedding_result['image_processing']['images_processed'] ) ) {
1242 $img_count = (int) $embedding_result['image_processing']['images_processed'];
1243 $attachments[] = sprintf( '%d %s',
1244 $img_count,
1245 $img_count === 1 ? 'image' : 'images'
1246 );
1247 }
1248
1249 // Append attachment summary
1250 if ( ! empty( $attachments ) ) {
1251 $suffix = ' [+ ' . implode( ', ', $attachments ) . ']';
1252 $preview = mb_substr( $preview, 0, $max_length - mb_strlen( $suffix ) ) . $suffix;
1253 } else {
1254 $preview = mb_substr( $preview, 0, $max_length );
1255 }
1256
1257 return $preview;
1258 }
1259
1260 /**
1261 * Noise filtering patterns - matches cloud mode (rag_ingestion/handler.py)
1262 * These patterns identify non-informative content that shouldn't be indexed.
1263 */
1264 private static $gratitude_patterns = [
1265 '/^thanks?\.?!?$/i',
1266 '/^thank you\.?!?$/i',
1267 '/^thx\.?!?$/i',
1268 '/^ty\.?!?$/i',
1269 '/^thanks?,?\s*(a lot|so much|for .{0,30})\.?!?$/i',
1270 '/^(great|awesome|perfect|helpful|nice|good),?\s*thanks?\.?!?$/i',
1271 '/^thanks?,?\s*(it works|that worked|that fixed it|that helped)\.?!?$/i',
1272 ];
1273
1274 private static $agreement_patterns = [
1275 '/^i agree\.?!?$/i',
1276 '/^same here\.?!?$/i',
1277 '/^me too\.?!?$/i',
1278 '/^same (problem|issue)\.?!?$/i',
1279 '/^\+1\.?$/i',
1280 '/^this\.?!?$/i',
1281 '/^exactly\.?!?$/i',
1282 '/^(yes|no|correct|right|true|indeed|absolutely|definitely)\.?!?$/i',
1283 ];
1284
1285 private static $bump_patterns = [
1286 '/^bump\.?!?$/i',
1287 '/^following\.?$/i',
1288 '/^subscribing\.?$/i',
1289 '/^watching\.?$/i',
1290 '/^any ?one\??$/i',
1291 '/^any updates?\??$/i',
1292 '/^hello\??$/i',
1293 '/^still waiting\.?$/i',
1294 '/^\?\?\?+$/i',
1295 ];
1296
1297 private static $noise_phrases = [ 'ok', 'okay', 'k', 'up' ];
1298
1299 /** Minimum character count for indexing */
1300 const MIN_CHAR_COUNT = 20;
1301
1302 /** Minimum word count for indexing */
1303 const MIN_WORD_COUNT = 5;
1304
1305 /** Cached locale check result for performance */
1306 private static $is_english_locale = null;
1307
1308 /**
1309 * Check if the current locale is English-like.
1310 * Cached for performance since it doesn't change during a request.
1311 *
1312 * @return bool True if locale is English (en, en_US, en_GB, etc.)
1313 */
1314 private function is_english_locale() {
1315 if ( self::$is_english_locale === null ) {
1316 $locale = get_locale();
1317 // Match en, en_US, en_GB, en_AU, etc.
1318 self::$is_english_locale = ( $locale === 'en' || strpos( $locale, 'en_' ) === 0 );
1319 }
1320 return self::$is_english_locale;
1321 }
1322
1323 /**
1324 * Check if a post is noise content that shouldn't be indexed.
1325 *
1326 * Filters out non-informative content to improve search quality:
1327 * - Length < 20 characters (all languages)
1328 * - Word count < 5 words (all languages)
1329 * - Only emojis (no alphanumeric text) (all languages)
1330 * - Gratitude-only posts ("thanks", etc.) (English only)
1331 * - Agreement-only posts ("+1", "I agree", etc.) (English only)
1332 * - Bump/follow posts ("bump", "following", etc.) (English only)
1333 * - Common noise phrases ("ok", etc.) (English only)
1334 *
1335 * Pattern matching is only applied for English locales to avoid
1336 * wasting resources on non-English forums where patterns won't match.
1337 *
1338 * Matches cloud mode behavior (rag_ingestion/handler.py:is_noise_reply).
1339 *
1340 * @param string $body Post body content (HTML allowed, will be stripped)
1341 * @return bool True if post is noise and should be skipped, false if it should be indexed
1342 */
1343 public function is_noise_post( $body ) {
1344 // Strip HTML and normalize whitespace
1345 $cleaned = wp_strip_all_tags( $body );
1346 $cleaned = preg_replace( '/\s+/', ' ', $cleaned );
1347 $cleaned = trim( $cleaned );
1348
1349 // Rule 1: Minimum character count (language-agnostic)
1350 if ( mb_strlen( $cleaned ) < self::MIN_CHAR_COUNT ) {
1351 return true;
1352 }
1353
1354 // Rule 2: Minimum word count (language-agnostic)
1355 $words = preg_split( '/\s+/', $cleaned, -1, PREG_SPLIT_NO_EMPTY );
1356 if ( count( $words ) < self::MIN_WORD_COUNT ) {
1357 return true;
1358 }
1359
1360 // Rule 3: Check if only emojis/symbols (no alphanumeric chars) (language-agnostic)
1361 if ( ! preg_match( '/[a-zA-Z0-9]/', $cleaned ) ) {
1362 return true;
1363 }
1364
1365 // Pattern matching only for English locales - skip for other languages
1366 // to avoid wasting CPU cycles on patterns that won't match
1367 if ( ! $this->is_english_locale() ) {
1368 return false;
1369 }
1370
1371 // Prepare for pattern matching (English only from here)
1372 $lower_text = mb_strtolower( $cleaned );
1373
1374 // Rule 4: Gratitude-only detection
1375 foreach ( self::$gratitude_patterns as $pattern ) {
1376 if ( preg_match( $pattern, $lower_text ) ) {
1377 return true;
1378 }
1379 }
1380
1381 // Rule 5: Agreement-only detection
1382 foreach ( self::$agreement_patterns as $pattern ) {
1383 if ( preg_match( $pattern, $lower_text ) ) {
1384 return true;
1385 }
1386 }
1387
1388 // Rule 6: Bump/follow detection
1389 foreach ( self::$bump_patterns as $pattern ) {
1390 if ( preg_match( $pattern, $lower_text ) ) {
1391 return true;
1392 }
1393 }
1394
1395 // Rule 7: Legacy noise phrases (exact match)
1396 if ( in_array( $lower_text, self::$noise_phrases, true ) ) {
1397 return true;
1398 }
1399
1400 // Passed all filters - keep this post
1401 return false;
1402 }
1403
1404 /**
1405 * Compute a stable content hash for embedding deduplication.
1406 *
1407 * Uses raw content inputs (body text, topic title, attachment counts) rather than
1408 * the formatted output of prepare_content_for_embedding(). This ensures that changes
1409 * to embedding enrichment formatting (e.g., adding [FORUM:], [SOLVED], [BEST ANSWER]
1410 * tags or topic title prefixes) don't invalidate all existing hashes and trigger
1411 * unnecessary re-indexing that wastes credits.
1412 *
1413 * @param array $post Post data with 'body' key
1414 * @param array $topic Topic data with 'title' key
1415 * @param int $images_count Number of images in the post
1416 * @param int $docs_count Number of documents in the post
1417 * @return string MD5 hash
1418 */
1419 private function compute_content_hash( $post, $topic, $images_count = 0, $docs_count = 0 ) {
1420 $body = wp_strip_all_tags( strip_shortcodes( $post['body'] ?? '' ) );
1421 $body = preg_replace( '/\[(?:\/)?[a-zA-Z0-9_-]+(?:\s[^\]]*?)?\]/', '', $body );
1422 $body = html_entity_decode( $body, ENT_QUOTES, 'UTF-8' );
1423 $body = preg_replace( '/\s+/', ' ', trim( $body ) );
1424
1425 $hash_input = ( $topic['title'] ?? '' ) . '|' . $body . '|images:' . $images_count . '|docs:' . $docs_count;
1426
1427 return md5( $hash_input );
1428 }
1429
1430 /**
1431 * Migrate content hashes from v1 (full prepared content) to v2 (raw inputs only).
1432 *
1433 * Runs once automatically on the first indexing call after the update.
1434 * Updates existing hashes in the DB without re-generating embeddings,
1435 * so there is no API call cost or credit usage.
1436 */
1437 public function maybe_migrate_content_hashes() {
1438 if ( (int) get_option( 'wpforo_ai_hash_version', 0 ) >= 2 ) {
1439 return;
1440 }
1441
1442 global $wpdb;
1443
1444 $embeddings_table = WPF()->tables->ai_embeddings;
1445 $posts_table = WPF()->tables->posts;
1446 $topics_table = WPF()->tables->topics;
1447
1448 $batch_size = 500;
1449 $offset = 0;
1450 $updated = 0;
1451
1452 do {
1453 $rows = $wpdb->get_results( $wpdb->prepare(
1454 "SELECT e.id, p.body, t.title
1455 FROM {$embeddings_table} e
1456 INNER JOIN {$posts_table} p ON e.postid = p.postid
1457 INNER JOIN {$topics_table} t ON p.topicid = t.topicid
1458 WHERE e.content_type = 'forum'
1459 LIMIT %d OFFSET %d",
1460 $batch_size,
1461 $offset
1462 ), ARRAY_A );
1463
1464 if ( empty( $rows ) ) {
1465 break;
1466 }
1467
1468 foreach ( $rows as $row ) {
1469 $body = wp_strip_all_tags( strip_shortcodes( $row['body'] ?? '' ) );
1470 $body = preg_replace( '/\[(?:\/)?[a-zA-Z0-9_-]+(?:\s[^\]]*?)?\]/', '', $body );
1471 $body = html_entity_decode( $body, ENT_QUOTES, 'UTF-8' );
1472 $body = preg_replace( '/\s+/', ' ', trim( $body ) );
1473
1474 // Use 0 for image/doc counts — posts with attachments will be
1475 // re-indexed once when their actual counts are computed.
1476 $hash_input = ( $row['title'] ?? '' ) . '|' . $body . '|images:0|docs:0';
1477 $new_hash = md5( $hash_input );
1478
1479 $wpdb->update(
1480 $embeddings_table,
1481 [ 'content_hash' => $new_hash ],
1482 [ 'id' => $row['id'] ],
1483 [ '%s' ],
1484 [ '%d' ]
1485 );
1486 $updated++;
1487 }
1488
1489 $offset += $batch_size;
1490 } while ( count( $rows ) === $batch_size );
1491
1492 update_option( 'wpforo_ai_hash_version', 2, false );
1493 }
1494
1495 public function prepare_content_for_embedding( $post, $topic ) {
1496 // Max content length for embedding API (Titan Embed v2 handles ~32K chars / 8K tokens)
1497 $max_content_length = 45000;
1498
1499 $parts = [];
1500
1501 // Add topic title for context on ALL posts (not just first post).
1502 // This matches cloud mode behavior where every chunk gets [TOPIC: title] prefix.
1503 // Without this, reply embeddings lack topic context and can't match topic-specific
1504 // search terms (e.g., searching "postgres" won't find replies in a PostgreSQL topic).
1505 // The title is stripped from content_preview by build_content_preview() so it
1506 // won't appear in search result excerpts.
1507 if ( ! empty( $topic['title'] ) ) {
1508 $parts[] = 'Topic: ' . $topic['title'];
1509 }
1510
1511 // Contextual enrichment tags (Phase 2.1) — matches cloud mode chunk prefixes.
1512 // These tags improve embedding quality by adding forum, solved, and best answer context.
1513 // They are stripped from display by build_content_preview() via generic shortcode pattern.
1514 if ( ! empty( $topic['forum_name'] ) ) {
1515 $parts[] = '[FORUM: ' . $topic['forum_name'] . ']';
1516 }
1517 if ( ! empty( $topic['solved'] ) ) {
1518 $parts[] = '[SOLVED]';
1519 }
1520 if ( ! empty( $post['is_answer'] ) ) {
1521 $parts[] = '[BEST ANSWER]';
1522 }
1523
1524 // Add post content (cleaned)
1525 $content = $post['body'] ?? '';
1526 // Strip shortcodes (both WordPress and wpForo shortcodes like [attach]ID[/attach])
1527 $content = strip_shortcodes( $content );
1528 $content = preg_replace( '/\[(?:\/)?[a-zA-Z0-9_-]+(?:\s[^\]]*?)?\]/', '', $content );
1529 $content = wp_strip_all_tags( $content );
1530 $content = html_entity_decode( $content, ENT_QUOTES, 'UTF-8' );
1531 $content = preg_replace( '/\s+/', ' ', $content );
1532 $parts[] = trim( $content );
1533
1534 // Add tags if available (first post only)
1535 if ( isset( $post['is_first_post'] ) && $post['is_first_post'] && ! empty( $topic['tags'] ) ) {
1536 $tags = is_array( $topic['tags'] ) ? implode( ', ', $topic['tags'] ) : $topic['tags'];
1537 $parts[] = 'Tags: ' . $tags;
1538 }
1539
1540 // Repeat title at the end to increase its weight in the embedding.
1541 // This improves title-based search (topic suggestions) where users type
1542 // a short title and need to match against long content embeddings.
1543 if ( isset( $post['is_first_post'] ) && $post['is_first_post'] && ! empty( $topic['title'] ) ) {
1544 $parts[] = 'Topic: ' . $topic['title'];
1545 }
1546
1547 $result = implode( "\n\n", array_filter( $parts ) );
1548
1549 // Truncate to max length if needed (embedding model and API limits)
1550 if ( mb_strlen( $result ) > $max_content_length ) {
1551 $result = mb_substr( $result, 0, $max_content_length );
1552 }
1553
1554 return $result;
1555 }
1556
1557 /**
1558 * Update indexed hash for topics after successful local indexing
1559 *
1560 * The indexed hash is an MD5 of "topicid_postcount" which changes when posts are added/removed.
1561 * This enables:
1562 * 1. Statistics to show correct indexed topic count
1563 * 2. Summarization feature to use indexed content
1564 * 3. Deduplication - skip unchanged topics on re-indexing
1565 *
1566 * @param array $topic_ids Array of topic IDs that were indexed
1567 * @return int Number of topics updated
1568 */
1569 private function update_topics_indexed_hash( $topic_ids ) {
1570 if ( empty( $topic_ids ) ) {
1571 return 0;
1572 }
1573
1574 global $wpdb;
1575
1576 // Build CASE statement for indexed hash updates
1577 // Hash is MD5 of "topicid_postcount"
1578 $topic_ids = array_map( 'intval', $topic_ids );
1579 $ids_list = implode( ',', $topic_ids );
1580
1581 // Update indexed hash and local column in a single query
1582 // The hash is calculated as MD5(topicid + '_' + posts)
1583 $updated = $wpdb->query(
1584 "UPDATE `" . WPF()->tables->topics . "`
1585 SET `indexed` = MD5(CONCAT(topicid, '_', posts)),
1586 `local` = 1
1587 WHERE topicid IN ($ids_list)"
1588 );
1589
1590 if ( $updated > 0 ) {
1591 // Clear topic cache to reflect indexed status
1592 wpforo_clean_cache( 'topic' );
1593 }
1594
1595 return (int) $updated;
1596 }
1597
1598 /**
1599 * Generate embedding vector for content
1600 *
1601 * Uses the cloud API to generate embeddings.
1602 * This is used for both local and cloud storage modes.
1603 *
1604 * Supports multimodal image indexing (Professional+ plans):
1605 * - Pass images array with URLs from site domain
1606 * - Images are processed by vision models on the backend
1607 * - Returns full response including processed_content if images were processed
1608 *
1609 * Supports document indexing (Professional+ plans):
1610 * - Pass documents array with URLs from site domain
1611 * - Documents are processed (text extraction, OCR) on the backend
1612 *
1613 * @param string $content Content to embed
1614 * @param array $images Optional. Array of image data for multimodal indexing
1615 * @param string $topic_context Optional. Topic title for better image/document descriptions
1616 * @param bool $full_response Optional. Return full response instead of just embedding
1617 * @param array $documents Optional. Array of document data for document indexing
1618 * @return array|WP_Error Vector array (default) or full response array if $full_response=true
1619 */
1620 public function generate_embedding( $content, $images = [], $topic_context = '', $full_response = false, $documents = [] ) {
1621 $ai_client = $this->get_ai_client();
1622
1623 // Call cloud API to generate embedding (with optional images and documents)
1624 $result = $ai_client->generate_embedding( $content, $images, $topic_context, $documents );
1625
1626 if ( is_wp_error( $result ) ) {
1627 return $result;
1628 }
1629
1630 if ( ! isset( $result['embedding'] ) || ! is_array( $result['embedding'] ) ) {
1631 return new \WP_Error( 'invalid_embedding', wpforo_phrase( 'Invalid embedding response from API', false ) );
1632 }
1633
1634 // Return full response if requested (for image processing info)
1635 if ( $full_response ) {
1636 return $result;
1637 }
1638
1639 return $result['embedding'];
1640 }
1641
1642 /**
1643 * Delete embeddings for a topic
1644 *
1645 * @param int $topicid Topic ID
1646 * @return bool|WP_Error
1647 */
1648 public function delete_topic_embeddings( $topicid ) {
1649 if ( $this->is_local_mode() ) {
1650 $local = $this->get_local_storage();
1651 return $local->delete_topic_embeddings( $topicid );
1652 } else {
1653 $ai_client = $this->get_ai_client();
1654 return $ai_client->delete_topic_from_index( $topicid, $this->board_id );
1655 }
1656 }
1657
1658 /**
1659 * Delete embedding for a single post
1660 *
1661 * @param int $postid Post ID
1662 * @return bool|WP_Error
1663 */
1664 public function delete_post_embedding( $postid ) {
1665 if ( $this->is_local_mode() ) {
1666 $local = $this->get_local_storage();
1667 return $local->delete_embedding( $postid );
1668 } else {
1669 $ai_client = $this->get_ai_client();
1670 return $ai_client->delete_post_from_index( $postid, $this->board_id );
1671 }
1672 }
1673
1674 /**
1675 * Clear all embeddings for current board
1676 *
1677 * @return bool|WP_Error
1678 */
1679 public function clear_all_embeddings() {
1680 if ( $this->is_local_mode() ) {
1681 global $wpdb;
1682 $wpdb->query( "TRUNCATE TABLE " . WPF()->tables->ai_embeddings );
1683 $wpdb->query( "TRUNCATE TABLE " . WPF()->tables->ai_embeddings_cache );
1684 // Also clear indexed status and local column in topics table so topics can be re-indexed
1685 WPF()->db->query(
1686 "UPDATE `" . WPF()->tables->topics . "` SET `indexed` = NULL, `local` = 0 WHERE `indexed` IS NOT NULL OR `local` = 1"
1687 );
1688 wpforo_clean_cache( 'topic' );
1689 $this->clear_indexing_stats_cache();
1690 return true;
1691 } else {
1692 $ai_client = $this->get_ai_client();
1693 $result = $ai_client->clear_rag_database( $this->board_id );
1694 $this->clear_indexing_stats_cache();
1695 return $result;
1696 }
1697 }
1698
1699 /**
1700 * Clear embeddings for specific forums in current storage mode.
1701 *
1702 * @param array $forum_ids Array of forum IDs to clear
1703 * @return array|WP_Error Result with counts
1704 */
1705 public function clear_forum_embeddings( $forum_ids ) {
1706 $forum_ids = array_values( array_filter( array_map( 'intval', (array) $forum_ids ), function( $id ) { return $id > 0; } ) );
1707
1708 if ( empty( $forum_ids ) ) {
1709 return new \WP_Error( 'invalid_forums', __( 'No valid forum IDs provided.', 'wpforo' ) );
1710 }
1711
1712 if ( $this->is_local_mode() ) {
1713 return $this->clear_forum_embeddings_local( $forum_ids );
1714 } else {
1715 return $this->clear_forum_embeddings_cloud( $forum_ids );
1716 }
1717 }
1718
1719 /**
1720 * Clear forum embeddings in local storage mode.
1721 *
1722 * @param array $forum_ids Forum IDs to clear
1723 * @return array Result with counts
1724 */
1725 private function clear_forum_embeddings_local( $forum_ids ) {
1726 global $wpdb;
1727
1728 $placeholders = implode( ',', array_fill( 0, count( $forum_ids ), '%d' ) );
1729
1730 $deleted = $wpdb->query(
1731 $wpdb->prepare(
1732 "DELETE FROM `" . WPF()->tables->ai_embeddings . "` WHERE `forumid` IN ({$placeholders})",
1733 ...$forum_ids
1734 )
1735 );
1736
1737 $topics_cleared = $wpdb->query(
1738 $wpdb->prepare(
1739 "UPDATE `" . WPF()->tables->topics . "` SET `local` = 0 WHERE `forumid` IN ({$placeholders}) AND `local` = 1",
1740 ...$forum_ids
1741 )
1742 );
1743
1744 wpforo_clean_cache( 'topic' );
1745 $this->clear_indexing_stats_cache();
1746
1747 return [
1748 'deleted_embeddings' => (int) $deleted,
1749 'topics_cleared' => (int) $topics_cleared,
1750 'forums_cleared' => count( $forum_ids ),
1751 ];
1752 }
1753
1754 /**
1755 * Clear forum embeddings in cloud storage mode.
1756 *
1757 * @param array $forum_ids Forum IDs to clear
1758 * @return array|WP_Error Result with counts
1759 */
1760 private function clear_forum_embeddings_cloud( $forum_ids ) {
1761 global $wpdb;
1762
1763 $ai_client = $this->get_ai_client();
1764 $result = $ai_client->clear_forum_index( $forum_ids, $this->board_id );
1765
1766 if ( is_wp_error( $result ) ) {
1767 return $result;
1768 }
1769
1770 $placeholders = implode( ',', array_fill( 0, count( $forum_ids ), '%d' ) );
1771 $topics_cleared = $wpdb->query(
1772 $wpdb->prepare(
1773 "UPDATE `" . WPF()->tables->topics . "` SET `cloud` = 0 WHERE `forumid` IN ({$placeholders}) AND `cloud` != 0",
1774 ...$forum_ids
1775 )
1776 );
1777
1778 wpforo_clean_cache( 'topic' );
1779 $this->clear_indexing_stats_cache();
1780
1781 $result['topics_cleared'] = (int) $topics_cleared;
1782 return $result;
1783 }
1784
1785 /**
1786 * Ingest multiple topics
1787 *
1788 * For local mode, indexes topics directly.
1789 * For cloud mode, sends to cloud API.
1790 *
1791 * @param array $topic_ids Array of topic IDs
1792 * @param int $chunk_size Chunk size (used for cloud mode)
1793 * @param int $overlap_percent Overlap percentage (used for cloud mode)
1794 * @return array|WP_Error Result array or error
1795 */
1796 public function ingest_topics( $topic_ids, $chunk_size = 512, $overlap_percent = 20 ) {
1797 if ( $this->is_local_mode() ) {
1798 return $this->ingest_topics_local( $topic_ids );
1799 } else {
1800 return $this->ingest_topics_cloud( $topic_ids, $chunk_size, $overlap_percent );
1801 }
1802 }
1803
1804 /**
1805 * Ingest topics locally via WP Cron batches
1806 *
1807 * Uses batch embedding API for efficiency - multiple topics processed
1808 * in a single API call, matching cloud indexing pattern.
1809 *
1810 * @param array $topic_ids Topic IDs to index
1811 * @return array Result with success status and scheduled counts
1812 */
1813 private function ingest_topics_local( $topic_ids ) {
1814 $total_topics = count( $topic_ids );
1815
1816 if ( empty( $topic_ids ) ) {
1817 return [
1818 'success' => true,
1819 'topics_queued' => 0,
1820 'message' => wpforo_phrase( 'No topics to index.', false ),
1821 ];
1822 }
1823
1824 // Check available credits before starting
1825 $ai_client = $this->get_ai_client();
1826 $status = $ai_client->get_tenant_status( true ); // Force fresh status
1827 if ( is_wp_error( $status ) ) {
1828 return $status;
1829 }
1830
1831 $credits_available = isset( $status['subscription']['credits_remaining'] )
1832 ? (int) $status['subscription']['credits_remaining']
1833 : 0;
1834
1835 if ( $credits_available <= 0 ) {
1836 return new \WP_Error(
1837 'no_credits',
1838 wpforo_phrase( 'No credits available for indexing. Please wait for your monthly credit reset or upgrade your plan.', false )
1839 );
1840 }
1841
1842 // Use self-rescheduling queue pattern:
1843 // - Store all topic IDs in a queue (option)
1844 // - Schedule ONE cron job
1845 // - Job processes a batch, then reschedules itself if more remain
1846 // This avoids overwhelming WP Cron with hundreds of jobs
1847 $queue_key = 'wpforo_ai_indexing_queue_' . $this->board_id;
1848
1849 // Get existing queue and merge (in case of concurrent requests)
1850 $existing_queue = get_option( $queue_key, [] );
1851 $merged_queue = array_unique( array_merge( $existing_queue, $topic_ids ) );
1852 update_option( $queue_key, $merged_queue, false ); // No autoload
1853
1854 // Schedule ONE job to start processing (if not already scheduled)
1855 $cron_hook = 'wpforo_ai_process_queue';
1856 $cron_args = [ $this->board_id ];
1857
1858 if ( ! wp_next_scheduled( $cron_hook, $cron_args ) ) {
1859 wp_schedule_single_event( time() + 5, $cron_hook, $cron_args );
1860 }
1861
1862 return [
1863 'success' => true,
1864 'threads_indexed' => 0, // Will be processed async
1865 'posts_indexed' => 0,
1866 'posts_unchanged' => 0,
1867 'errors' => [],
1868 'credits_consumed' => 0, // Credits consumed during cron processing
1869 'topics_queued' => $total_topics,
1870 'batches_queued' => 1, // Always just 1 job that self-reschedules
1871 'message' => sprintf(
1872 wpforo_phrase( 'Indexing queued! %d topics will be processed in batches. Processing starts in 5 seconds.', false ),
1873 $total_topics
1874 ),
1875 'stats' => [
1876 'deduplication' => [
1877 'posts_unchanged' => 0,
1878 ],
1879 ],
1880 ];
1881 }
1882
1883 /**
1884 * Ingest topics to cloud storage via option-backed self-rescheduling queue
1885 *
1886 * Writes topic IDs to a single DynamoDB-style queue option (autoload=false)
1887 * and schedules ONE WP Cron event that drains the queue in batches,
1888 * rescheduling itself until empty.
1889 *
1890 * This replaces the previous "sync first batch + N scheduled events" pattern,
1891 * which caused O(n²) autoloaded `cron` option rewrites and OOM on large forums.
1892 * It mirrors the pattern already used by ingest_topics_local() and
1893 * queue_topic_for_auto_indexing(), so the existing UI (progress polling,
1894 * stop button, cleanup session) works unchanged — the queue key and cron
1895 * hook are already handled by get_pending_cron_jobs() and
1896 * clear_pending_cron_jobs() in AIClient.
1897 *
1898 * Credits are enforced per-batch by the backend; the cron handler
1899 * (VectorStorageManager::cron_process_queue_mode) detects 402 responses
1900 * and stops rescheduling automatically.
1901 *
1902 * @param array $topic_ids Array of topic IDs
1903 * @param int $chunk_size Chunk size for text splitting in tokens (unused here — read from options by cron handler)
1904 * @param int $overlap_percent Overlap percentage for chunking (unused here — read from options by cron handler)
1905 * @return array|WP_Error Result array or error
1906 */
1907 private function ingest_topics_cloud( $topic_ids, $chunk_size = 512, $overlap_percent = 20 ) {
1908 $total_topics = count( $topic_ids );
1909
1910 if ( empty( $topic_ids ) ) {
1911 return [
1912 'success' => true,
1913 'topics_queued' => 0,
1914 'message' => wpforo_phrase( 'No topics to index.', false ),
1915 ];
1916 }
1917
1918 $ai_client = $this->get_ai_client();
1919
1920 // Check if database clearing is in progress
1921 if ( $ai_client->is_clearing_in_progress() ) {
1922 $remaining = $ai_client->get_clearing_time_remaining();
1923 $minutes = ceil( $remaining / 60 );
1924 return new \WP_Error(
1925 'clearing_in_progress',
1926 sprintf(
1927 wpforo_phrase( 'Database clearing is in progress. Please wait approximately %d minute(s) before starting new indexing.', false ),
1928 $minutes
1929 )
1930 );
1931 }
1932
1933 // Check available credits before starting
1934 $status = $ai_client->get_tenant_status( true );
1935 if ( is_wp_error( $status ) ) {
1936 return $status;
1937 }
1938
1939 $credits_available = isset( $status['subscription']['credits_remaining'] )
1940 ? (int) $status['subscription']['credits_remaining']
1941 : 0;
1942
1943 if ( $credits_available <= 0 ) {
1944 return new \WP_Error(
1945 'no_credits',
1946 wpforo_phrase( 'No credits available for indexing. Please wait for your monthly credit reset or upgrade your plan.', false )
1947 );
1948 }
1949
1950 // Limit topics to available credits (worker will also stop on 402 as a safety net)
1951 $topics_limited = false;
1952 $skipped_topics = 0;
1953 if ( $total_topics > $credits_available ) {
1954 $topic_ids = array_slice( $topic_ids, 0, $credits_available );
1955 $topics_limited = true;
1956 $skipped_topics = $total_topics - $credits_available;
1957 $total_topics = count( $topic_ids );
1958 }
1959
1960 // Normalize to integers to keep the queue array small and consistent with
1961 // queue_topic_for_auto_indexing() which stores ints.
1962 $topic_ids = array_values( array_unique( array_map( 'intval', $topic_ids ) ) );
1963
1964 // Write to option-backed queue (autoload=false). Merge with any existing
1965 // queue so concurrent ingest calls don't clobber each other and auto-
1966 // indexing entries are preserved.
1967 $queue_key = 'wpforo_ai_indexing_queue_cloud_' . $this->board_id;
1968 $existing_queue = get_option( $queue_key, [] );
1969 if ( ! is_array( $existing_queue ) ) {
1970 $existing_queue = [];
1971 }
1972 $merged_queue = array_values( array_unique( array_merge( $existing_queue, $topic_ids ) ) );
1973 update_option( $queue_key, $merged_queue, false );
1974
1975 // Schedule ONE self-rescheduling cron event. The handler
1976 // (cron_process_queue_mode in this class, wired via AIClient::cron_process_queue_cloud)
1977 // will drain the queue in pagination_size batches and reschedule itself
1978 // until empty. If an event is already scheduled, we don't schedule a second —
1979 // the running worker will simply pick up the new entries on its next batch.
1980 $cron_hook = 'wpforo_ai_process_queue_cloud';
1981 $cron_args = [ $this->board_id ];
1982 if ( ! wp_next_scheduled( $cron_hook, $cron_args ) ) {
1983 wp_schedule_single_event( time() + 5, $cron_hook, $cron_args );
1984 }
1985
1986 // Clear cached status so the UI picks up "indexing in progress" immediately
1987 $ai_client->clear_rag_status_cache();
1988
1989 if ( $topics_limited ) {
1990 $message = sprintf(
1991 wpforo_phrase( 'Indexing queued: %1$d topics (limited by %2$d available credits). %3$d topics skipped. Processing starts in 5 seconds and continues in the background.', false ),
1992 $total_topics,
1993 $credits_available,
1994 $skipped_topics
1995 );
1996 } else {
1997 $message = sprintf(
1998 wpforo_phrase( 'Indexing queued: %d topics. Processing starts in 5 seconds and continues in the background.', false ),
1999 $total_topics
2000 );
2001 }
2002
2003 return [
2004 'success' => true,
2005 'message' => $message,
2006 'topics_queued' => $total_topics,
2007 'topics_limited' => $topics_limited,
2008 'skipped_topics' => $skipped_topics,
2009 'queue_size' => count( $merged_queue ),
2010 ];
2011 }
2012
2013 /**
2014 * Queue a single topic for auto-indexing
2015 *
2016 * Used for automatic indexing when:
2017 * - A new approved topic is created
2018 * - An unapproved topic is approved
2019 *
2020 * Adds the topic to the existing queue and schedules the cron processor
2021 * if not already scheduled. This is a lightweight operation.
2022 *
2023 * @param int $topicid Topic ID to queue
2024 * @return bool True if queued successfully
2025 */
2026 public function queue_topic_for_auto_indexing( $topicid ) {
2027 $topicid = (int) $topicid;
2028
2029 // Check if auto-indexing is enabled for this board
2030 $auto_indexing_enabled = (bool) wpforo_get_option( 'ai_auto_indexing_enabled', 0 );
2031 if ( ! $auto_indexing_enabled ) {
2032 return false;
2033 }
2034
2035 // Check if AI service is available
2036 $ai_client = $this->get_ai_client();
2037 if ( ! $ai_client->is_service_available() ) {
2038 return false;
2039 }
2040
2041 // Verify topic exists and is approved (status = 0) and not private
2042 $topic = WPF()->topic->get_topic( $topicid );
2043 if ( ! $topic ) {
2044 return false;
2045 }
2046
2047 // Only index approved (status=0), non-private topics
2048 if ( intval( wpfval( $topic, 'status' ) ) !== 0 || intval( wpfval( $topic, 'private' ) ) === 1 ) {
2049 return false;
2050 }
2051
2052 // Use mode-specific queue key to ensure topics are indexed in the correct mode
2053 // This prevents cloud topics from being processed by local indexing and vice versa
2054 $storage_mode = $this->get_storage_mode();
2055 $queue_key = 'wpforo_ai_indexing_queue_' . $storage_mode . '_' . $this->board_id;
2056
2057 // Get existing queue and add this topic (avoid duplicates)
2058 // Cast to int to prevent type mismatches — wpfval() returns strings,
2059 // but get_pending_topics_for_indexing() stores integers via array_map('intval')
2060 $existing_queue = get_option( $queue_key, [] );
2061 if ( ! in_array( $topicid, $existing_queue ) ) {
2062 $existing_queue[] = $topicid;
2063 update_option( $queue_key, $existing_queue, false ); // No autoload
2064 }
2065
2066 // Schedule the mode-specific cron processor if not already scheduled
2067 $cron_hook = 'wpforo_ai_process_queue_' . $storage_mode;
2068 $cron_args = [ $this->board_id ];
2069
2070 if ( ! wp_next_scheduled( $cron_hook, $cron_args ) ) {
2071 // Cloud: 24 hours, Local: 1 hour — batches replies to save credits
2072 $default_delay = ( $storage_mode === 'cloud' ) ? 86400 : 3600;
2073
2074 /**
2075 * Filters the auto-indexing delay in seconds before the queued topics are processed.
2076 *
2077 * Cloud mode defaults to 86400 (24 hours), local mode defaults to 3600 (1 hour).
2078 *
2079 * @param int $delay Delay in seconds.
2080 * @param string $storage_mode Storage mode ('local' or 'cloud').
2081 * @param int $board_id Board ID.
2082 */
2083 $delay = (int) apply_filters( 'wpforo_ai_auto_indexing_delay', $default_delay, $storage_mode, $this->board_id );
2084
2085 wp_schedule_single_event( time() + max( 1, $delay ), $cron_hook, $cron_args );
2086 }
2087
2088 $this->log_info( 'topic_queued_for_auto_indexing', [
2089 'topicid' => $topicid,
2090 'queue_size' => count( $existing_queue ),
2091 'storage_mode' => $storage_mode,
2092 'queue_key' => $queue_key,
2093 ] );
2094
2095 return true;
2096 }
2097
2098 /**
2099 * Get new or recently modified topics that need indexing
2100 *
2101 * Only returns topics that are newer (by modified date) than the last
2102 * indexed topic in the current storage mode. This ensures the daily cron
2103 * only indexes genuinely new or updated topics, not old unindexed ones
2104 * from forums the admin chose not to index.
2105 *
2106 * @param int $limit Maximum number of topics to return (default 100)
2107 * @return array Array of topic IDs
2108 */
2109 public function get_pending_topics_for_indexing( $limit = 100 ) {
2110 global $wpdb;
2111
2112 $column = $this->is_local_mode() ? 'local' : 'cloud';
2113 $table = WPF()->tables->topics;
2114
2115 // Find the modified date of the most recently indexed topic
2116 $last_indexed_modified = $wpdb->get_var(
2117 "SELECT `modified` FROM `{$table}` WHERE `{$column}` = 1 ORDER BY `modified` DESC LIMIT 1"
2118 );
2119
2120 if ( $last_indexed_modified ) {
2121 // Select unindexed topics modified after the last indexed topic
2122 $topic_ids = $wpdb->get_col(
2123 $wpdb->prepare(
2124 "SELECT topicid FROM `{$table}`
2125 WHERE `{$column}` = 0
2126 AND `modified` > %s
2127 AND `status` = 0
2128 AND `private` = 0
2129 ORDER BY `modified` ASC
2130 LIMIT %d",
2131 $last_indexed_modified,
2132 $limit
2133 )
2134 );
2135 } else {
2136 // No indexed topics exist yet — only pick up topics from today forward
2137 $topic_ids = $wpdb->get_col(
2138 $wpdb->prepare(
2139 "SELECT topicid FROM `{$table}`
2140 WHERE `{$column}` = 0
2141 AND `modified` >= CURDATE()
2142 AND `status` = 0
2143 AND `private` = 0
2144 ORDER BY `modified` ASC
2145 LIMIT %d",
2146 $limit
2147 )
2148 );
2149 }
2150
2151 return array_map( 'intval', $topic_ids );
2152 }
2153
2154 /**
2155 * Process pending topics (daily cron job handler)
2156 *
2157 * Finds topics with local=0 or cloud=0 (based on storage mode)
2158 * and queues them for indexing using mode-specific queue.
2159 *
2160 * @return array Result with counts
2161 */
2162 public function cron_process_pending_topics() {
2163 // Check if auto-indexing is enabled for this board
2164 $auto_indexing_enabled = (bool) wpforo_get_option( 'ai_auto_indexing_enabled', 0 );
2165 if ( ! $auto_indexing_enabled ) {
2166 return [
2167 'success' => false,
2168 'message' => 'Auto-indexing is disabled',
2169 'topics_queued' => 0,
2170 ];
2171 }
2172
2173 // Check if AI service is available
2174 $ai_client = $this->get_ai_client();
2175 if ( ! $ai_client->is_service_available() ) {
2176 return [
2177 'success' => false,
2178 'message' => 'AI service not available',
2179 'topics_queued' => 0,
2180 ];
2181 }
2182
2183 // Get current storage mode - this determines which column to check
2184 $storage_mode = $this->get_storage_mode();
2185
2186 // Get pending topics (limit to 500 per day to avoid overloading)
2187 // This checks local=0 for local mode, cloud=0 for cloud mode
2188 $pending_topics = $this->get_pending_topics_for_indexing( 500 );
2189
2190 if ( empty( $pending_topics ) ) {
2191 return [
2192 'success' => true,
2193 'message' => 'No pending topics found',
2194 'topics_queued' => 0,
2195 'storage_mode' => $storage_mode,
2196 ];
2197 }
2198
2199 // Use mode-specific queue key to ensure topics are indexed in the correct mode
2200 $queue_key = 'wpforo_ai_indexing_queue_' . $storage_mode . '_' . $this->board_id;
2201
2202 // Get existing queue and merge
2203 $existing_queue = get_option( $queue_key, [] );
2204 $merged_queue = array_unique( array_merge( $existing_queue, $pending_topics ) );
2205 update_option( $queue_key, $merged_queue, false );
2206
2207 // Schedule the mode-specific cron processor if not already scheduled
2208 $cron_hook = 'wpforo_ai_process_queue_' . $storage_mode;
2209 $cron_args = [ $this->board_id ];
2210
2211 if ( ! wp_next_scheduled( $cron_hook, $cron_args ) ) {
2212 wp_schedule_single_event( time() + 5, $cron_hook, $cron_args );
2213 }
2214
2215 $this->log_info( 'daily_pending_topics_queued', [
2216 'topics_found' => count( $pending_topics ),
2217 'queue_size' => count( $merged_queue ),
2218 'storage_mode' => $storage_mode,
2219 'queue_key' => $queue_key,
2220 ] );
2221
2222 return [
2223 'success' => true,
2224 'message' => sprintf( 'Queued %d pending topics for %s indexing', count( $pending_topics ), $storage_mode ),
2225 'topics_queued' => count( $pending_topics ),
2226 'storage_mode' => $storage_mode,
2227 ];
2228 }
2229
2230 /**
2231 * Process the indexing queue (called by WP Cron)
2232 *
2233 * Self-rescheduling pattern: processes one batch at a time,
2234 * then reschedules itself if more topics remain.
2235 *
2236 * @param int $board_id Board ID
2237 * @return void
2238 */
2239 public function cron_process_queue( $board_id = 0 ) {
2240 // Set the board context to ensure board-specific options are read correctly
2241 WPF()->change_board( $board_id );
2242
2243 $lock_key = 'wpforo_ai_indexing_lock_' . $board_id;
2244 $queue_key = 'wpforo_ai_indexing_queue_' . $board_id;
2245 $batch_size = (int) wpforo_get_option( 'ai_pagination_size', 20 ); // Topics per batch from settings
2246
2247 // Check if another process (AJAX or another cron) is running
2248 $existing_lock = get_transient( $lock_key );
2249 if ( $existing_lock ) {
2250 // Reschedule as backup
2251 $cron_hook = 'wpforo_ai_process_queue';
2252 $cron_args = [ $board_id ];
2253 if ( ! wp_next_scheduled( $cron_hook, $cron_args ) ) {
2254 wp_schedule_single_event( time() + 60, $cron_hook, $cron_args );
2255 }
2256 return;
2257 }
2258
2259 // Lock per batch — expires after 2 minutes in case of crash
2260 set_transient( $lock_key, 'cron_' . time(), 120 );
2261
2262 // Get pending topics from queue
2263 $pending_topics = get_option( $queue_key, [] );
2264
2265 if ( empty( $pending_topics ) ) {
2266 // Queue is empty, nothing to do
2267 delete_option( $queue_key );
2268 delete_transient( $lock_key );
2269 return;
2270 }
2271
2272 // Take the next batch
2273 $batch = array_slice( $pending_topics, 0, $batch_size );
2274 $remaining = array_slice( $pending_topics, $batch_size );
2275
2276 // Update queue with remaining topics BEFORE processing
2277 // This prevents re-processing if cron runs twice
2278 if ( ! empty( $remaining ) ) {
2279 update_option( $queue_key, $remaining, false );
2280 } else {
2281 delete_option( $queue_key );
2282 }
2283
2284 // Use the batch embedding method with saved settings
2285 $chunk_size = (int) wpforo_get_option( 'ai_chunk_size', 512 );
2286 $overlap_percent = (int) wpforo_get_option( 'ai_overlap_percent', 20 );
2287 $result = $this->index_topics_batch_local( $batch, [
2288 'chunk_size' => $chunk_size,
2289 'overlap_percent' => $overlap_percent,
2290 ] );
2291
2292 // Check for credit exhaustion - stop rescheduling if no credits
2293 $has_credit_error = false;
2294 $errors = is_array( $result ) ? ( $result['errors'] ?? [] ) : [];
2295 foreach ( $errors as $error ) {
2296 if ( stripos( $error, 'insufficient credits' ) !== false || stripos( $error, '402' ) !== false ) {
2297 $has_credit_error = true;
2298 break;
2299 }
2300 }
2301
2302 // Release lock after processing
2303 delete_transient( $lock_key );
2304
2305 // Clear indexed counts cache so UI shows updated progress after each batch
2306 $this->clear_indexing_stats_cache( $board_id );
2307
2308 if ( $has_credit_error ) {
2309 // Clear queue so page reload doesn't auto-resume indexing
2310 delete_option( $queue_key );
2311 delete_option( 'wpforo_ai_indexing_settings_' . $board_id );
2312 return;
2313 }
2314
2315 // If more topics remain, reschedule ourselves
2316 if ( ! empty( $remaining ) ) {
2317 $cron_hook = 'wpforo_ai_process_queue';
2318 $cron_args = [ $board_id ];
2319
2320 // Schedule next batch in 30 seconds
2321 if ( ! wp_next_scheduled( $cron_hook, $cron_args ) ) {
2322 wp_schedule_single_event( time() + 30, $cron_hook, $cron_args );
2323 }
2324 }
2325 }
2326
2327 /**
2328 * Process a mode-specific auto-indexing queue (called by WP Cron)
2329 *
2330 * This processes topics that were queued for a specific storage mode,
2331 * ensuring they are indexed with the correct method regardless of
2332 * what the current storage mode is set to.
2333 *
2334 * @param int $board_id Board ID
2335 * @param string $mode Storage mode ('local' or 'cloud')
2336 * @return void
2337 */
2338 public function cron_process_queue_mode( $board_id = 0, $mode = 'local' ) {
2339 // Set the board context to ensure board-specific options are read correctly
2340 WPF()->change_board( $board_id );
2341
2342 $lock_key = 'wpforo_ai_indexing_lock_' . $mode . '_' . $board_id;
2343 $queue_key = 'wpforo_ai_indexing_queue_' . $mode . '_' . $board_id;
2344 $batch_size = (int) wpforo_get_option( 'ai_pagination_size', 20 ); // Topics per batch from settings
2345
2346 // Check if already processing
2347 $existing_lock = get_transient( $lock_key );
2348 if ( $existing_lock ) {
2349 // Already processing - reschedule as backup
2350 $cron_hook = 'wpforo_ai_process_queue_' . $mode;
2351 $cron_args = [ $board_id ];
2352 if ( ! wp_next_scheduled( $cron_hook, $cron_args ) ) {
2353 wp_schedule_single_event( time() + 60, $cron_hook, $cron_args );
2354 }
2355 return;
2356 }
2357
2358 // Set lock for processing
2359 set_transient( $lock_key, 'cron', 300 );
2360
2361 // Get pending topics from mode-specific queue
2362 $pending_topics = get_option( $queue_key, [] );
2363
2364 if ( empty( $pending_topics ) ) {
2365 // Queue is empty, nothing to do
2366 delete_option( $queue_key );
2367 delete_transient( $lock_key );
2368 return;
2369 }
2370
2371 // Take the next batch
2372 $batch = array_slice( $pending_topics, 0, $batch_size );
2373 $remaining = array_slice( $pending_topics, $batch_size );
2374
2375 // Update queue with remaining topics BEFORE processing
2376 if ( ! empty( $remaining ) ) {
2377 update_option( $queue_key, $remaining, false );
2378 } else {
2379 delete_option( $queue_key );
2380 }
2381
2382 // Get chunk settings from options
2383 $chunk_size = (int) wpforo_get_option( 'ai_chunk_size', 512 );
2384 $overlap_percent = (int) wpforo_get_option( 'ai_overlap_percent', 20 );
2385
2386 // Process using the specified mode
2387 if ( $mode === 'local' ) {
2388 // Use local batch embedding
2389 $result = $this->index_topics_batch_local( $batch, [
2390 'chunk_size' => $chunk_size,
2391 'overlap_percent' => $overlap_percent,
2392 ] );
2393 } else {
2394 // Use cloud indexing via ingest_topics
2395 $ai_client = $this->get_ai_client();
2396 $result = $ai_client->ingest_topics( $batch, $chunk_size, $overlap_percent );
2397 }
2398
2399 // Check for credit exhaustion - stop rescheduling if no credits
2400 $has_credit_error = false;
2401 $errors = is_array( $result ) ? ( $result['errors'] ?? [] ) : [];
2402 foreach ( $errors as $error ) {
2403 if ( stripos( $error, 'insufficient credits' ) !== false || stripos( $error, '402' ) !== false ) {
2404 $has_credit_error = true;
2405 break;
2406 }
2407 }
2408
2409 $this->log_info( 'cron_process_queue_mode_complete', [
2410 'mode' => $mode,
2411 'batch_size' => count( $batch ),
2412 'remaining' => count( $remaining ),
2413 'credit_error' => $has_credit_error,
2414 'result' => is_wp_error( $result ) ? $result->get_error_message() : 'success',
2415 ] );
2416
2417 // Release lock after processing
2418 delete_transient( $lock_key );
2419
2420 // Clear indexed counts cache so UI shows updated progress after each batch
2421 $this->clear_indexing_stats_cache( $board_id );
2422
2423 if ( $has_credit_error ) {
2424 // Clear queue so page reload doesn't auto-resume indexing
2425 delete_option( $queue_key );
2426 return;
2427 }
2428
2429 // If more topics remain, reschedule ourselves
2430 if ( ! empty( $remaining ) ) {
2431 $cron_hook = 'wpforo_ai_process_queue_' . $mode;
2432 $cron_args = [ $board_id ];
2433
2434 // Schedule next batch in 30 seconds
2435 if ( ! wp_next_scheduled( $cron_hook, $cron_args ) ) {
2436 wp_schedule_single_event( time() + 30, $cron_hook, $cron_args );
2437 }
2438 }
2439 }
2440
2441 /**
2442 * Reindex all topics
2443 *
2444 * For local mode, schedules WP Cron jobs to index in batches.
2445 * For cloud mode, sends to cloud API.
2446 *
2447 * @param int $chunk_size Chunk size
2448 * @param int $overlap_percent Overlap percentage
2449 * @return array|WP_Error Result or error
2450 */
2451 public function reindex_all_topics( $chunk_size = 512, $overlap_percent = 20 ) {
2452 if ( $this->is_local_mode() ) {
2453 return $this->reindex_all_topics_local();
2454 } else {
2455 $ai_client = $this->get_ai_client();
2456 return $ai_client->reindex_all_topics( $chunk_size, $overlap_percent );
2457 }
2458 }
2459
2460 /**
2461 * Clear local indexed status for all topics
2462 *
2463 * Sets `local` column to 0 for all topics without deleting actual embeddings.
2464 * Used when user wants to force re-index all topics.
2465 *
2466 * @return int Number of topics updated
2467 */
2468 private function clear_topics_local_indexed_status() {
2469 global $wpdb;
2470
2471 $updated = $wpdb->query(
2472 "UPDATE `" . WPF()->tables->topics . "` SET `local` = 0 WHERE `local` = 1"
2473 );
2474
2475 if ( $updated > 0 ) {
2476 wpforo_clean_cache( 'topic' );
2477 $this->clear_indexing_stats_cache();
2478 }
2479
2480 return (int) $updated;
2481 }
2482
2483 /**
2484 * Reindex all topics locally via WP Cron
2485 *
2486 * Uses batch embedding API for efficiency.
2487 * Supports incremental indexing: only indexes topics with local=0.
2488 * If all topics are already indexed, clears status and re-indexes all.
2489 *
2490 * @return array Result with queued count
2491 */
2492 private function reindex_all_topics_local() {
2493 // Get unindexed topics (local = 0)
2494 $unindexed_topic_ids = $this->get_unindexed_topic_ids();
2495 $unindexed_count = count( $unindexed_topic_ids );
2496
2497 // Determine if we're doing incremental indexing or full re-index
2498 $is_reindex_all = ( $unindexed_count === 0 );
2499
2500 if ( $is_reindex_all ) {
2501 // All topics are indexed - user wants to re-index everything
2502 // Clear status first so all topics become "unindexed"
2503 $this->clear_topics_local_indexed_status();
2504
2505 // Now get all topic IDs (they're all local=0 now)
2506 $topic_ids = $this->get_unindexed_topic_ids();
2507 } else {
2508 // Some topics need indexing - only index those (don't clear status)
2509 $topic_ids = $unindexed_topic_ids;
2510 }
2511
2512 $total_topics = count( $topic_ids );
2513
2514 if ( $total_topics === 0 ) {
2515 return [
2516 'success' => true,
2517 'topics_queued' => 0,
2518 'message' => wpforo_phrase( 'No topics found to index.', false ),
2519 ];
2520 }
2521
2522 // Check available credits before starting (same as cloud mode)
2523 $ai_client = $this->get_ai_client();
2524 $status = $ai_client->get_tenant_status( true ); // Force fresh status
2525 if ( is_wp_error( $status ) ) {
2526 return $status;
2527 }
2528
2529 $credits_available = isset( $status['subscription']['credits_remaining'] )
2530 ? (int) $status['subscription']['credits_remaining']
2531 : 0;
2532
2533 if ( $credits_available <= 0 ) {
2534 return new \WP_Error(
2535 'no_credits',
2536 wpforo_phrase( 'No credits available for indexing. Please wait for your monthly credit reset or upgrade your plan.', false )
2537 );
2538 }
2539
2540 // Warn if credits are low but still proceed (deduplication may reduce actual usage)
2541 $credits_warning = null;
2542 if ( $credits_available < $total_topics ) {
2543 $credits_warning = sprintf(
2544 wpforo_phrase( 'Note: You have %d credits but %d topics to index. Unchanged topics will be skipped, but new topics may not all be indexed.', false ),
2545 $credits_available,
2546 $total_topics
2547 );
2548 }
2549
2550 // Use self-rescheduling queue pattern:
2551 // - Store all topic IDs in a queue (option)
2552 // - Schedule ONE cron job
2553 // - Job processes a batch (pagination_size topics), then reschedules itself if more remain
2554 // This avoids overwhelming WP Cron with hundreds of jobs for large forums
2555 $queue_key = 'wpforo_ai_indexing_queue_' . $this->board_id;
2556
2557 // Clear any existing queue and set new one
2558 update_option( $queue_key, $topic_ids, false ); // No autoload
2559
2560 // Schedule ONE job to start processing (if not already scheduled)
2561 $cron_hook = 'wpforo_ai_process_queue';
2562 $cron_args = [ $this->board_id ];
2563
2564 if ( ! wp_next_scheduled( $cron_hook, $cron_args ) ) {
2565 wp_schedule_single_event( time() + 5, $cron_hook, $cron_args );
2566 }
2567
2568 $batch_size = (int) wpforo_get_option( 'ai_pagination_size', 20 );
2569 $message = sprintf(
2570 wpforo_phrase( 'Indexing queued! %d topics will be processed in batches of %d. Processing starts in 5 seconds.', false ),
2571 $total_topics,
2572 $batch_size
2573 );
2574
2575 // Append credits warning if applicable
2576 if ( $credits_warning ) {
2577 $message .= ' ' . $credits_warning;
2578 }
2579
2580 return [
2581 'success' => true,
2582 'topics_queued' => $total_topics,
2583 'total_topics' => $total_topics,
2584 'batches_queued' => 1, // Just ONE self-rescheduling job
2585 'credits_available' => $credits_available,
2586 'message' => $message,
2587 ];
2588 }
2589
2590 // =========================================================================
2591 // SEMANTIC SEARCH
2592 // =========================================================================
2593
2594 /**
2595 * Perform semantic search
2596 *
2597 * @param string $query Search query
2598 * @param int $limit Maximum results
2599 * @param array $filters Optional filters (forumid, userid, etc.)
2600 * @return array|WP_Error Search results or error
2601 */
2602 public function semantic_search( $query, $limit = 10, $filters = [] ) {
2603 // Add forum access filtering (restrict to forums user can view)
2604 // This applies to BOTH local and cloud modes
2605 // Skip if accessible_forumids already provided in filters (avoids duplicate computation)
2606 if ( ! array_key_exists( 'accessible_forumids', $filters ) ) {
2607 $ai_client = $this->get_ai_client();
2608 $accessible_forumids = $ai_client->get_accessible_forumids();
2609
2610 if ( $accessible_forumids !== null ) {
2611 // User has restricted access - if empty array, they can't access any forums
2612 if ( empty( $accessible_forumids ) ) {
2613 return [
2614 'results' => [],
2615 'total' => 0,
2616 'message' => wpforo_phrase( 'No results found', false ),
2617 ];
2618 }
2619 $filters['accessible_forumids'] = $accessible_forumids;
2620 }
2621 } elseif ( is_array( $filters['accessible_forumids'] ) && empty( $filters['accessible_forumids'] ) ) {
2622 // Empty array passed = user can't access any forums
2623 return [
2624 'results' => [],
2625 'total' => 0,
2626 'message' => wpforo_phrase( 'No results found', false ),
2627 ];
2628 }
2629
2630 if ( $this->is_local_mode() ) {
2631 return $this->semantic_search_local( $query, $limit, $filters );
2632 } else {
2633 return $this->semantic_search_cloud( $query, $limit, $filters );
2634 }
2635 }
2636
2637 /**
2638 * Perform local semantic search
2639 *
2640 * @param string $query Search query
2641 * @param int $limit Maximum results
2642 * @param array $filters Filters
2643 * @return array|WP_Error
2644 */
2645 private function semantic_search_local( $query, $limit = 10, $filters = [] ) {
2646 // Inject score threshold from settings so VectorStorageLocal filters at search time.
2647 // Local cosine similarities are on a different scale (5-25%) than cloud scores (30-90%),
2648 // so apply 1/3 of the configured threshold for local mode.
2649 // Minimum absolute threshold of 15% prevents garbage results from nonsense queries
2650 // (random vectors have ~5-15% cosine similarity with any query).
2651 $min_score_setting = (int) wpfval( WPF()->settings->ai, 'search_min_score' );
2652 $local_threshold = $min_score_setting > 0 ? ( $min_score_setting / 100 ) / 3 : 0;
2653 $absolute_min = 0.15; // 15% - below this, results are definitely garbage
2654 if ( ! isset( $filters['min_score'] ) ) {
2655 $filters['min_score'] = max( $local_threshold, $absolute_min );
2656 }
2657
2658 // Convert accessible_forumids to forumids for local storage (include filter)
2659 if ( ! empty( $filters['accessible_forumids'] ) && empty( $filters['forumids'] ) ) {
2660 $filters['forumids'] = $filters['accessible_forumids'];
2661 unset( $filters['accessible_forumids'] );
2662 }
2663
2664 // Generate embedding for query
2665 $query_embedding = $this->generate_embedding( $query );
2666 if ( is_wp_error( $query_embedding ) ) {
2667 return $query_embedding;
2668 }
2669
2670 $local = $this->get_local_storage();
2671 // Fetch 3x more results to have headroom after deduplication
2672 $results = $local->semantic_search( $query_embedding, $limit * 3, $filters );
2673
2674 if ( is_wp_error( $results ) ) {
2675 return $results;
2676 }
2677
2678 // Deduplicate: group by topic/post, then by content fingerprint
2679 $results = $this->deduplicate_results( $results, $limit );
2680
2681 // Format results to match cloud response format
2682 return $this->format_search_results( $results );
2683 }
2684
2685 /**
2686 * Deduplicate local search results.
2687 *
2688 * Mirrors the cloud search's _deduplicate_by_topic() two-stage approach:
2689 * 1. Group by topicid (forum) or postid (WordPress CPT) — keep best score per group
2690 * 2. Content fingerprint dedup — catch identical text across different posts
2691 *
2692 * @param array $results Raw search results from VectorStorageLocal
2693 * @param int $limit Maximum results to return
2694 * @return array Deduplicated results
2695 */
2696 private function deduplicate_results( $results, $limit ) {
2697 if ( empty( $results ) ) {
2698 return $results;
2699 }
2700
2701 // Stage 1: Group by topic/post, keep best score per group
2702 $best_by_group = [];
2703 foreach ( $results as $result ) {
2704 $content_type = $result['content_type'] ?? 'forum';
2705
2706 if ( $content_type !== 'forum' ) {
2707 // WordPress CPT: group by postid (each post is unique content)
2708 $group_key = 'wp_' . $result['postid'];
2709 } else {
2710 // Forum: group by topicid (multiple posts per topic)
2711 $group_key = 'topic_' . $result['topicid'];
2712 }
2713
2714 if ( ! isset( $best_by_group[ $group_key ] )
2715 || $result['similarity'] > $best_by_group[ $group_key ]['similarity'] ) {
2716 $best_by_group[ $group_key ] = $result;
2717 }
2718 }
2719
2720 // Sort by similarity descending
2721 $deduplicated = array_values( $best_by_group );
2722 usort( $deduplicated, function ( $a, $b ) {
2723 return $b['similarity'] <=> $a['similarity'];
2724 } );
2725
2726 // Stage 2: Content fingerprint dedup (catches identical text across different posts)
2727 $seen_content = [];
2728 $final = [];
2729 foreach ( $deduplicated as $result ) {
2730 $preview = $result['content_preview'] ?? '';
2731 $fingerprint = substr( strtolower( trim( $preview ) ), 0, 200 );
2732
2733 if ( $fingerprint && isset( $seen_content[ $fingerprint ] ) ) {
2734 continue; // Skip duplicate content
2735 }
2736
2737 if ( $fingerprint ) {
2738 $seen_content[ $fingerprint ] = true;
2739 }
2740 $final[] = $result;
2741 }
2742
2743 return array_slice( $final, 0, $limit );
2744 }
2745
2746 /**
2747 * Perform cloud semantic search
2748 *
2749 * @param string $query Search query
2750 * @param int $limit Maximum results
2751 * @param array $filters Filters
2752 * @return array|WP_Error
2753 */
2754 private function semantic_search_cloud( $query, $limit = 10, $filters = [] ) {
2755 $ai_client = $this->get_ai_client();
2756 return $ai_client->semantic_search( $query, $limit, $filters );
2757 }
2758
2759 /**
2760 * Format local search results to match cloud response format
2761 *
2762 * @param array $results Raw local results
2763 * @return array Formatted results
2764 */
2765 private function format_search_results( $results ) {
2766 $formatted = [
2767 'results' => [],
2768 'total' => count( $results ),
2769 ];
2770
2771 // Collect topic and post IDs for forum results to batch fetch (avoids N+1 queries)
2772 $topic_ids = [];
2773 $post_ids = [];
2774 foreach ( $results as $result ) {
2775 $content_type = $result['content_type'] ?? 'forum';
2776 if ( $content_type === 'forum' ) {
2777 $topic_ids[] = (int) $result['topicid'];
2778 $post_ids[] = (int) $result['postid'];
2779 }
2780 }
2781
2782 // Batch fetch topics and posts in 2 queries instead of 2*N queries
2783 $topics_map = [];
2784 $posts_map = [];
2785
2786 if ( ! empty( $topic_ids ) ) {
2787 $topic_ids = array_unique( array_filter( $topic_ids ) );
2788 $_count = 0;
2789 $all_topics = WPF()->topic->get_topics( [
2790 'include' => $topic_ids,
2791 'row_count' => count( $topic_ids ),
2792 'access_filter' => false,
2793 ], $_count, false );
2794 foreach ( $all_topics as $t ) {
2795 $topics_map[ (int) $t['topicid'] ] = $t;
2796 }
2797 }
2798
2799 if ( ! empty( $post_ids ) ) {
2800 $post_ids = array_unique( array_filter( $post_ids ) );
2801 $_count = 0;
2802 $all_posts = WPF()->post->get_posts( [
2803 'include' => $post_ids,
2804 'row_count' => count( $post_ids ),
2805 'check_private' => false,
2806 ], $_count, false );
2807 foreach ( $all_posts as $p ) {
2808 $posts_map[ (int) $p['postid'] ] = $p;
2809 }
2810 }
2811
2812 // Process results using pre-fetched maps
2813 foreach ( $results as $result ) {
2814 $content_type = $result['content_type'] ?? 'forum';
2815
2816 if ( $content_type !== 'forum' ) {
2817 // WordPress CPT result from local DB
2818 $wp_post = get_post( $result['postid'] );
2819 if ( ! $wp_post || $wp_post->post_status !== 'publish' ) {
2820 continue;
2821 }
2822
2823 $post_type_obj = get_post_type_object( $wp_post->post_type );
2824 $post_type_label = $post_type_obj ? $post_type_obj->labels->singular_name : ucfirst( $wp_post->post_type );
2825
2826 $formatted['results'][] = [
2827 'title' => $wp_post->post_title,
2828 'content' => $result['content_preview'] ?? wp_trim_words( wp_strip_all_tags( $wp_post->post_content ), 50 ),
2829 'score' => (float) $result['similarity'],
2830 'url' => get_permalink( $wp_post ),
2831 'post_url' => get_permalink( $wp_post ),
2832 'created' => $wp_post->post_date,
2833 'user_id' => (int) $wp_post->post_author,
2834 'post_id' => $wp_post->ID,
2835 'content_type' => $content_type,
2836 'content_source' => 'wordpress',
2837 'post_type_label' => $post_type_label,
2838 'metadata' => [
2839 'content_source' => 'wordpress',
2840 'post_id' => $wp_post->ID,
2841 'post_type' => $wp_post->post_type,
2842 ],
2843 ];
2844 continue;
2845 }
2846
2847 // Forum result - use pre-fetched maps
2848 $topic = $topics_map[ (int) $result['topicid'] ] ?? null;
2849 $post = $posts_map[ (int) $result['postid'] ] ?? null;
2850
2851 if ( ! $topic || ! $post ) {
2852 continue;
2853 }
2854
2855 // Calculate quality boost for ranking (uses live data, no schema changes)
2856 // Boost is applied to ranking only; original score is preserved for display
2857 $base_score = (float) $result['similarity'];
2858 $boost = 1.0;
2859 if ( ! empty( $topic['solved'] ) ) {
2860 $boost += 0.05; // +5% for solved topics
2861 }
2862 if ( ! empty( $post['is_answer'] ) ) {
2863 $boost += 0.10; // +10% for best answer posts
2864 }
2865 if ( ! empty( $post['votes'] ) && (int) $post['votes'] > 5 ) {
2866 $boost += 0.03; // +3% for well-voted posts (>5 votes)
2867 }
2868
2869 $formatted['results'][] = [
2870 'topic_id' => (int) $result['topicid'],
2871 'post_id' => (int) $result['postid'],
2872 'forum_id' => (int) $result['forumid'],
2873 'title' => $topic['title'] ?? '',
2874 'content' => preg_replace( '/\[(?:FORUM|SOLVED|BEST ANSWER)[^\]]*\]/', '', $result['content_preview'] ?? preg_replace( '/\[(?:\/)?[a-zA-Z0-9_-]+(?:\s[^\]]*?)?\]/', '', strip_tags( $post['body'] ) ) ),
2875 'score' => $base_score, // Original similarity for display
2876 '_boost_score' => $base_score * $boost, // Internal: for sorting only
2877 'url' => WPF()->topic->get_url( $topic ), // Pass array to avoid extra query
2878 'post_url' => WPF()->post->get_url( $post ), // Pass array to avoid extra query
2879 'created' => $post['created'] ?? null,
2880 'user_id' => (int) ( $result['userid'] ?? 0 ),
2881 'content_type' => 'forum',
2882 'content_source' => 'forum',
2883 ];
2884 }
2885
2886 // Re-sort by boosted score (quality signals affect ranking)
2887 if ( ! empty( $formatted['results'] ) ) {
2888 usort( $formatted['results'], function( $a, $b ) {
2889 return ( $b['_boost_score'] ?? $b['score'] ) <=> ( $a['_boost_score'] ?? $a['score'] );
2890 } );
2891 // Remove internal boost score from output
2892 foreach ( $formatted['results'] as &$r ) {
2893 unset( $r['_boost_score'] );
2894 }
2895 unset( $r );
2896 }
2897
2898 return $formatted;
2899 }
2900
2901 // =========================================================================
2902 // SIMILAR CONTENT
2903 // =========================================================================
2904
2905 /**
2906 * Find similar topics/posts
2907 *
2908 * @param string $type 'topic' or 'post'
2909 * @param int $id Topic or post ID
2910 * @param int $limit Maximum results
2911 * @param bool $use_cache Whether to use cache
2912 * @return array|WP_Error
2913 */
2914 public function find_similar( $type, $id, $limit = 5, $use_cache = true ) {
2915 if ( $this->is_local_mode() ) {
2916 $local = $this->get_local_storage();
2917 return $local->find_similar( $type, $id, $limit, ! $use_cache );
2918 } else {
2919 $ai_client = $this->get_ai_client();
2920 return $ai_client->find_similar_topics( $id, $limit );
2921 }
2922 }
2923
2924 // =========================================================================
2925 // UTILITY METHODS
2926 // =========================================================================
2927
2928 /**
2929 * Check if content is indexed
2930 *
2931 * @param int $postid Post ID
2932 * @param string $content_hash Optional content hash to check freshness
2933 * @return bool
2934 */
2935 public function is_indexed( $postid, $content_hash = null ) {
2936 if ( $this->is_local_mode() ) {
2937 $local = $this->get_local_storage();
2938 $existing = $local->get_embedding( $postid );
2939
2940 if ( ! $existing ) {
2941 return false;
2942 }
2943
2944 if ( $content_hash !== null ) {
2945 return $existing['content_hash'] === $content_hash;
2946 }
2947
2948 return true;
2949 } else {
2950 // For cloud, we'd need to check via API
2951 // For now, assume indexed if we've indexed before
2952 return false; // Let cloud handle deduplication
2953 }
2954 }
2955
2956 /**
2957 * Get the current storage mode label
2958 *
2959 * @return string Human-readable label
2960 */
2961 public function get_storage_mode_label() {
2962 if ( $this->is_local_mode() ) {
2963 return wpforo_phrase( 'Local (WordPress)', false );
2964 } else {
2965 return wpforo_phrase( 'Cloud (gVectors)', false );
2966 }
2967 }
2968
2969 /**
2970 * Sync local indexed status in wpforo_topics table
2971 *
2972 * Queries wpforo_ai_embeddings table for all unique topic IDs and
2973 * updates the `local` column in wpforo_topics accordingly.
2974 * Called when switching to local storage mode.
2975 *
2976 * @return array {
2977 * @type int $updated Number of topics marked as indexed
2978 * @type int $cleared Number of topics marked as not indexed
2979 * }
2980 */
2981 public function sync_local_indexed_status() {
2982 global $wpdb;
2983
2984 $embeddings_table = WPF()->tables->ai_embeddings;
2985 $topics_table = WPF()->tables->topics;
2986
2987 // Set local=1 for topics that have embeddings (single JOIN query)
2988 $updated = (int) $wpdb->query(
2989 "UPDATE `{$topics_table}` t
2990 INNER JOIN (SELECT DISTINCT topicid FROM `{$embeddings_table}` WHERE topicid > 0) e
2991 ON t.topicid = e.topicid
2992 SET t.`local` = 1
2993 WHERE t.`local` != 1"
2994 );
2995
2996 // Set local=0 for topics that have no embeddings (single LEFT JOIN query)
2997 $cleared = (int) $wpdb->query(
2998 "UPDATE `{$topics_table}` t
2999 LEFT JOIN (SELECT DISTINCT topicid FROM `{$embeddings_table}` WHERE topicid > 0) e
3000 ON t.topicid = e.topicid
3001 SET t.`local` = 0
3002 WHERE e.topicid IS NULL AND t.`local` != 0"
3003 );
3004
3005 // Count indexed topics
3006 $indexed_count = (int) $wpdb->get_var(
3007 "SELECT COUNT(DISTINCT topicid) FROM `{$embeddings_table}` WHERE topicid > 0"
3008 );
3009
3010 // Clear stats cache for fresh counts
3011 if ( $updated > 0 || $cleared > 0 ) {
3012 $this->clear_indexing_stats_cache();
3013 }
3014
3015 return [
3016 'updated' => $updated,
3017 'cleared' => $cleared,
3018 'indexed_count' => $indexed_count,
3019 ];
3020 }
3021
3022 /**
3023 * Sync cloud indexed status in wpforo_topics table
3024 *
3025 * Calls the /v1/rag/indexed-topics API endpoint to get all indexed
3026 * topic IDs and updates the `cloud` column in wpforo_topics accordingly.
3027 * Called when switching to cloud storage mode.
3028 *
3029 * @return array|WP_Error {
3030 * @type int $updated Number of topics marked as indexed
3031 * @type int $cleared Number of topics marked as not indexed
3032 * }
3033 */
3034 public function sync_cloud_indexed_status() {
3035 global $wpdb;
3036
3037 // Get indexed topic IDs from cloud API
3038 $indexed_topic_ids = $this->get_cloud_indexed_topic_ids();
3039
3040 if ( is_wp_error( $indexed_topic_ids ) ) {
3041 return $indexed_topic_ids;
3042 }
3043
3044 $updated = 0;
3045 $cleared = 0;
3046
3047 if ( ! empty( $indexed_topic_ids ) ) {
3048 // Set cloud=1 for topics that are indexed
3049 $placeholders = implode( ',', array_fill( 0, count( $indexed_topic_ids ), '%d' ) );
3050 $updated = $wpdb->query(
3051 $wpdb->prepare(
3052 "UPDATE `" . WPF()->tables->topics . "` SET `cloud` = 1 WHERE topicid IN ($placeholders)",
3053 $indexed_topic_ids
3054 )
3055 );
3056
3057 // Set cloud=0 for topics that are NOT indexed
3058 $cleared = $wpdb->query(
3059 $wpdb->prepare(
3060 "UPDATE `" . WPF()->tables->topics . "` SET `cloud` = 0 WHERE topicid NOT IN ($placeholders)",
3061 $indexed_topic_ids
3062 )
3063 );
3064 } else {
3065 // No indexed topics - set all to 0
3066 $cleared = $wpdb->query(
3067 "UPDATE `" . WPF()->tables->topics . "` SET `cloud` = 0 WHERE `cloud` = 1"
3068 );
3069 }
3070
3071 // Clear topic cache to ensure wpforo_topic() returns fresh cloud values
3072 // This is critical for force_reindex to work correctly
3073 if ( $updated > 0 || $cleared > 0 ) {
3074 wpforo_clean_cache( 'topic' );
3075 // Clear indexing stats cache for fresh counts
3076 $this->clear_indexing_stats_cache();
3077 }
3078
3079 return [
3080 'updated' => (int) $updated,
3081 'cleared' => (int) $cleared,
3082 'indexed_count' => count( $indexed_topic_ids )
3083 ];
3084 }
3085
3086 /**
3087 * Get all indexed topic IDs from cloud storage
3088 *
3089 * Calls the /v1/rag/indexed-topics API endpoint.
3090 *
3091 * @return array|WP_Error Array of topic IDs or error
3092 */
3093 public function get_cloud_indexed_topic_ids() {
3094 $ai_client = $this->get_ai_client();
3095 if ( ! $ai_client ) {
3096 return new \WP_Error( 'no_ai_client', wpforo_phrase( 'AI client not available', false ) );
3097 }
3098
3099 $response = $ai_client->api_get( '/rag/indexed-topics' );
3100
3101 if ( is_wp_error( $response ) ) {
3102 return $response;
3103 }
3104
3105 if ( ! isset( $response['topic_ids'] ) || ! is_array( $response['topic_ids'] ) ) {
3106 return new \WP_Error( 'invalid_response', wpforo_phrase( 'Invalid response from API', false ) );
3107 }
3108
3109 return $response['topic_ids'];
3110 }
3111
3112 /**
3113 * Mark a topic as indexed in the current storage mode
3114 *
3115 * Updates the `local` or `cloud` column based on current mode.
3116 *
3117 * @param int $topicid Topic ID
3118 * @return bool Success
3119 */
3120 public function mark_topic_indexed( $topicid ) {
3121 global $wpdb;
3122
3123 $column = $this->is_local_mode() ? 'local' : 'cloud';
3124
3125 return $wpdb->update(
3126 WPF()->tables->topics,
3127 [ $column => 1 ],
3128 [ 'topicid' => $topicid ],
3129 [ '%d' ],
3130 [ '%d' ]
3131 ) !== false;
3132 }
3133
3134 /**
3135 * Mark a topic as not indexed in the current storage mode
3136 *
3137 * Updates the `local` or `cloud` column based on current mode.
3138 *
3139 * @param int $topicid Topic ID
3140 * @return bool Success
3141 */
3142 public function mark_topic_not_indexed( $topicid ) {
3143 global $wpdb;
3144
3145 $column = $this->is_local_mode() ? 'local' : 'cloud';
3146
3147 return $wpdb->update(
3148 WPF()->tables->topics,
3149 [ $column => 0 ],
3150 [ 'topicid' => $topicid ],
3151 [ '%d' ],
3152 [ '%d' ]
3153 ) !== false;
3154 }
3155
3156 /**
3157 * Get topics that are not indexed in the current storage mode
3158 *
3159 * Uses the `local` or `cloud` column based on current mode.
3160 *
3161 * @param int $limit Maximum number of topics to return (0 = no limit)
3162 * @param int $offset Offset for pagination
3163 * @return array Array of topic IDs
3164 */
3165 public function get_unindexed_topic_ids( $limit = 0, $offset = 0 ) {
3166 global $wpdb;
3167
3168 $column = $this->is_local_mode() ? 'local' : 'cloud';
3169
3170 // Exclude private topics (private = 1) and unapproved topics (status != 0)
3171 $sql = "SELECT topicid FROM `" . WPF()->tables->topics . "` WHERE `{$column}` = 0 AND `status` = 0 AND `private` = 0 ORDER BY topicid ASC";
3172
3173 if ( $limit > 0 ) {
3174 $sql .= $wpdb->prepare( " LIMIT %d OFFSET %d", $limit, $offset );
3175 }
3176
3177 return array_map( 'intval', $wpdb->get_col( $sql ) );
3178 }
3179
3180 /**
3181 * Count topics that are not indexed in the current storage mode
3182 *
3183 * @return int Count of unindexed topics
3184 */
3185 public function count_unindexed_topics() {
3186 global $wpdb;
3187
3188 $column = $this->is_local_mode() ? 'local' : 'cloud';
3189
3190 // Exclude private topics (private = 1) and unapproved topics (status != 0)
3191 return (int) $wpdb->get_var(
3192 "SELECT COUNT(*) FROM `" . WPF()->tables->topics . "` WHERE `{$column}` = 0 AND `status` = 0 AND `private` = 0"
3193 );
3194 }
3195
3196 // =========================================================================
3197 // INDEXING STATUS ANALYSIS
3198 // =========================================================================
3199
3200 /**
3201 * Get a detailed breakdown of topics by indexing status
3202 *
3203 * Analyzes all topics and categorizes them by why they are or aren't indexed.
3204 * This helps users understand why some topics might not appear in AI search.
3205 *
3206 * Categories:
3207 * - indexed: Successfully indexed in current storage mode
3208 * - pending: Eligible for indexing but not yet indexed
3209 * - private: Private topics (excluded from indexing)
3210 * - unapproved: Unapproved topics (excluded from indexing)
3211 *
3212 * @return array Breakdown with counts for each category
3213 */
3214 public function get_indexing_status_breakdown() {
3215 global $wpdb;
3216
3217 $column = $this->is_local_mode() ? 'local' : 'cloud';
3218 $topics_table = WPF()->tables->topics;
3219
3220 // Get counts for each category in a single query
3221 $results = $wpdb->get_row(
3222 "SELECT
3223 COUNT(*) as total,
3224 SUM(CASE WHEN `{$column}` = 1 AND `status` = 0 AND `private` = 0 THEN 1 ELSE 0 END) as indexed,
3225 SUM(CASE WHEN `{$column}` = 0 AND `status` = 0 AND `private` = 0 THEN 1 ELSE 0 END) as pending,
3226 SUM(CASE WHEN `private` = 1 THEN 1 ELSE 0 END) as private_topics,
3227 SUM(CASE WHEN `status` != 0 AND `private` = 0 THEN 1 ELSE 0 END) as unapproved
3228 FROM `{$topics_table}`",
3229 ARRAY_A
3230 );
3231
3232 if ( ! $results ) {
3233 return [
3234 'total' => 0,
3235 'indexed' => 0,
3236 'pending' => 0,
3237 'private' => 0,
3238 'unapproved' => 0,
3239 'storage_mode' => $this->get_storage_mode(),
3240 ];
3241 }
3242
3243 return [
3244 'total' => (int) $results['total'],
3245 'indexed' => (int) $results['indexed'],
3246 'pending' => (int) $results['pending'],
3247 'private' => (int) $results['private_topics'],
3248 'unapproved' => (int) $results['unapproved'],
3249 'storage_mode' => $this->get_storage_mode(),
3250 ];
3251 }
3252
3253 /**
3254 * Get sample topics that are pending indexing
3255 *
3256 * Returns a limited sample of topics that should be indexed but aren't.
3257 * Useful for debugging why topics aren't being picked up.
3258 *
3259 * @param int $limit Maximum number of topics to return
3260 * @return array Array of topic data with basic info
3261 */
3262 public function get_pending_topics_sample( $limit = 10 ) {
3263 global $wpdb;
3264
3265 $column = $this->is_local_mode() ? 'local' : 'cloud';
3266 $topics_table = WPF()->tables->topics;
3267
3268 $topics = $wpdb->get_results(
3269 $wpdb->prepare(
3270 "SELECT topicid, title, forumid, posts, created, modified
3271 FROM `{$topics_table}`
3272 WHERE `{$column}` = 0
3273 AND `status` = 0
3274 AND `private` = 0
3275 ORDER BY topicid DESC
3276 LIMIT %d",
3277 $limit
3278 ),
3279 ARRAY_A
3280 );
3281
3282 return $topics ?: [];
3283 }
3284
3285 // =========================================================================
3286 // DEBUG LOGGING
3287 // =========================================================================
3288
3289 /**
3290 * Check if debug mode is enabled
3291 *
3292 * @return bool True if debug mode is enabled
3293 */
3294 private function is_debug_mode() {
3295 return defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WPFORO_AI_DEBUG' ) && WPFORO_AI_DEBUG;
3296 }
3297
3298 /**
3299 * Log informational message (only in debug mode)
3300 *
3301 * @param string $context Log context identifier
3302 * @param array $data Optional data to log
3303 */
3304 private function log_info( $context, $data = [] ) {
3305 if ( ! $this->is_debug_mode() ) {
3306 return;
3307 }
3308
3309 \wpforo_ai_log( 'info', sprintf(
3310 '%s | Data: %s',
3311 $context,
3312 wp_json_encode( $data )
3313 ), 'VectorStorage' );
3314 }
3315 }
3316