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

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

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