PluginProbe
wpForo Forum / 3.0.2
wpForo Forum v3.0.2
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.2, at classes/VectorStorageManager.php

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