PluginProbe
wpForo Forum / 3.0.9
wpForo Forum v3.0.9
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.9, at classes/VectorStorageManager.php

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