PluginProbe
wpForo Forum / 3.1.5
wpForo Forum v3.1.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.1.5, at classes/VectorStorageManager.php

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