PluginProbe
wpForo Forum / 3.0.5
wpForo Forum v3.0.5
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.5, at classes/VectorStorageManager.php

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