PluginProbe
wpForo Forum / 3.1.0
wpForo Forum v3.1.0
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 / AIClient.php

AIClient.php in wpForo Forum 3.1.0, at classes/AIClient.php

10,101 lines 337.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace wpforo\classes;
4
5 /**
6 * wpForo AI Features API Client
7 *
8 * Handles communication with the wpForo AI backend service including:
9 * - Tenant registration and API key generation
10 * - Subscription status retrieval
11 * - API key regeneration
12 * - Service disconnection
13 *
14 * @since 3.0.0
15 */
16 class AIClient {
17 use AIAjaxTrait;
18 use AIUserTrait;
19
20 /**
21 * API base URL
22 *
23 * @var string
24 */
25 private $api_base_url = 'https://api.gvectors.com/v1';
26
27 /**
28 * Fallback API base URL (used when primary domain is blocked)
29 *
30 * @var string
31 */
32 private $fallback_api_url = 'https://api.gvectors.net/v1';
33
34 /**
35 * Image extensions supported for multimodal indexing
36 *
37 * @var array
38 */
39 private static $image_extensions = [ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp' ];
40
41 /**
42 * Document extensions supported for document indexing
43 *
44 * @var array
45 */
46 private static $document_extensions = [ 'pdf', 'docx', 'doc', 'pptx', 'txt', 'rtf' ];
47
48 /**
49 * Request timeout in seconds
50 *
51 * @var int
52 */
53 private $timeout = 30;
54
55 /**
56 * Constructor
57 */
58 public function __construct() {
59 if ( defined( 'WPFORO_AI_API' ) && WPFORO_AI_API ) {
60 $this->api_base_url = WPFORO_AI_API;
61 }
62
63 // Allow API URLs to be filtered for different environments
64 $this->api_base_url = apply_filters( 'wpforo_ai_api_base_url', $this->api_base_url );
65 $this->fallback_api_url = apply_filters( 'wpforo_ai_fallback_api_url', $this->fallback_api_url );
66
67 // Register admin-only AJAX handlers
68 if ( is_admin() ) {
69 add_action( 'wp_ajax_wpforo_ai_get_rag_status', [ $this, 'ajax_get_rag_status' ] );
70 add_action( 'wp_ajax_wpforo_ai_get_indexing_breakdown', [ $this, 'ajax_get_indexing_breakdown' ] );
71 add_action( 'wp_ajax_wpforo_ai_cancel_cloud_indexing', [ $this, 'ajax_cancel_cloud_indexing' ] );
72 add_action( 'wp_ajax_wpforo_ai_cleanup_indexing_session', [ $this, 'ajax_cleanup_indexing_session' ] );
73 add_action( 'wp_ajax_wpforo_ai_action', [ $this, 'ajax_generic_action' ] );
74 add_action( 'wp_ajax_wpforo_ai_save_storage_mode', [ $this, 'ajax_save_storage_mode' ] );
75 add_action( 'wp_ajax_wpforo_ai_save_auto_indexing', [ $this, 'ajax_save_auto_indexing' ] );
76 add_action( 'wp_ajax_wpforo_ai_save_image_indexing', [ $this, 'ajax_save_image_indexing' ] );
77 add_action( 'wp_ajax_wpforo_ai_save_document_indexing', [ $this, 'ajax_save_document_indexing' ] );
78 add_action( 'wp_ajax_wpforo_ai_save_wp_indexing_option', [ $this, 'ajax_save_wp_indexing_option' ] );
79 add_action( 'wp_ajax_wpforo_ai_get_analytics', [ $this, 'ajax_get_analytics' ] );
80 add_action( 'wp_ajax_wpforo_ai_run_insight', [ $this, 'ajax_run_insight' ] );
81 add_action( 'wp_ajax_wpforo_ai_link_subscription', [ $this, 'ajax_link_subscription' ] );
82 add_action( 'wp_ajax_wpforo_ai_activate_license', [ $this, 'ajax_activate_license' ] );
83 add_action( 'wp_ajax_wpforo_ai_paddle_checkout', [ $this, 'ajax_paddle_checkout' ] );
84 add_action( 'wp_ajax_wpforo_ai_link_paddle_subscription', [ $this, 'ajax_link_paddle_subscription' ] );
85 add_action( 'wp_ajax_wpforo_ai_activate_paddle_transaction', [ $this, 'ajax_activate_paddle_transaction' ] );
86 add_action( 'wp_ajax_wpforo_ai_search_bot_users', [ $this, 'ajax_search_bot_users' ] );
87 add_action( 'wp_ajax_wpforo_ai_request_bonus_credits', [ $this, 'ajax_request_bonus_credits' ] );
88
89 // Custom Knowledge AJAX handlers
90 add_action( 'wp_ajax_wpforo_ai_add_knowledge', [ $this, 'ajax_add_knowledge' ] );
91 add_action( 'wp_ajax_wpforo_ai_delete_knowledge', [ $this, 'ajax_delete_knowledge' ] );
92 add_action( 'wp_ajax_wpforo_ai_save_knowledge_settings', [ $this, 'ajax_save_knowledge_priorities' ] );
93 add_action( 'wp_ajax_wpforo_ai_get_knowledge_settings', [ $this, 'ajax_get_knowledge_settings' ] );
94 add_action( 'wp_ajax_wpforo_ai_get_knowledge_files', [ $this, 'ajax_get_knowledge_files' ] );
95 add_action( 'wp_ajax_wpforo_ai_get_job_status', [ $this, 'ajax_get_job_status' ] );
96
97 // Register privacy policy content for AI features
98 add_action( 'admin_init', [ $this, 'register_privacy_policy_content' ] );
99
100 // Self-heal stalled WP-Cron on wpForo AI admin page loads.
101 // Runs once per page refresh (not on AJAX polls) to avoid any
102 // interference with the Stop Indexing flow. Hosts where AJAX
103 // responses are unreliable (proxies stripping bodies, etc.)
104 // still get the nudge whenever an admin reloads the page.
105 add_action( 'admin_init', [ $this, 'maybe_nudge_wp_cron_on_admin_page' ] );
106 }
107
108 // Register front-end and admin AJAX handlers (for semantic search, antispam, etc.)
109 add_action( 'wp_ajax_wpforo_ai_semantic_search', [ $this, 'ajax_semantic_search' ] );
110 add_action( 'wp_ajax_nopriv_wpforo_ai_semantic_search', [ $this, 'ajax_semantic_search' ] );
111
112 // Register public front-end semantic search (no admin permissions required)
113 add_action( 'wp_ajax_wpforo_ai_public_search', [ $this, 'ajax_public_semantic_search' ] );
114 add_action( 'wp_ajax_nopriv_wpforo_ai_public_search', [ $this, 'ajax_public_semantic_search' ] );
115
116 // Register translation AJAX handlers (for logged-in and guest users)
117 add_action( 'wp_ajax_wpforo_ai_translate', [ $this, 'ajax_translate_content' ] );
118 add_action( 'wp_ajax_nopriv_wpforo_ai_translate', [ $this, 'ajax_translate_content' ] );
119
120 // Register topic summarization AJAX handlers (for logged-in and guest users)
121 add_action( 'wp_ajax_wpforo_ai_summarize_topic', [ $this, 'ajax_summarize_topic' ] );
122 add_action( 'wp_ajax_nopriv_wpforo_ai_summarize_topic', [ $this, 'ajax_summarize_topic' ] );
123
124 // Register topic suggestions AJAX handlers (for logged-in and guest users)
125 add_action( 'wp_ajax_wpforo_ai_get_topic_suggestions', [ $this, 'ajax_get_topic_suggestions' ] );
126 add_action( 'wp_ajax_nopriv_wpforo_ai_get_topic_suggestions', [ $this, 'ajax_get_topic_suggestions' ] );
127
128 // Register translation button hook for post content
129 add_action( 'wpforo_post_content_top_left', [ $this, 'render_translation_button' ] );
130
131 // Register AI Bot Reply button hook for post action buttons
132 add_filter( 'wpforo_template_buttons_bottom', [ $this, 'render_bot_reply_button' ], 8, 5 );
133
134 // Register Suggest Reply button hook for reply form
135 add_action( 'wpforo_editor_post_submit_button_before', [ $this, 'render_suggest_reply_button' ], 10, 3 );
136
137 // Register Bot Reply AJAX handlers (logged-in users only)
138 add_action( 'wp_ajax_wpforo_ai_bot_reply', [ $this, 'ajax_bot_reply' ] );
139 add_action( 'wp_ajax_wpforo_ai_suggest_reply', [ $this, 'ajax_suggest_reply' ] );
140
141 // Register topic summarization button hook (in head-bar with subscribe button)
142 add_action( 'wpforo_template_post_head_bar_action_links', [ $this, 'render_topic_summary_button' ], 11, 3 );
143
144 // Register topic summary container hook (after head-bar, for slide-down area)
145 add_action( 'wpforo_template_post_head_bar', [ $this, 'render_topic_summary_container_standalone' ], 10, 3 );
146
147 // Register user AI preferences handler (logged-in users only)
148 add_action( 'wp_ajax_wpforo_save_ai_preferences', [ $this, 'ajax_save_ai_preferences' ] );
149
150 // Register WP Cron handler for background batch processing
151 // IMPORTANT: Must be registered unconditionally (not only in admin context)
152 // because WP Cron runs in a separate request where is_admin() returns FALSE
153 // Accept 3 args for backwards compatibility with old cron format
154 add_action( 'wpforo_ai_process_batch', [ $this, 'cron_process_batch' ], 10, 3 );
155
156 // Register WP Cron handler for local indexing queue (self-rescheduling pattern)
157 // This processes batches from the queue and reschedules itself until queue is empty
158 add_action( 'wpforo_ai_process_queue', [ $this, 'cron_process_queue' ], 10, 1 );
159
160 // Register mode-specific WP Cron handlers for auto-indexing queues
161 // These ensure local topics are processed with local indexing and cloud topics with cloud indexing
162 add_action( 'wpforo_ai_process_queue_local', [ $this, 'cron_process_queue_local' ], 10, 1 );
163 add_action( 'wpforo_ai_process_queue_cloud', [ $this, 'cron_process_queue_cloud' ], 10, 1 );
164
165 // Register WP Cron handler for AI cache cleanup (daily)
166 add_action( 'wpforo_ai_cache_cleanup', [ $this, 'cron_cache_cleanup' ] );
167
168 // Register WP Cron handler for daily pending topics indexing
169 add_action( 'wpforo_ai_pending_topics_indexing', [ $this, 'cron_pending_topics_indexing' ] );
170
171 // Register WP Cron handler for daily subscription status sync
172 add_action( 'wpforo_ai_daily_subscription_sync', [ $this, 'cron_daily_subscription_sync' ] );
173
174 // Clear translation cache and invalidate indexed status when posts change
175 add_action( 'wpforo_after_add_post', [ $this, 'on_post_add' ], 10, 2 );
176 add_action( 'wpforo_after_edit_post', [ $this, 'on_post_edit' ], 10, 4 );
177 add_action( 'wpforo_after_delete_post', [ $this, 'on_post_delete' ], 10, 1 );
178 add_action( 'wpforo_post_approve', [ $this, 'on_post_approve' ], 10, 1 );
179
180 // Auto-index new approved topics and topics that get approved
181 add_action( 'wpforo_after_add_topic', [ $this, 'on_topic_add' ], 10, 2 );
182 add_action( 'wpforo_topic_approve', [ $this, 'on_topic_approve' ], 10, 1 );
183
184 // Clean up embeddings when topics are deleted
185 add_action( 'wpforo_after_delete_topic', [ $this, 'on_topic_delete' ], 10, 1 );
186
187 // Clean up embeddings when topics become private (priority 15 to run after Forums/PostMeta hooks)
188 add_action( 'wpforo_topic_private_update', [ $this, 'on_topic_private_update' ], 15, 2 );
189
190 // Add AI suggestions panel right after title field using form fields filter
191 add_filter( 'wpforo_form_fields', [ $this, 'add_ai_suggestions_after_title' ] );
192
193 // Disable built-in wpForo topic suggestions when AI Topic Suggestions is enabled
194 add_filter( 'wpforo_topic_suggestion', [ $this, 'filter_built_in_suggestions' ] );
195
196 // Cron lifecycle: schedule/unschedule the recurring AI maintenance crons
197 // based on AI service connection state. Prevents stale events from
198 // piling up in `wp_options.cron` on installs that never enabled AI.
199 add_action( 'wpforo_ai_tenant_registered', [ $this, 'register_ai_crons' ] );
200 add_action( 'wpforo_ai_tenant_disconnected', [ $this, 'unregister_ai_crons' ] );
201 }
202
203 /**
204 * Schedule all recurring AI-related crons.
205 *
206 * Called on tenant connect (wpforo_ai_tenant_registered). Idempotent —
207 * each helper skips if already scheduled. Safe to call from plugin
208 * upgrade migrations via sync_cron_state().
209 */
210 public function register_ai_crons() {
211 $this->schedule_cache_cleanup();
212 $this->schedule_daily_subscription_sync();
213
214 if ( isset( WPF()->ai_content_moderation ) && WPF()->ai_content_moderation ) {
215 WPF()->ai_content_moderation->schedule_moderation_cleanup();
216 }
217 if ( isset( WPF()->vector_storage ) && WPF()->vector_storage ) {
218 WPF()->vector_storage->schedule_cron_jobs();
219 }
220 if ( isset( WPF()->task_manager ) && WPF()->task_manager ) {
221 WPF()->task_manager->schedule_cron_jobs();
222 }
223
224 $this->log_info( 'ai_crons_registered' );
225 }
226
227 /**
228 * Unschedule every recurring AI-related cron.
229 *
230 * Called on tenant disconnect (wpforo_ai_tenant_disconnected) and on
231 * plugin upgrade when not connected, so users who never enabled AI (or
232 * who disconnected) do not see stale events accumulating in wp_cron.
233 *
234 * Single-event crons (wpforo_ai_execute_task[_for_topic], _process_batch,
235 * _process_queue*, _process_wp_batch) are not blanket-cleared here — they
236 * are managed per-task on the AI side and never get scheduled for users
237 * who do not use AI features.
238 */
239 public function unregister_ai_crons() {
240 $this->unschedule_cache_cleanup();
241 $this->unschedule_daily_subscription_sync();
242 $this->unschedule_pending_topics_indexing();
243
244 if ( isset( WPF()->ai_content_moderation ) && WPF()->ai_content_moderation ) {
245 WPF()->ai_content_moderation->unschedule_moderation_cleanup();
246 }
247 if ( isset( WPF()->vector_storage ) && WPF()->vector_storage ) {
248 WPF()->vector_storage->unschedule_cron_jobs();
249 }
250 if ( isset( WPF()->task_manager ) && WPF()->task_manager ) {
251 WPF()->task_manager->unschedule_cron_jobs();
252 }
253
254 // AILogs cleanup is scheduled lazily on first log insert; clear it
255 // too so the wp_cron option stays clean for users who never reconnect.
256 wp_clear_scheduled_hook( 'wpforo_ai_logs_cleanup' );
257
258 $this->log_info( 'ai_crons_unregistered' );
259 }
260
261 /**
262 * Idempotent reconciler: ensures the AI cron set matches the current
263 * connection state. Called from plugin upgrade so existing installs that
264 * accumulated AI crons without ever connecting get cleaned up.
265 */
266 public function sync_cron_state() {
267 if ( $this->is_connected() ) {
268 $this->register_ai_crons();
269 } else {
270 $this->unregister_ai_crons();
271 }
272 }
273
274 /**
275 * Get API base URL
276 *
277 * @return string API base URL
278 */
279 public function get_api_base_url() {
280 return $this->api_base_url;
281 }
282
283 /**
284 * Register suggested privacy policy content for AI features
285 *
286 * Adds a suggestion to Settings > Privacy so site admins can include
287 * AI data processing disclosure in their site's privacy policy.
288 */
289 public function register_privacy_policy_content() {
290 $content = '<h2>' . __( 'wpForo AI Features', 'wpforo' ) . '</h2>' .
291 '<p>' . __( 'When AI features are enabled by the forum administrator, this site sends forum content (topics, posts, and metadata) to the gVectors AI API (api.gvectors.com) for processing. This processing includes semantic search, AI translation, summarization, content moderation, AI chat, and topic suggestions.', 'wpforo' ) . '</p>' .
292 '<p>' . __( 'No forum data is sent to external servers unless the site administrator has explicitly enabled AI features and configured the service. The data is processed on secure AWS infrastructure and is used solely to provide the requested AI functionality.', 'wpforo' ) . '</p>' .
293 '<p>' . sprintf(
294 __( 'For more information, see the gVectors %1$sTerms of Service%2$s and %3$sPrivacy Policy%4$s.', 'wpforo' ),
295 '<a href="https://gvectors.com/terms-and-conditions/">',
296 '</a>',
297 '<a href="https://gvectors.com/privacy-policy/">',
298 '</a>'
299 ) . '</p>';
300
301 wp_add_privacy_policy_content( 'wpForo Forum', $content );
302 }
303
304 // =========================================================================
305 // GLOBAL AI OPTIONS
306 // =========================================================================
307 // These options are shared across ALL boards (use base prefix 'wpforo_')
308 // Unlike board-specific options, these don't use the board prefix.
309 //
310 // Global options:
311 // - ai_api_key : API key for authentication (shared)
312 // - ai_tenant_id : Tenant identifier (shared)
313 //
314 // Board-specific options (use wpforo_get_option/wpforo_update_option):
315 // - ai_chunk_size : Chunking size per board
316 // - ai_overlap_percent : Overlap percentage per board
317 // - ai_pagination_size : Pagination size per board
318 // =========================================================================
319
320 /**
321 * Get a global AI option (shared across all boards)
322 *
323 * Uses base prefix 'wpforo_' regardless of current board context.
324 * This ensures API key and tenant ID are always found.
325 *
326 * @param string $option Option name without prefix (e.g., 'ai_api_key')
327 * @param mixed $default Default value if option not found
328 * @return mixed Option value
329 */
330 public function get_global_option( $option, $default = '' ) {
331 return get_option( 'wpforo_' . $option, $default );
332 }
333
334 /**
335 * Update a global AI option (shared across all boards)
336 *
337 * Uses base prefix 'wpforo_' regardless of current board context.
338 *
339 * @param string $option Option name without prefix (e.g., 'ai_api_key')
340 * @param mixed $value Value to save
341 * @return bool True on success
342 */
343 public function update_global_option( $option, $value ) {
344 $result = update_option( 'wpforo_' . $option, $value );
345 wpforo_clean_cache( 'option' );
346 return $result;
347 }
348
349 /**
350 * Delete a global AI option (shared across all boards)
351 *
352 * Uses base prefix 'wpforo_' regardless of current board context.
353 *
354 * @param string $option Option name without prefix (e.g., 'ai_api_key')
355 * @return bool True on success
356 */
357 public function delete_global_option( $option ) {
358 $result = delete_option( 'wpforo_' . $option );
359 wpforo_clean_cache( 'option' );
360 return $result;
361 }
362
363 /**
364 * Get the API key (global option)
365 *
366 * @return string Encrypted API key or empty string
367 */
368 public function get_api_key() {
369 return $this->get_global_option( 'ai_api_key', '' );
370 }
371
372 /**
373 * Get the tenant ID (global option)
374 *
375 * @return string Tenant ID or empty string
376 */
377 public function get_tenant_id() {
378 return $this->get_global_option( 'ai_tenant_id', '' );
379 }
380
381 /**
382 * Check if tenant is connected to AI service
383 *
384 * @return bool True if API key and tenant ID are configured
385 */
386 public function is_connected() {
387 $api_key = $this->get_api_key();
388 $tenant_id = $this->get_tenant_id();
389 return ! empty( $api_key ) && ! empty( $tenant_id );
390 }
391
392 /**
393 * Check if AI service is available for use
394 *
395 * This checks both connection AND subscription status.
396 * AI features should only work when:
397 * 1. Tenant is connected (has API key and tenant ID)
398 * 2. Subscription status is active or trial
399 *
400 * Returns false for: inactive, pending_approval, disconnected, expired
401 *
402 * @return bool True if AI features can be used
403 */
404 public function is_service_available() {
405 // First check connection
406 if ( ! $this->is_connected() ) {
407 return false;
408 }
409
410 // Use cached subscription status from database options (no API calls)
411 // Status is synced when:
412 // 1. AI Features > Overview tab is loaded
413 // 2. User clicks refresh button
414 // 3. On first connection/registration
415 $sub_status = $this->get_subscription_status();
416
417 // Only allow active and trial statuses
418 return in_array( $sub_status, [ 'active', 'trial' ], true );
419 }
420
421 /**
422 * Check API health status
423 *
424 * @return array|WP_Error Health status or error object
425 */
426 public function health_check() {
427 $response = $this->get( '/tenant/health' );
428
429 if ( is_wp_error( $response ) ) {
430 $this->log_error( 'health_check_failed', $response->get_error_message() );
431 return $response;
432 }
433
434 return $response;
435 }
436
437 /**
438 * Register new tenant and generate API key
439 *
440 * Creates a new tenant account with free trial (500 credits, 30 days)
441 *
442 * @return array|WP_Error Response data or error object
443 */
444 public function register_tenant() {
445 $site_url = get_site_url();
446
447 // For localhost development, allow URL override via filter or constant
448 if ( strpos( $site_url, 'localhost' ) !== false || strpos( $site_url, '127.0.0.1' ) !== false ) {
449 // Check for development mode override
450 if ( defined( 'WPFORO_AI_DEV_URL' ) && WPFORO_AI_DEV_URL ) {
451 $site_url = WPFORO_AI_DEV_URL;
452 }
453 }
454
455 $data = [
456 'site_url' => $site_url,
457 'admin_email' => get_option( 'admin_email' ),
458 'wordpress_version' => get_bloginfo( 'version' ),
459 'wpforo_version' => defined( 'WPFORO_VERSION' ) ? WPFORO_VERSION : 'unknown',
460 'site_name' => get_bloginfo( 'name' ),
461 'language' => get_bloginfo( 'language' ),
462 'timezone' => wp_timezone_string(),
463 ];
464
465 // Allow filtering registration data
466 $data = apply_filters( 'wpforo_ai_registration_data', $data );
467
468 // Log the registration attempt
469 $this->log_info( 'attempting_tenant_registration', [
470 'site_url' => $data['site_url'],
471 'admin_email' => $data['admin_email'],
472 ] );
473
474 $response = $this->post( '/tenant/register', $data );
475
476 if ( is_wp_error( $response ) ) {
477 $this->log_error( 'registration_failed', $response->get_error_message() );
478 return $response;
479 }
480
481 // Log successful registration
482 $this->log_info( 'tenant_registered', [
483 'tenant_id' => wpfval( $response, 'tenant_id' ),
484 'plan' => wpfval( $response, 'subscription', 'plan' ),
485 ] );
486
487 do_action( 'wpforo_ai_tenant_registered', $response );
488
489 return $response;
490 }
491
492 /**
493 * Get current tenant status and subscription info
494 *
495 * @param bool $force_fresh Whether to bypass the cache and fetch fresh data
496 * @return array|WP_Error Status data or error object
497 */
498 public function get_tenant_status( $force_fresh = false ) {
499 // Check cache first (5 minute cache)
500 $cache_key = 'wpforo_ai_tenant_status';
501 $cached = get_transient( $cache_key );
502
503 if ( false !== $cached && ! $force_fresh && ! $this->is_debug_mode() ) {
504 return $cached;
505 }
506
507 // Validate credentials exist before making API call (use global options)
508 $api_key = $this->get_stored_api_key();
509 $tenant_id = $this->get_tenant_id();
510
511 if ( empty( $api_key ) || empty( $tenant_id ) ) {
512 return new \WP_Error(
513 'no_credentials',
514 wpforo_phrase( 'No credentials found. Please connect to the service first.', false )
515 );
516 }
517
518 $response = $this->get( '/tenant/status' );
519
520 if ( is_wp_error( $response ) ) {
521 $this->log_error( 'status_fetch_failed', $response->get_error_message() );
522 return $response;
523 }
524
525 // Cache the response for 5 minutes
526 set_transient( $cache_key, $response, 5 * MINUTE_IN_SECONDS );
527
528 // Also update persistent subscription info for frontend feature gating
529 // This allows checking plan without API calls on every page load
530 $this->update_cached_subscription_info( $response );
531
532 return $response;
533 }
534
535 /**
536 * Update cached subscription info from status response
537 *
538 * Stores plan and features in WordPress options for quick access
539 * without making API calls on every page load.
540 *
541 * @param array $status_response Response from /tenant/status API
542 */
543 private function update_cached_subscription_info( $status_response ) {
544 if ( ! is_array( $status_response ) ) {
545 return;
546 }
547
548 $subscription = isset( $status_response['subscription'] ) ? $status_response['subscription'] : [];
549 $features_enabled = isset( $status_response['features_enabled'] ) ? $status_response['features_enabled'] : [];
550
551 // Store subscription status (e.g., 'active', 'trial', 'inactive', 'pending_approval')
552 $sub_status = isset( $subscription['status'] ) ? sanitize_text_field( $subscription['status'] ) : '';
553 $this->update_global_option( 'ai_subscription_status', $sub_status );
554
555 // Store plan (e.g., 'free_trial', 'starter', 'professional', 'business', 'enterprise')
556 $plan = isset( $subscription['plan'] ) ? sanitize_text_field( $subscription['plan'] ) : 'free_trial';
557 $this->update_global_option( 'ai_subscription_plan', $plan );
558
559 // Store features enabled (array of feature IDs)
560 $this->update_global_option( 'ai_features_enabled', array_map( 'sanitize_text_field', $features_enabled ) );
561
562 // Store payment provider (freemius, paddle, or empty for free trial)
563 $payment_provider = isset( $subscription['payment_provider'] ) ? sanitize_text_field( $subscription['payment_provider'] ) : '';
564 if ( $payment_provider ) {
565 update_option( 'wpforo_ai_payment_provider', $payment_provider );
566 }
567
568 // Store all payment providers list (for tenants with both Freemius and Paddle)
569 if ( isset( $subscription['payment_providers'] ) && is_array( $subscription['payment_providers'] ) ) {
570 update_option( 'wpforo_ai_payment_providers', array_map( 'sanitize_text_field', $subscription['payment_providers'] ) );
571 }
572
573 // Store last sync time for debugging
574 $this->update_global_option( 'ai_subscription_synced_at', current_time( 'mysql', true ) );
575 }
576
577 /**
578 * Get cached subscription plan
579 *
580 * Returns the plan stored in WordPress options.
581 * This doesn't make API calls - use get_tenant_status() to refresh.
582 *
583 * @return string Plan name (free_trial, starter, professional, business, enterprise)
584 */
585 public function get_subscription_plan() {
586 return $this->get_global_option( 'ai_subscription_plan', 'free_trial' );
587 }
588
589 /**
590 * Get cached subscription status
591 *
592 * Returns the subscription status stored in WordPress options.
593 * This doesn't make API calls - use get_tenant_status() to refresh.
594 *
595 * @return string Status (active, trial, inactive, pending_approval, etc.)
596 */
597 public function get_subscription_status() {
598 return $this->get_global_option( 'ai_subscription_status', '' );
599 }
600
601 /**
602 * Get cached features enabled list
603 *
604 * Returns the features_enabled array from last status sync.
605 *
606 * @return array List of enabled feature IDs
607 */
608 public function get_features_enabled() {
609 $features = $this->get_global_option( 'ai_features_enabled', [] );
610 return is_array( $features ) ? $features : [];
611 }
612
613 /**
614 * Check if a specific feature is available based on subscription plan
615 *
616 * This method checks locally cached plan data to avoid API calls.
617 * Use this for frontend feature gating (showing/hiding UI elements).
618 *
619 * Note: Backend APIs still verify plan independently for security.
620 *
621 * @param string $feature_id Feature identifier (e.g., 'ai_assistant_chatbot', 'multi_language_translation')
622 * @return bool True if feature is available for current plan
623 */
624 public function is_feature_available( $feature_id ) {
625 // Service must be available (connected + active subscription)
626 if ( ! $this->is_service_available() ) {
627 return false;
628 }
629
630 // Get current plan from cache
631 $current_plan = $this->get_subscription_plan();
632
633 // Get feature definitions to find required plan
634 $all_features = $this->get_feature_definitions();
635 $feature = isset( $all_features[ $feature_id ] ) ? $all_features[ $feature_id ] : null;
636
637 // Unknown feature - deny by default
638 if ( ! $feature ) {
639 return false;
640 }
641
642 $required_plan = isset( $feature['plan'] ) ? $feature['plan'] : 'enterprise';
643
644 // Check if current plan meets requirement
645 return $this->plan_meets_requirement( $current_plan, $required_plan );
646 }
647
648 /**
649 * Check if current plan meets or exceeds required plan level
650 *
651 * @param string $current_plan Current subscription plan
652 * @param string $required_plan Required plan for feature
653 * @return bool True if current plan is sufficient
654 */
655 private function plan_meets_requirement( $current_plan, $required_plan ) {
656 // Plan hierarchy (lower to higher)
657 $plan_hierarchy = [
658 'free_trial' => 0,
659 'starter' => 0, // Starter and free_trial are same level
660 'professional' => 1,
661 'business' => 2,
662 'enterprise' => 3,
663 ];
664
665 $current_level = isset( $plan_hierarchy[ $current_plan ] ) ? $plan_hierarchy[ $current_plan ] : 0;
666 $required_level = isset( $plan_hierarchy[ $required_plan ] ) ? $plan_hierarchy[ $required_plan ] : 0;
667
668 return $current_level >= $required_level;
669 }
670
671 /**
672 * Get feature definitions with plan requirements
673 *
674 * Returns a simplified version of feature definitions for plan checking.
675 * This is a subset of what wpforo_ai_get_all_features() returns.
676 *
677 * @return array Feature ID => ['plan' => required_plan]
678 */
679 private function get_feature_definitions() {
680 return [
681 // Starter Plan Features (also available on free_trial)
682 'semantic_search' => [ 'plan' => 'starter' ],
683 'search_enhance' => [ 'plan' => 'starter' ],
684 'content_indexing' => [ 'plan' => 'starter' ],
685 'multi_language_translation' => [ 'plan' => 'starter' ],
686 'topic_summary' => [ 'plan' => 'starter' ],
687 'smart_topic_suggestions' => [ 'plan' => 'starter' ],
688 'ai_spam_detection' => [ 'plan' => 'starter' ],
689 'ai_toxicity_detection' => [ 'plan' => 'starter' ],
690 'ai_rule_compliance' => [ 'plan' => 'starter' ],
691
692 // Professional Plan Features
693 'analytics_insights' => [ 'plan' => 'professional' ],
694 'ai_topic_generator' => [ 'plan' => 'professional' ],
695 'ai_reply_generator' => [ 'plan' => 'professional' ],
696 'ai_bot_reply' => [ 'plan' => 'professional' ],
697 'auto_tag_generation' => [ 'plan' => 'professional' ],
698
699 // Business Plan Features
700 'ai_assistant_chatbot' => [ 'plan' => 'business' ],
701 'extended_knowledge_base' => [ 'plan' => 'business' ],
702 'wordpress_content_indexing' => [ 'plan' => 'business' ],
703 'custom_post_types_indexing' => [ 'plan' => 'business' ],
704 'woocommerce_products_indexing' => [ 'plan' => 'business' ],
705 'vector_db_cloud_storage' => [ 'plan' => 'business' ],
706 'custom_knowledge' => [ 'plan' => 'business' ],
707
708 // Enterprise Plan Features
709 'developer_features' => [ 'plan' => 'enterprise' ],
710 'rest_api_access' => [ 'plan' => 'enterprise' ],
711 'custom_ai_models' => [ 'plan' => 'enterprise' ],
712 'custom_feature_development' => [ 'plan' => 'enterprise' ],
713 'premium_support' => [ 'plan' => 'enterprise' ],
714 'dedicated_account_manager' => [ 'plan' => 'enterprise' ],
715 'enterprise_capabilities' => [ 'plan' => 'enterprise' ],
716 ];
717 }
718
719 /**
720 * Clear cached tenant status
721 * Forces fresh fetch on next status request
722 */
723 public function clear_status_cache() {
724 delete_transient( 'wpforo_ai_tenant_status' );
725 }
726
727 /**
728 * Get indexed topic statistics by forum
729 *
730 * Returns indexed topic counts per forum for displaying in admin UI
731 *
732 * @return array|WP_Error Response data with forum_counts or error object
733 */
734 public function get_indexed_stats_by_forum() {
735 $response = $this->get( '/rag/indexed-stats/forums' );
736
737 if ( is_wp_error( $response ) ) {
738 $this->log_error( 'indexed_stats_fetch_failed', $response->get_error_message() );
739 return $response;
740 }
741
742 return $response;
743 }
744
745 /**
746 * Disconnect service (soft delete)
747 *
748 * @param string $reason Reason for disconnection
749 * @param bool $confirm Confirmation flag
750 * @return array|WP_Error Response data or error object
751 */
752 public function disconnect_tenant( $reason = '', $confirm = false, $purge_data = false ) {
753 $data = [
754 'reason' => sanitize_text_field( $reason ),
755 'confirm' => (bool) $confirm,
756 'purge_data' => (bool) $purge_data,
757 ];
758
759 $response = $this->delete( '/tenant/disconnect', $data );
760
761 if ( is_wp_error( $response ) ) {
762 $this->log_error( 'disconnection_failed', $response->get_error_message() );
763 return $response;
764 }
765
766 $this->log_info( 'tenant_disconnected', [ 'reason' => $reason ] );
767 $this->clear_status_cache();
768
769 // Clear indexed status for all topics (vectors are deleted on disconnect)
770 $this->clear_topics_indexed_status();
771
772 do_action( 'wpforo_ai_tenant_disconnected', $response );
773
774 return $response;
775 }
776
777 /**
778 * Check eligibility for bonus credits (large forum incentive)
779 *
780 * @return array Eligibility data with 'eligible' boolean and 'data' array
781 */
782 public function check_bonus_credits_eligibility() {
783 global $wpdb;
784
785 // Get wpforo table names
786 $topics_table = WPF()->tables->topics ?? $wpdb->prefix . 'wpforo_topics';
787 $posts_table = WPF()->tables->posts ?? $wpdb->prefix . 'wpforo_posts';
788 $profile_table = WPF()->tables->profiles ?? $wpdb->prefix . 'wpforo_profiles';
789
790 // 1. Count approved, non-private topics (status=0, private=0)
791 $topic_count = (int) $wpdb->get_var(
792 "SELECT COUNT(*) FROM {$topics_table} WHERE status = 0 AND private = 0"
793 );
794
795 // 2. Count approved posts (status=0)
796 $post_count = (int) $wpdb->get_var(
797 "SELECT COUNT(*) FROM {$posts_table} WHERE status = 0"
798 );
799
800 // 3. Get days between first and last topic
801 $date_range = $wpdb->get_row(
802 "SELECT
803 MIN(created) as first_topic,
804 MAX(created) as last_topic
805 FROM {$topics_table}
806 WHERE status = 0 AND private = 0"
807 );
808
809 $days_active = 0;
810 if ( $date_range && $date_range->first_topic && $date_range->last_topic ) {
811 $first_time = strtotime( $date_range->first_topic );
812 $last_time = strtotime( $date_range->last_topic );
813 $days_active = (int) floor( ( $last_time - $first_time ) / DAY_IN_SECONDS );
814 }
815
816 // 4. Count distinct topic authors who have login history (online_time > 0)
817 $active_authors = (int) $wpdb->get_var(
818 "SELECT COUNT(DISTINCT t.userid)
819 FROM {$topics_table} t
820 INNER JOIN {$profile_table} p ON t.userid = p.userid
821 WHERE t.status = 0 AND t.private = 0 AND t.userid > 0 AND p.online_time > 0"
822 );
823
824 // Determine eligibility
825 $eligible = (
826 $topic_count >= 501 &&
827 $post_count >= 511 &&
828 $days_active >= 30 &&
829 $active_authors >= 10
830 );
831
832 return [
833 'eligible' => $eligible,
834 'data' => [
835 'topic_count' => $topic_count,
836 'post_count' => $post_count,
837 'days_active' => $days_active,
838 'active_authors' => $active_authors,
839 ],
840 'requirements' => [
841 'min_topics' => 501,
842 'min_posts' => 511,
843 'min_days' => 30,
844 'min_active_authors' => 10,
845 ],
846 ];
847 }
848
849 /**
850 * Request bonus credits from API
851 *
852 * @param array $eligibility_data Data from check_bonus_credits_eligibility()
853 * @return array|WP_Error Response with credits_added or error
854 */
855 public function request_bonus_credits( $eligibility_data ) {
856 $response = $this->post( '/tenant/bonus-credits', [
857 'topic_count' => (int) $eligibility_data['topic_count'],
858 'post_count' => (int) $eligibility_data['post_count'],
859 'days_active' => (int) $eligibility_data['days_active'],
860 'active_authors' => (int) $eligibility_data['active_authors'],
861 ] );
862
863 if ( is_wp_error( $response ) ) {
864 $this->log_error( 'bonus_credits_failed', $response->get_error_message() );
865 return $response;
866 }
867
868 // Store bonus credits info locally
869 // Use isset() instead of !empty() for credits_added — empty(0) is true in PHP,
870 // which would skip saving when credits_added is 0 (e.g., due to cap enforcement)
871 if ( ! empty( $response['success'] ) && isset( $response['credits_added'] ) ) {
872 update_option( 'wpforo_ai_bonus_credits_claimed', true );
873 update_option( 'wpforo_ai_bonus_credits_amount', (int) $response['credits_added'] );
874 update_option( 'wpforo_ai_bonus_credits_claimed_at', current_time( 'mysql', true ) );
875
876 $this->log_info( 'bonus_credits_granted', [
877 'credits_added' => $response['credits_added'],
878 ] );
879
880 // Clear status cache to reflect new credits
881 $this->clear_status_cache();
882 }
883
884 return $response;
885 }
886
887 /**
888 * AJAX handler for requesting bonus credits
889 *
890 * @return void
891 */
892 public function ajax_request_bonus_credits() {
893 $this->verify_ajax_admin_request( 'wpforo_ai_features_nonce', '_wpnonce' );
894
895 // Check if already claimed locally
896 if ( get_option( 'wpforo_ai_bonus_credits_claimed', false ) ) {
897 $this->send_error(
898 wpforo_phrase( 'Bonus credits have already been claimed.', false ),
899 [ 'code' => 'already_claimed' ]
900 );
901 }
902
903 // Check eligibility
904 $eligibility = $this->check_bonus_credits_eligibility();
905
906 if ( ! $eligibility['eligible'] ) {
907 $this->send_error(
908 wpforo_phrase( 'Forum does not meet eligibility requirements for bonus credits.', false ),
909 [
910 'code' => 'not_eligible',
911 'requirements' => $eligibility['requirements'],
912 'current' => $eligibility['data'],
913 ]
914 );
915 }
916
917 // Request bonus credits from API
918 $response = $this->request_bonus_credits( $eligibility['data'] );
919
920 if ( is_wp_error( $response ) ) {
921 $this->send_error( $response->get_error_message() );
922 }
923
924 $this->send_success( [
925 'message' => $response['message'] ?? wpforo_phrase( 'Bonus credits added successfully!', false ),
926 'credits_added' => $response['credits_added'] ?? 0,
927 ] );
928 }
929
930 /**
931 * Check if bonus credits have been claimed
932 *
933 * @return bool|array False if not claimed, array with details if claimed
934 */
935 public function get_bonus_credits_status() {
936 $claimed = get_option( 'wpforo_ai_bonus_credits_claimed', false );
937
938 if ( ! $claimed ) {
939 return false;
940 }
941
942 return [
943 'claimed' => true,
944 'amount' => (int) get_option( 'wpforo_ai_bonus_credits_amount', 0 ),
945 'claimed_at' => get_option( 'wpforo_ai_bonus_credits_claimed_at', '' ),
946 ];
947 }
948
949 /**
950 * Get AI Content Indexing status
951 *
952 * Returns current status of AI Content Indexing including total indexed threads,
953 * progress, and whether indexing is currently active.
954 *
955 * @return array|WP_Error Status data or error object
956 */
957 public function get_rag_status( $boardid = 0 ) {
958 // Check cache first (30 second cache for frequent updates)
959 // Board-specific cache key
960 $cache_key = 'wpforo_ai_rag_status_' . intval( $boardid );
961 $cached = get_transient( $cache_key );
962
963 if ( false !== $cached && ! $this->is_debug_mode() ) {
964 return $cached;
965 }
966
967 // Include boardid in API request for future backend filtering
968 $endpoint = '/rag/status';
969 if ( $boardid > 0 ) {
970 $endpoint .= '?boardid=' . intval( $boardid );
971 }
972
973 $response = $this->get( $endpoint );
974
975 if ( is_wp_error( $response ) ) {
976 $this->log_error( 'rag_status_fetch_failed', $response->get_error_message() );
977 return $response;
978 }
979
980 // Cache the response for 30 seconds (short cache for indexing status)
981 set_transient( $cache_key, $response, 30 );
982
983 return $response;
984 }
985
986 /**
987 * AJAX handler for getting RAG status
988 *
989 * @return void
990 */
991 public function ajax_get_rag_status() {
992 $this->verify_ajax_admin_request( 'wpforo_ai_features_nonce', '_wpnonce' );
993
994 // Use VectorStorageManager to get stats (routes to local or cloud automatically)
995 $storage_manager = WPF()->vector_storage;
996 $status = $storage_manager->get_indexing_stats();
997
998 // Clear tenant status cache to get fresh credit info
999 $this->clear_status_cache();
1000
1001 // Add tenant status info for credits display (fresh fetch)
1002 $tenant_status = $this->get_tenant_status();
1003 if ( ! is_wp_error( $tenant_status ) ) {
1004 $status['tenant_id'] = $tenant_status['tenant_id'] ?? '';
1005 $status['credits'] = $tenant_status['credits'] ?? [];
1006 $status['subscription_tier'] = $tenant_status['subscription_tier'] ?? '';
1007 }
1008
1009 // Add pending cron jobs info
1010 $pending_jobs = $this->get_pending_cron_jobs();
1011 $status['pending_cron_jobs'] = $pending_jobs;
1012
1013 $this->send_success( $status );
1014 }
1015
1016 /**
1017 * AJAX handler for getting indexing status breakdown (private/unapproved counts)
1018 *
1019 * Returns cached breakdown data (1-day TTL) for displaying excluded topics info.
1020 * Loaded asynchronously after page load to avoid slow initial page renders.
1021 *
1022 * @return void
1023 */
1024 public function ajax_get_indexing_breakdown() {
1025 $this->verify_ajax_admin_request( 'wpforo_ai_features_nonce', 'nonce' );
1026
1027 $storage_manager = WPF()->vector_storage;
1028 $breakdown = $storage_manager->get_indexing_status_breakdown();
1029
1030 $this->send_success( $breakdown );
1031 }
1032
1033 /**
1034 * Ask the backend to stop any in-flight cloud indexing for this tenant.
1035 *
1036 * Sends POST /v1/rag/cancel, which sets a cancellation flag on the
1037 * tenant record with a ~10 minute TTL. The backend reads the flag
1038 * at the start of every queued message and skips (without charging
1039 * credits) anything still pending. Already in-flight processing
1040 * calls are allowed to finish and are billed.
1041 *
1042 * @return array|WP_Error Backend response or error object
1043 */
1044 public function cancel_indexing() {
1045 // Note: /rag/cancel was replaced by /rag/cleanup-jobs in backend
1046 // Both set indexing_cancel_until flag, cleanup-jobs also finalizes stuck jobs
1047 return $this->post( '/rag/cleanup-jobs', [], 30 );
1048 }
1049
1050 /**
1051 * Cleanup stuck cloud indexing jobs (backend).
1052 *
1053 * Calls POST /v1/rag/cleanup-jobs — scans the tenant's rag_jobs
1054 * records, force-finalizes anything stuck (media_done < media_total,
1055 * created >1h ago), refunds the unused portion of the image/document
1056 * credit reservation, and sets indexing_cancel_until so any in-flight
1057 * SQS messages drain without charging.
1058 *
1059 * Idempotent: backend uses a conditional update on
1060 * media_credits_charged, so re-clicking is safe — no double refund.
1061 * Safe in local storage mode: tenants with no rag_jobs get a zero-count
1062 * response with no side effects.
1063 *
1064 * @return array|WP_Error Backend response: status, jobs_scanned,
1065 * jobs_finalized, refund_images, refund_docs, ...
1066 */
1067 public function cleanup_jobs() {
1068 return $this->post( '/rag/cleanup-jobs', [], 30 );
1069 }
1070
1071 /**
1072 * AJAX handler: stop in-flight cloud indexing.
1073 *
1074 * Mirrors the local-mode `clearLocalIndexingQueue` flow — no page
1075 * reload, UI polling will pick up the drained state via the regular
1076 * `/rag/status` poll.
1077 *
1078 * @return void
1079 */
1080 public function ajax_cancel_cloud_indexing() {
1081 $this->verify_ajax_admin_request( 'wpforo_ai_features_nonce', '_wpnonce' );
1082
1083 // Clear any WordPress cron jobs scheduled for indexing (cloud/local/legacy
1084 // hooks) and the per-board queue options. The legacy form-submit Stop
1085 // flow did this via wpforo_ai_handle_stop_indexing(); the new AJAX path
1086 // must do the same or orphaned crons continue firing after Stop.
1087 $this->clear_pending_cron_jobs();
1088
1089 $result = $this->cancel_indexing();
1090
1091 if ( is_wp_error( $result ) ) {
1092 $this->send_error(
1093 wpforo_phrase( 'Failed to stop indexing. Please try again.', false ),
1094 500
1095 );
1096 }
1097
1098 $this->send_success( [
1099 'message' => wpforo_phrase( 'Indexing is being stopped. In-flight items may take a few minutes to drain.', false ),
1100 ] );
1101 }
1102
1103 /**
1104 * Cleanup stuck indexing session state.
1105 *
1106 * Resets all "in-progress" markers (queues, crons, locks, caches, backend
1107 * cancel flag) for either the forum-indexing pipeline or the WordPress
1108 * content indexing pipeline — without touching any successfully-indexed
1109 * data (vectors, wpforo_ai_local_vectors rows, topics.cloud/local/indexed
1110 * columns, credit counters).
1111 *
1112 * Covers BOTH local and cloud storage modes in a single call: forum
1113 * cleanup clears local queues, cloud queues, legacy queues, and tells the
1114 * backend worker to drop any queued items.
1115 *
1116 * @param string $scope 'forum' | 'wp' | 'all'
1117 * @return array Summary of what was cleared.
1118 */
1119 public function cleanup_indexing_session( $scope = 'forum' ) {
1120 $board_id = WPF()->board->get_current( 'boardid' ) ?: 0;
1121 $summary = [
1122 'scope' => $scope,
1123 'options_deleted' => 0,
1124 'transients_deleted' => 0,
1125 'crons_cleared' => 0,
1126 'topics_cleared' => 0,
1127 'backend_cancelled' => false,
1128 'backend_cleanup' => false,
1129 ];
1130
1131 if ( $scope === 'forum' || $scope === 'all' ) {
1132 // Reuse the existing helper — it clears local/cloud/legacy queue
1133 // options AND their cron hooks for the current board, including
1134 // the legacy wpforo_ai_process_batch pattern.
1135 $cron_result = $this->clear_pending_cron_jobs();
1136 $summary['topics_cleared'] += (int) ( $cron_result['cleared_topics'] ?? 0 );
1137 $summary['crons_cleared'] += (int) ( $cron_result['cleared_jobs'] ?? 0 );
1138
1139 // Session snapshot (chunk_size/overlap/batch_size/total/started_at)
1140 if ( delete_option( 'wpforo_ai_indexing_settings_' . $board_id ) ) {
1141 $summary['options_deleted']++;
1142 }
1143
1144 // Per-board batch locks (serialize AJAX + WP-Cron processing).
1145 // Clear all variants: legacy, local-mode, and cloud-mode locks.
1146 // If any are stale, new batches refuse to run until TTL expires.
1147 if ( delete_transient( 'wpforo_ai_indexing_lock_' . $board_id ) ) {
1148 $summary['transients_deleted']++;
1149 }
1150 if ( delete_transient( 'wpforo_ai_indexing_lock_local_' . $board_id ) ) {
1151 $summary['transients_deleted']++;
1152 }
1153 if ( delete_transient( 'wpforo_ai_indexing_lock_cloud_' . $board_id ) ) {
1154 $summary['transients_deleted']++;
1155 }
1156
1157 // Global 5-min "clearing in progress" semaphore. If set while
1158 // a clear operation crashed, it blocks the UI for up to 5 minutes.
1159 if ( delete_transient( 'wpforo_ai_clearing_in_progress' ) ) {
1160 $summary['transients_deleted']++;
1161 }
1162
1163 // Force-refresh the cached RAG status so the UI flips to Idle
1164 // immediately after cleanup.
1165 if ( delete_transient( 'wpforo_ai_rag_status' ) ) {
1166 $summary['transients_deleted']++;
1167 }
1168 if ( delete_transient( 'wpforo_ai_rag_status_' . $board_id ) ) {
1169 $summary['transients_deleted']++;
1170 }
1171
1172 // Clear indexing breakdown cache so UI shows fresh counts
1173 WPF()->vector_storage->clear_indexing_status_breakdown_cache();
1174
1175 // Tell the backend to drop any in-flight cloud image_worker items.
1176 // Safe in local mode: backend simply sets indexing_cancel_until on
1177 // the tenant record with no other side effects. Errors are
1178 // non-fatal — WP-side cleanup has already succeeded.
1179 $cancel = $this->cancel_indexing();
1180 $summary['backend_cancelled'] = ! is_wp_error( $cancel );
1181
1182 // Force-finalize any stuck rag_jobs server-side and refund the
1183 // unused portion of the image/document credit reservation.
1184 // Idempotent and tenant-wide; safe in local mode (no jobs to
1185 // finalize). Errors are non-fatal — WP-side cleanup has already
1186 // succeeded and the backend reaper cron runs hourly anyway.
1187 $cleanup = $this->cleanup_jobs();
1188 $summary['backend_cleanup'] = is_wp_error( $cleanup ) ? false : $cleanup;
1189 }
1190
1191 if ( $scope === 'wp' || $scope === 'all' ) {
1192 // WordPress post/page indexing queue (single global key, not
1193 // board-scoped — WP content is global).
1194 if ( delete_option( 'wpforo_ai_wp_indexing_queue' ) ) {
1195 $summary['options_deleted']++;
1196 }
1197
1198 // WP indexing status cache (5-min TTL) — deleting it forces the
1199 // UI to query fresh state.
1200 if ( delete_transient( 'wpforo_ai_wp_indexing_status' ) ) {
1201 $summary['transients_deleted']++;
1202 }
1203
1204 // WP indexing lock transient — must be cleared so new indexing
1205 // can start immediately after stop/clear. Without this, user
1206 // would have to wait up to 300s for the lock to auto-expire.
1207 if ( delete_transient( 'wpforo_ai_wp_indexing_lock' ) ) {
1208 $summary['transients_deleted']++;
1209 }
1210
1211 // WP post/page batch cron (single-event, no args, reschedules
1212 // itself). wp_clear_scheduled_hook removes all pending events.
1213 if ( wp_next_scheduled( 'wpforo_ai_process_wp_batch' ) ) {
1214 $summary['crons_cleared']++;
1215 }
1216 wp_clear_scheduled_hook( 'wpforo_ai_process_wp_batch' );
1217
1218 // For cloud mode: cancel in-flight backend jobs and cleanup stuck
1219 // rag_jobs. These APIs are tenant-wide (not scope-specific), so
1220 // calling them for WP scope ensures stuck cloud jobs are handled
1221 // even if user only uses WordPress indexing. Safe in local mode:
1222 // backend simply ignores the request with no side effects.
1223 if ( $scope === 'wp' ) {
1224 $cancel = $this->cancel_indexing();
1225 $summary['backend_cancelled'] = ! is_wp_error( $cancel );
1226
1227 $cleanup = $this->cleanup_jobs();
1228 $summary['backend_cleanup'] = is_wp_error( $cleanup ) ? false : $cleanup;
1229 }
1230 }
1231
1232 return $summary;
1233 }
1234
1235 /**
1236 * AJAX handler: cleanup stuck indexing session state.
1237 *
1238 * POST params:
1239 * scope — 'forum' | 'wp' | 'all' (default 'forum')
1240 * _wpnonce — wpforo_ai_features_nonce
1241 *
1242 * @return void
1243 */
1244 public function ajax_cleanup_indexing_session() {
1245 $this->verify_ajax_admin_request( 'wpforo_ai_features_nonce', '_wpnonce' );
1246
1247 $scope = $this->get_post_param( 'scope', 'forum' );
1248 if ( ! in_array( $scope, [ 'forum', 'wp', 'all' ], true ) ) {
1249 $scope = 'forum';
1250 }
1251
1252 $summary = $this->cleanup_indexing_session( $scope );
1253
1254 $this->send_success( [
1255 'summary' => $summary,
1256 'message' => wpforo_phrase( 'Indexing session cleaned up. Stuck jobs, queues and cached state have been cleared.', false ),
1257 ] );
1258 }
1259
1260 /**
1261 * AJAX handler for saving storage mode setting
1262 *
1263 * @return void
1264 */
1265 public function ajax_save_storage_mode() {
1266 $this->verify_ajax_admin_request( 'wpforo_ai_features_nonce', 'nonce' );
1267
1268 // Get and validate storage mode
1269 $storage_mode = $this->get_post_param( 'storage_mode', 'local' );
1270 if ( ! in_array( $storage_mode, [ 'local', 'cloud' ], true ) ) {
1271 $storage_mode = 'local';
1272 }
1273
1274 $board_id = $this->get_post_param( 'board_id', 0, 'int' );
1275
1276 // Save the setting
1277 $option_name = 'wpforo_ai_storage_mode_' . $board_id;
1278 $old_mode = get_option( $option_name, 'local' );
1279 update_option( $option_name, $storage_mode );
1280
1281 // If mode changed, sync the indexed status for the new mode
1282 $sync_result = null;
1283 if ( $old_mode !== $storage_mode ) {
1284 // Reset cached mode in VectorStorageManager
1285 WPF()->vector_storage->reset_storage_mode_cache();
1286
1287 // Sync indexed status based on new mode
1288 if ( $storage_mode === 'local' ) {
1289 $sync_result = WPF()->vector_storage->sync_local_indexed_status();
1290 } else {
1291 $sync_result = WPF()->vector_storage->sync_cloud_indexed_status();
1292 }
1293 }
1294
1295 // Log the change
1296 $this->log_info( 'storage_mode_changed', [
1297 'board_id' => $board_id,
1298 'old_mode' => $old_mode,
1299 'storage_mode' => $storage_mode,
1300 'sync_result' => is_wp_error( $sync_result ) ? $sync_result->get_error_message() : $sync_result
1301 ] );
1302
1303 $response_data = [
1304 'message' => wpforo_phrase( 'Storage mode saved successfully.', false ),
1305 'storage_mode' => $storage_mode,
1306 'board_id' => $board_id
1307 ];
1308
1309 if ( $sync_result && ! is_wp_error( $sync_result ) ) {
1310 $response_data['sync'] = $sync_result;
1311 }
1312
1313 $this->send_success( $response_data );
1314 }
1315
1316 /**
1317 * AJAX handler to save auto-indexing setting
1318 *
1319 * Saves the auto-indexing enabled/disabled state for a specific board.
1320 * When enabled, new and approved topics will be automatically queued for indexing.
1321 *
1322 * @return void Sends JSON response
1323 */
1324 public function ajax_save_auto_indexing() {
1325 $this->verify_ajax_admin_request( 'wpforo_ai_features_nonce', 'nonce' );
1326
1327 $enabled = $this->get_post_param( 'enabled', 0, 'bool' ) ? 1 : 0;
1328 $board_id = $this->get_post_param( 'board_id', 0, 'int' );
1329
1330 // Switch to the correct board context
1331 if ( $board_id > 0 ) {
1332 WPF()->change_board( $board_id );
1333 }
1334
1335 // Save the setting using wpforo options (board-specific)
1336 wpforo_update_option( 'ai_auto_indexing_enabled', $enabled );
1337
1338 // If enabling auto-indexing, schedule the cron jobs
1339 if ( $enabled ) {
1340 $this->schedule_pending_topics_indexing();
1341 } else {
1342 $this->unschedule_pending_topics_indexing();
1343 }
1344
1345 // Log the change
1346 $this->log_info( 'auto_indexing_changed', [
1347 'board_id' => $board_id,
1348 'enabled' => $enabled
1349 ] );
1350
1351 $this->send_success( [
1352 'message' => $enabled
1353 ? wpforo_phrase( 'Auto-indexing enabled successfully.', false )
1354 : wpforo_phrase( 'Auto-indexing disabled successfully.', false ),
1355 'enabled' => $enabled,
1356 'board_id' => $board_id
1357 ] );
1358 }
1359
1360 /**
1361 * AJAX handler to save image indexing setting
1362 *
1363 * Saves the image indexing enabled/disabled state for a specific board.
1364 * When enabled, posts with images will consume +1 additional credit for
1365 * multimodal processing (image → text description → embedding).
1366 *
1367 * Feature Requirements:
1368 * - Business or Enterprise plan required
1369 * - Maximum 10 images per post (enforced by API)
1370 * - +1 credit per post that has images (not per image)
1371 *
1372 * @return void Sends JSON response
1373 */
1374 public function ajax_save_image_indexing() {
1375 // Verify nonce
1376 check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
1377
1378 // Check user permissions
1379 if ( ! current_user_can( 'manage_options' ) ) {
1380 wp_send_json_error( [
1381 'message' => wpforo_phrase( 'Insufficient permissions', false )
1382 ], 403 );
1383 }
1384
1385 // Check plan eligibility (Business/Enterprise only)
1386 $status = $this->get_tenant_status();
1387 if ( is_wp_error( $status ) ) {
1388 wp_send_json_error( [
1389 'message' => wpforo_phrase( 'Could not verify subscription status', false )
1390 ], 400 );
1391 }
1392
1393 $plan = isset( $status['subscription']['plan'] ) ? strtolower( $status['subscription']['plan'] ) : '';
1394 if ( ! in_array( $plan, [ 'professional', 'business', 'enterprise' ], true ) ) {
1395 wp_send_json_error( [
1396 'message' => wpforo_phrase( 'Image indexing requires Professional plan or higher', false )
1397 ], 403 );
1398 }
1399
1400 // Get and validate enabled state
1401 $enabled = isset( $_POST['enabled'] ) ? (int) $_POST['enabled'] : 0;
1402 $enabled = $enabled ? 1 : 0;
1403
1404 // Get board ID
1405 $board_id = isset( $_POST['board_id'] ) ? intval( $_POST['board_id'] ) : 0;
1406
1407 // Switch to the correct board context
1408 if ( $board_id > 0 ) {
1409 WPF()->change_board( $board_id );
1410 }
1411
1412 // Save the setting using wpforo options (board-specific)
1413 wpforo_update_option( 'ai_image_indexing_enabled', $enabled );
1414
1415 // Log the change
1416 $this->log_info( 'image_indexing_changed', [
1417 'board_id' => $board_id,
1418 'enabled' => $enabled
1419 ] );
1420
1421 wp_send_json_success( [
1422 'message' => $enabled
1423 ? wpforo_phrase( 'Image indexing enabled. Posts with images will consume +1 additional credit.', false )
1424 : wpforo_phrase( 'Image indexing disabled.', false ),
1425 'enabled' => $enabled,
1426 'board_id' => $board_id
1427 ] );
1428 }
1429
1430 /**
1431 * AJAX handler for saving document indexing setting
1432 *
1433 * Saves the board-specific document indexing enabled/disabled state.
1434 * Requires Professional+ plan.
1435 */
1436 public function ajax_save_document_indexing() {
1437 check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
1438
1439 if ( ! current_user_can( 'manage_options' ) ) {
1440 wp_send_json_error( [
1441 'message' => wpforo_phrase( 'Insufficient permissions', false )
1442 ], 403 );
1443 }
1444
1445 $status = $this->get_tenant_status();
1446 if ( is_wp_error( $status ) ) {
1447 wp_send_json_error( [
1448 'message' => wpforo_phrase( 'Could not verify subscription status', false )
1449 ], 400 );
1450 }
1451
1452 $plan = isset( $status['subscription']['plan'] ) ? strtolower( $status['subscription']['plan'] ) : '';
1453 if ( ! in_array( $plan, [ 'professional', 'business', 'enterprise' ], true ) ) {
1454 wp_send_json_error( [
1455 'message' => wpforo_phrase( 'Document indexing requires Professional plan or higher', false )
1456 ], 403 );
1457 }
1458
1459 $enabled = isset( $_POST['enabled'] ) ? (int) $_POST['enabled'] : 0;
1460 $enabled = $enabled ? 1 : 0;
1461
1462 $board_id = isset( $_POST['board_id'] ) ? intval( $_POST['board_id'] ) : 0;
1463 if ( $board_id > 0 ) {
1464 WPF()->change_board( $board_id );
1465 }
1466
1467 wpforo_update_option( 'ai_document_indexing_enabled', $enabled );
1468
1469 $this->log_info( 'document_indexing_changed', [
1470 'board_id' => $board_id,
1471 'enabled' => $enabled
1472 ] );
1473
1474 wp_send_json_success( [
1475 'message' => $enabled
1476 ? wpforo_phrase( 'Document indexing enabled. Credit cost: 1 per page.', false )
1477 : wpforo_phrase( 'Document indexing disabled.', false ),
1478 'enabled' => $enabled,
1479 'board_id' => $board_id
1480 ] );
1481 }
1482
1483 /**
1484 * AJAX handler for saving WordPress indexing options
1485 *
1486 * WordPress content is global (not board-specific), so these settings
1487 * are saved globally using update_option() instead of wpforo_update_option().
1488 *
1489 * Supported options:
1490 * - ai_wp_auto_indexing_enabled: Auto-index new WordPress content
1491 * - ai_wp_image_indexing_enabled: Include images in WP content indexing
1492 */
1493 public function ajax_save_wp_indexing_option() {
1494 // Verify nonce
1495 check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
1496
1497 // Check user permissions
1498 if ( ! current_user_can( 'manage_options' ) ) {
1499 wp_send_json_error( [
1500 'message' => wpforo_phrase( 'Insufficient permissions', false )
1501 ], 403 );
1502 }
1503
1504 // Get option name and validate it's one of the allowed options
1505 $option_name = isset( $_POST['option_name'] ) ? sanitize_key( $_POST['option_name'] ) : '';
1506 $allowed_options = [ 'ai_wp_auto_indexing_enabled', 'ai_wp_image_indexing_enabled' ];
1507
1508 if ( ! in_array( $option_name, $allowed_options, true ) ) {
1509 wp_send_json_error( [
1510 'message' => wpforo_phrase( 'Invalid option name', false )
1511 ], 400 );
1512 }
1513
1514 // For image indexing, check plan eligibility (Professional/Business/Enterprise)
1515 if ( $option_name === 'ai_wp_image_indexing_enabled' ) {
1516 $status = $this->get_tenant_status();
1517 if ( is_wp_error( $status ) ) {
1518 wp_send_json_error( [
1519 'message' => wpforo_phrase( 'Could not verify subscription status', false )
1520 ], 400 );
1521 }
1522
1523 $plan = isset( $status['subscription']['plan'] ) ? strtolower( $status['subscription']['plan'] ) : '';
1524 if ( ! in_array( $plan, [ 'professional', 'business', 'enterprise' ], true ) ) {
1525 wp_send_json_error( [
1526 'message' => wpforo_phrase( 'Image indexing requires Professional, Business or Enterprise plan', false )
1527 ], 403 );
1528 }
1529 }
1530
1531 // Get and validate enabled state
1532 $enabled = isset( $_POST['enabled'] ) ? (int) $_POST['enabled'] : 0;
1533 $enabled = $enabled ? 1 : 0;
1534
1535 // Save globally using WordPress options (not board-specific)
1536 update_option( 'wpforo_' . $option_name, $enabled );
1537
1538 // Log the change
1539 $this->log_info( 'wp_indexing_option_changed', [
1540 'option' => $option_name,
1541 'enabled' => $enabled
1542 ] );
1543
1544 // Prepare success message based on option
1545 if ( $option_name === 'ai_wp_image_indexing_enabled' ) {
1546 $message = $enabled
1547 ? wpforo_phrase( 'WordPress image indexing enabled. Posts with images will consume +1 additional credit.', false )
1548 : wpforo_phrase( 'WordPress image indexing disabled.', false );
1549 } else {
1550 $message = $enabled
1551 ? wpforo_phrase( 'WordPress auto-indexing enabled. New content will be indexed automatically.', false )
1552 : wpforo_phrase( 'WordPress auto-indexing disabled.', false );
1553 }
1554
1555 wp_send_json_success( [
1556 'message' => $message,
1557 'enabled' => $enabled,
1558 'option' => $option_name
1559 ] );
1560 }
1561
1562 /**
1563 * AJAX handler for linking Freemius subscription to tenant
1564 *
1565 * Called after successful Freemius checkout to store subscription_id and user_id.
1566 * This enables webhook matching when emails don't match.
1567 *
1568 * @return void Sends JSON response
1569 */
1570 public function ajax_link_subscription() {
1571 // Verify nonce
1572 check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
1573
1574 // Check user permissions
1575 if ( ! current_user_can( 'manage_options' ) ) {
1576 wp_send_json_error( [ 'message' => 'Insufficient permissions' ], 403 );
1577 }
1578
1579 $subscription_id = isset( $_POST['subscription_id'] ) ? sanitize_text_field( $_POST['subscription_id'] ) : '';
1580 $user_id = isset( $_POST['user_id'] ) ? sanitize_text_field( $_POST['user_id'] ) : '';
1581 $plan = isset( $_POST['plan'] ) ? sanitize_text_field( $_POST['plan'] ) : '';
1582
1583 if ( empty( $subscription_id ) ) {
1584 wp_send_json_error( [ 'message' => 'Missing subscription_id' ], 400 );
1585 }
1586
1587 // Call backend API to link subscription
1588 $response = $this->post( '/tenant/link-subscription', [
1589 'freemius_subscription_id' => $subscription_id,
1590 'freemius_user_id' => $user_id,
1591 'plan' => $plan,
1592 ] );
1593
1594 if ( is_wp_error( $response ) ) {
1595 $this->log_error( 'link_subscription_failed', $response->get_error_message() );
1596 wp_send_json_error( [ 'message' => $response->get_error_message() ], 400 );
1597 }
1598
1599 $this->log_info( 'subscription_linked', [
1600 'subscription_id' => $subscription_id,
1601 'user_id' => $user_id,
1602 'plan' => $plan,
1603 ] );
1604
1605 wp_send_json_success( [ 'message' => 'Subscription linked successfully' ] );
1606 }
1607
1608 /**
1609 * AJAX handler for manual license activation
1610 *
1611 * Called when user enters a License ID to manually activate their plan.
1612 * The backend verifies with Freemius API and updates the subscription.
1613 *
1614 * Note: We only transmit the License ID (a numeric identifier like "1845944"),
1615 * NOT the License Key (sk_...). The License ID is safe to store as it's just
1616 * a reference number, not a secret.
1617 *
1618 * @return void Sends JSON response
1619 */
1620 public function ajax_activate_license() {
1621 // Verify nonce
1622 check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
1623
1624 // Check user permissions
1625 if ( ! current_user_can( 'manage_options' ) ) {
1626 wp_send_json_error( [ 'message' => 'Insufficient permissions' ], 403 );
1627 }
1628
1629 $license_id = isset( $_POST['license_id'] ) ? sanitize_text_field( $_POST['license_id'] ) : '';
1630
1631 if ( empty( $license_id ) ) {
1632 wp_send_json_error( [ 'message' => 'Please enter your License ID' ], 400 );
1633 }
1634
1635 // Validate license_id format (should be numeric)
1636 if ( ! preg_match( '/^\d+$/', $license_id ) ) {
1637 wp_send_json_error( [ 'message' => 'Invalid License ID format. Please enter the numeric License ID from your purchase confirmation.' ], 400 );
1638 }
1639
1640 // Call backend API to verify and activate license
1641 $response = $this->post( '/tenant/activate-license', [
1642 'license_id' => $license_id,
1643 ] );
1644
1645 if ( is_wp_error( $response ) ) {
1646 $this->log_error( 'license_activation_failed', $response->get_error_message() );
1647 wp_send_json_error( [ 'message' => $response->get_error_message() ], 400 );
1648 }
1649
1650 // Clear cached subscription data so it refreshes
1651 delete_option( 'wpforo_ai_subscription_plan' );
1652 delete_transient( 'wpforo_ai_subscription' );
1653 delete_transient( 'wpforo_ai_tenant_status' );
1654
1655 $this->log_info( 'license_activated', [
1656 'license_id' => $license_id,
1657 'plan' => $response['plan'] ?? '',
1658 ] );
1659
1660 wp_send_json_success( [
1661 'message' => $response['message'] ?? 'License activated successfully',
1662 'plan' => $response['plan'] ?? '',
1663 'credits_added' => $response['credits_added'] ?? 0,
1664 ] );
1665 }
1666
1667 /**
1668 * AJAX handler for activating a Paddle transaction manually.
1669 * Mirrors ajax_activate_license() but for Paddle Transaction IDs.
1670 */
1671 public function ajax_activate_paddle_transaction() {
1672 check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
1673
1674 if ( ! current_user_can( 'manage_options' ) ) {
1675 wp_send_json_error( [ 'message' => 'Insufficient permissions' ], 403 );
1676 }
1677
1678 $transaction_id = isset( $_POST['transaction_id'] ) ? sanitize_text_field( $_POST['transaction_id'] ) : '';
1679
1680 if ( empty( $transaction_id ) ) {
1681 wp_send_json_error( [ 'message' => 'Please enter your Transaction ID' ], 400 );
1682 }
1683
1684 if ( strpos( $transaction_id, 'txn_' ) !== 0 ) {
1685 wp_send_json_error( [ 'message' => 'Invalid Transaction ID format. Must start with "txn_".' ], 400 );
1686 }
1687
1688 $response = $this->post( '/tenant/activate-paddle-transaction', [
1689 'transaction_id' => $transaction_id,
1690 ] );
1691
1692 if ( is_wp_error( $response ) ) {
1693 $this->log_error( 'paddle_transaction_activation_failed', $response->get_error_message() );
1694 wp_send_json_error( [ 'message' => $response->get_error_message() ], 400 );
1695 }
1696
1697 // Clear cached subscription data so it refreshes
1698 delete_option( 'wpforo_ai_subscription_plan' );
1699 delete_transient( 'wpforo_ai_subscription' );
1700 delete_transient( 'wpforo_ai_tenant_status' );
1701
1702 $this->log_info( 'paddle_transaction_activated', [
1703 'transaction_id' => $transaction_id,
1704 'plan' => $response['plan'] ?? '',
1705 'transaction_type' => $response['transaction_type'] ?? '',
1706 ] );
1707
1708 wp_send_json_success( [
1709 'message' => $response['message'] ?? 'Transaction activated successfully',
1710 'plan' => $response['plan'] ?? '',
1711 'credits_added' => $response['credits_added'] ?? 0,
1712 'transaction_type' => $response['transaction_type'] ?? '',
1713 ] );
1714 }
1715
1716 /**
1717 * AJAX handler for creating a Paddle checkout
1718 *
1719 * Creates a server-side Paddle transaction via the backend.
1720 * Returns a checkout URL where Paddle.js is loaded and opens the
1721 * checkout overlay for the transaction.
1722 *
1723 * @return void Sends JSON response with checkout_url
1724 */
1725 public function ajax_paddle_checkout() {
1726 // Verify nonce
1727 check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
1728
1729 // Check user permissions
1730 if ( ! current_user_can( 'manage_options' ) ) {
1731 wp_send_json_error( [ 'message' => 'Insufficient permissions' ], 403 );
1732 }
1733
1734 $price_id = isset( $_POST['price_id'] ) ? sanitize_text_field( $_POST['price_id'] ) : '';
1735 $plan = isset( $_POST['plan'] ) ? sanitize_text_field( $_POST['plan'] ) : '';
1736
1737 if ( empty( $price_id ) ) {
1738 wp_send_json_error( [ 'message' => 'Missing price_id' ], 400 );
1739 }
1740
1741 // Get tenant info
1742 $tenant_id = $this->get_tenant_id();
1743 $current_user = wp_get_current_user();
1744 $customer_email = ! empty( $current_user->user_email ) ? $current_user->user_email : get_option( 'admin_email' );
1745 $customer_name = trim( $current_user->first_name . ' ' . $current_user->last_name );
1746
1747 if ( empty( $tenant_id ) ) {
1748 wp_send_json_error( [ 'message' => 'Not connected. Please generate an API key first.' ], 400 );
1749 }
1750
1751 // Call backend Lambda to create Paddle checkout transaction
1752 $response = $this->post( '/paddle/create-checkout', [
1753 'tenant_id' => $tenant_id,
1754 'price_id' => $price_id,
1755 'customer_email' => $customer_email,
1756 'customer_name' => $customer_name ?: null,
1757 'site_url' => site_url(),
1758 ] );
1759
1760 if ( is_wp_error( $response ) ) {
1761 $this->log_error( 'paddle_checkout_failed', $response->get_error_message() );
1762 wp_send_json_error( [ 'message' => $response->get_error_message() ], 400 );
1763 }
1764
1765 $checkout_url = $response['checkout_url'] ?? '';
1766 if ( empty( $checkout_url ) ) {
1767 wp_send_json_error( [ 'message' => 'No checkout URL returned. Please try again.' ], 500 );
1768 }
1769
1770 $this->log_info( 'paddle_checkout_created', [
1771 'price_id' => $price_id,
1772 'plan' => $plan,
1773 'transaction_id' => $response['transaction_id'] ?? '',
1774 ] );
1775
1776 wp_send_json_success( [
1777 'checkout_url' => $checkout_url,
1778 'transaction_id' => $response['transaction_id'] ?? '',
1779 ] );
1780 }
1781
1782 /**
1783 * AJAX handler for linking Paddle subscription to tenant
1784 *
1785 * Called after Paddle checkout to store paddle_subscription_id and paddle_customer_id.
1786 * This is a belt-and-suspenders approach — webhooks should already handle this via
1787 * custom_data.tenant_id, but calling this ensures the link is established immediately.
1788 *
1789 * @return void Sends JSON response
1790 */
1791 public function ajax_link_paddle_subscription() {
1792 // Verify nonce
1793 check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
1794
1795 // Check user permissions
1796 if ( ! current_user_can( 'manage_options' ) ) {
1797 wp_send_json_error( [ 'message' => 'Insufficient permissions' ], 403 );
1798 }
1799
1800 $paddle_subscription_id = isset( $_POST['paddle_subscription_id'] ) ? sanitize_text_field( $_POST['paddle_subscription_id'] ) : '';
1801 $paddle_customer_id = isset( $_POST['paddle_customer_id'] ) ? sanitize_text_field( $_POST['paddle_customer_id'] ) : '';
1802 $plan = isset( $_POST['plan'] ) ? sanitize_text_field( $_POST['plan'] ) : '';
1803
1804 if ( empty( $paddle_subscription_id ) ) {
1805 wp_send_json_error( [ 'message' => 'Missing paddle_subscription_id' ], 400 );
1806 }
1807
1808 // Call backend API to link Paddle subscription
1809 $response = $this->post( '/tenant/link-paddle-subscription', [
1810 'paddle_subscription_id' => $paddle_subscription_id,
1811 'paddle_customer_id' => $paddle_customer_id,
1812 'plan' => $plan,
1813 ] );
1814
1815 if ( is_wp_error( $response ) ) {
1816 $this->log_error( 'link_paddle_subscription_failed', $response->get_error_message() );
1817 wp_send_json_error( [ 'message' => $response->get_error_message() ], 400 );
1818 }
1819
1820 // Store payment provider locally for manage subscription URL routing
1821 update_option( 'wpforo_ai_payment_provider', 'paddle' );
1822
1823 $this->log_info( 'paddle_subscription_linked', [
1824 'paddle_subscription_id' => $paddle_subscription_id,
1825 'paddle_customer_id' => $paddle_customer_id,
1826 'plan' => $plan,
1827 ] );
1828
1829 wp_send_json_success( [ 'message' => 'Paddle subscription linked successfully' ] );
1830 }
1831
1832 /**
1833 * AJAX handler for searching bot users (for Bot Reply settings)
1834 *
1835 * Searches for activated WordPress users by login, display name, or email.
1836 * Only returns users with empty user_activation_key (active accounts).
1837 *
1838 * @return void Sends JSON response
1839 */
1840 public function ajax_search_bot_users() {
1841 // Verify nonce - use settings form nonce
1842 check_ajax_referer( 'wpforo_ai_bot_user_search', '_wpnonce' );
1843
1844 // Check user permissions
1845 if ( ! current_user_can( 'manage_options' ) ) {
1846 wp_send_json_error( [ 'message' => 'Permission denied' ] );
1847 }
1848
1849 $search = sanitize_text_field( $_POST['search'] ?? '' );
1850 $user_id = intval( $_POST['user_id'] ?? 0 );
1851
1852 global $wpdb;
1853
1854 // If user_id is provided, look up that specific user
1855 if ( $user_id > 0 ) {
1856 $user = get_userdata( $user_id );
1857 if ( $user ) {
1858 $role = ! empty( $user->roles ) ? ucfirst( $user->roles[0] ) : '';
1859 wp_send_json_success( [
1860 'users' => [
1861 [
1862 'id' => $user->ID,
1863 'user_login' => $user->user_login,
1864 'display_name' => $user->display_name,
1865 'role' => $role,
1866 'label' => sprintf(
1867 '%s (%s)%s',
1868 $user->display_name,
1869 $user->user_login,
1870 $role ? ' - ' . $role : ''
1871 ),
1872 ]
1873 ]
1874 ] );
1875 } else {
1876 wp_send_json_success( [ 'users' => [] ] );
1877 }
1878 return;
1879 }
1880
1881 // Otherwise, search by text
1882 if ( strlen( $search ) < 2 ) {
1883 wp_send_json_success( [ 'users' => [] ] );
1884 }
1885
1886 // Search for activated users (empty user_activation_key) by login, display name, or email
1887 $like = '%' . $wpdb->esc_like( $search ) . '%';
1888 $users = $wpdb->get_results(
1889 $wpdb->prepare(
1890 "SELECT ID, user_login, display_name, user_email
1891 FROM {$wpdb->users}
1892 WHERE user_activation_key = ''
1893 AND (user_login LIKE %s OR display_name LIKE %s OR user_email LIKE %s)
1894 ORDER BY display_name ASC
1895 LIMIT 50",
1896 $like,
1897 $like,
1898 $like
1899 )
1900 );
1901
1902 // Batch fetch user roles using single query
1903 $user_ids = wp_list_pluck( $users, 'ID' );
1904 $user_roles = [];
1905 if ( ! empty( $user_ids ) ) {
1906 $wp_users = get_users( [ 'include' => $user_ids, 'fields' => 'all_with_meta' ] );
1907 foreach ( $wp_users as $wp_user ) {
1908 $user_roles[ $wp_user->ID ] = ! empty( $wp_user->roles ) ? ucfirst( $wp_user->roles[0] ) : '';
1909 }
1910 }
1911
1912 $results = [];
1913 foreach ( $users as $user ) {
1914 $role = $user_roles[ $user->ID ] ?? '';
1915 $results[] = [
1916 'id' => $user->ID,
1917 'user_login' => $user->user_login,
1918 'display_name' => $user->display_name,
1919 'role' => $role,
1920 'label' => sprintf(
1921 '%s (%s)%s',
1922 $user->display_name,
1923 $user->user_login,
1924 $role ? ' - ' . $role : ''
1925 ),
1926 ];
1927 }
1928
1929 wp_send_json_success( [ 'users' => $results ] );
1930 }
1931
1932 /**
1933 * AJAX handler for getting analytics data
1934 *
1935 * Fetches AI usage analytics from backend API with local caching
1936 *
1937 * @return void Sends JSON response
1938 */
1939 public function ajax_get_analytics() {
1940 // Verify nonce
1941 check_ajax_referer( 'wpforo_ai_analytics_nonce', 'nonce' );
1942
1943 // Check user permissions
1944 if ( ! current_user_can( 'manage_options' ) ) {
1945 wp_send_json_error( [
1946 'message' => wpforo_phrase( 'Insufficient permissions', false )
1947 ], 403 );
1948 }
1949
1950 // Get parameters
1951 $board_id = isset( $_POST['board_id'] ) ? intval( $_POST['board_id'] ) : 0;
1952 $start_time = isset( $_POST['start_time'] ) ? intval( $_POST['start_time'] ) : strtotime( '-7 days' );
1953 $end_time = isset( $_POST['end_time'] ) ? intval( $_POST['end_time'] ) : time();
1954
1955 // Check cache first
1956 $cache_key = 'analytics_usage_' . md5( $board_id . ':' . $start_time . ':' . $end_time );
1957 $cached_data = $this->get_analytics_cache( $cache_key );
1958
1959 if ( $cached_data !== false ) {
1960 wp_send_json_success( $cached_data );
1961 return;
1962 }
1963
1964 // Fetch from backend API
1965 $analytics_data = $this->fetch_analytics_from_api( $start_time, $end_time, $board_id );
1966
1967 if ( is_wp_error( $analytics_data ) ) {
1968 wp_send_json_error( [
1969 'message' => $analytics_data->get_error_message()
1970 ] );
1971 return;
1972 }
1973
1974 // Cache the result for 1 hour
1975 $this->set_analytics_cache( $cache_key, $analytics_data, 3600 );
1976
1977 wp_send_json_success( $analytics_data );
1978 }
1979
1980 /**
1981 * Fetch analytics data from backend API
1982 *
1983 * @param int $start_time Start timestamp
1984 * @param int $end_time End timestamp
1985 * @param int $board_id Board ID for filtering (0 for all boards)
1986 * @return array|WP_Error Analytics data or error
1987 */
1988 private function fetch_analytics_from_api( $start_time, $end_time, $board_id = 0 ) {
1989 // Build request data
1990 $data = [
1991 'start_time' => $start_time,
1992 'end_time' => $end_time,
1993 'granularity' => $this->determine_granularity( $start_time, $end_time ),
1994 'group_by' => 'request_type',
1995 ];
1996
1997 // Add board_id filter if specified (non-zero)
1998 if ( $board_id > 0 ) {
1999 $data['board_id'] = $board_id;
2000 }
2001
2002 // Make API request (longer timeout for large date ranges scanning CloudWatch logs)
2003 $response = $this->make_request( 'POST', '/logs/analytics', $data, [], 45 );
2004
2005 if ( is_wp_error( $response ) ) {
2006 return $response;
2007 }
2008
2009 // Process and structure the response
2010 return $this->process_analytics_response( $response, $start_time, $end_time );
2011 }
2012
2013 /**
2014 * Process analytics API response into structured format
2015 *
2016 * @param array $response Raw API response
2017 * @param int $start_time Start timestamp
2018 * @param int $end_time End timestamp
2019 * @return array Processed analytics data
2020 */
2021 private function process_analytics_response( $response, $start_time, $end_time ) {
2022 $data = wpfval( $response, 'data' ) ?: $response;
2023
2024 // Calculate days in range for average
2025 $days_in_range = max( 1, ceil( ( $end_time - $start_time ) / DAY_IN_SECONDS ) );
2026
2027 // Time series data
2028 $time_series = wpfval( $data, 'time_series' ) ?: [];
2029
2030 // Feature breakdown
2031 $by_feature = wpfval( $data, 'by_feature' ) ?: [];
2032
2033 // Moderation stats
2034 $moderation = wpfval( $data, 'moderation' ) ?: [
2035 'spam_blocked' => 0,
2036 'toxic_detected' => 0,
2037 'policy_violations' => 0,
2038 'clean_passed' => 0,
2039 ];
2040
2041 // Summary calculations - prefer by_feature, fallback to time_series
2042 $total_credits = 0;
2043 $total_requests = 0;
2044 $success_count = 0;
2045
2046 if ( ! empty( $by_feature ) ) {
2047 // Calculate from feature breakdown (more accurate)
2048 foreach ( $by_feature as $feature => $stats ) {
2049 $total_credits += (float) wpfval( $stats, 'credits' ) ?: 0;
2050 $total_requests += (int) wpfval( $stats, 'requests' ) ?: 0;
2051 $success_count += (int) wpfval( $stats, 'success_count' ) ?: wpfval( $stats, 'requests' ) ?: 0;
2052 }
2053 } else {
2054 // Fallback: calculate from time series data
2055 foreach ( $time_series as $point ) {
2056 $total_credits += (float) wpfval( $point, 'credits' ) ?: 0;
2057 $total_requests += (int) wpfval( $point, 'requests' ) ?: 0;
2058 }
2059 $success_count = $total_requests; // Assume all successful when no feature breakdown
2060 }
2061
2062 $success_rate = $total_requests > 0 ? ( $success_count / $total_requests ) * 100 : 100;
2063
2064 return [
2065 'time_series' => $time_series,
2066 'by_feature' => $by_feature,
2067 'moderation' => $moderation,
2068 'summary' => [
2069 'total_credits' => round( $total_credits, 2 ),
2070 'total_requests' => $total_requests,
2071 'success_rate' => round( $success_rate, 1 ),
2072 'avg_credits_per_day' => round( $total_credits / $days_in_range, 2 ),
2073 ],
2074 ];
2075 }
2076
2077 /**
2078 * Determine granularity based on time range
2079 *
2080 * @param int $start_time Start timestamp
2081 * @param int $end_time End timestamp
2082 * @return string Granularity (daily, weekly, monthly)
2083 */
2084 private function determine_granularity( $start_time, $end_time ) {
2085 $days = ( $end_time - $start_time ) / DAY_IN_SECONDS;
2086
2087 if ( $days <= 31 ) {
2088 return 'daily';
2089 } elseif ( $days <= 180 ) {
2090 return 'weekly';
2091 } else {
2092 return 'monthly';
2093 }
2094 }
2095
2096 /**
2097 * Get cached analytics data
2098 *
2099 * @param string $cache_key Cache key
2100 * @return mixed Cached data or false if not found/expired
2101 */
2102 private function get_analytics_cache( $cache_key ) {
2103 global $wpdb;
2104 $table = $wpdb->prefix . 'wpforo_ai_cache';
2105
2106 // Suppress errors and return false on any database issue
2107 // This prevents cache table issues from breaking analytics
2108 $wpdb->suppress_errors( true );
2109 $result = $wpdb->get_row( $wpdb->prepare(
2110 "SELECT response, expires_at FROM {$table}
2111 WHERE cache_key = %s AND type = 'analytics' AND expires_at > %d",
2112 $cache_key,
2113 time()
2114 ) );
2115 $wpdb->suppress_errors( false );
2116
2117 // Check for database errors (table doesn't exist, column issues, etc.)
2118 if ( $wpdb->last_error ) {
2119 return false;
2120 }
2121
2122 if ( $result && ! empty( $result->response ) ) {
2123 $data = json_decode( $result->response, true );
2124 if ( json_last_error() === JSON_ERROR_NONE ) {
2125 return $data;
2126 }
2127 }
2128
2129 return false;
2130 }
2131
2132 /**
2133 * Set analytics cache
2134 *
2135 * @param string $cache_key Cache key
2136 * @param array $data Data to cache
2137 * @param int $ttl Time to live in seconds
2138 * @return bool Success
2139 */
2140 private function set_analytics_cache( $cache_key, $data, $ttl = 3600 ) {
2141 global $wpdb;
2142 $table = $wpdb->prefix . 'wpforo_ai_cache';
2143
2144 $expires_at = time() + $ttl;
2145 $cache_value = wp_json_encode( $data );
2146
2147 // Suppress errors - caching failure shouldn't break analytics
2148 // This handles cases where the table doesn't exist or has schema issues
2149 $wpdb->suppress_errors( true );
2150 $result = $wpdb->replace( $table, [
2151 'cache_key' => $cache_key,
2152 'type' => 'analytics',
2153 'response' => $cache_value,
2154 'expires_at' => $expires_at,
2155 'postid' => 0,
2156 ], [ '%s', '%s', '%s', '%d', '%d' ] );
2157 $wpdb->suppress_errors( false );
2158
2159 return $result !== false && ! $wpdb->last_error;
2160 }
2161
2162 /**
2163 * AJAX handler for running AI insights analysis
2164 *
2165 * Sends forum content to AI for analysis (sentiment, trending, recommendations)
2166 * Uses credits and returns results with HTML rendering.
2167 *
2168 * @return void Sends JSON response
2169 */
2170 public function ajax_run_insight() {
2171 // Track start time for logging
2172 $start_time = microtime( true );
2173
2174 // Load analytics functions (needed for caching and rendering)
2175 require_once WPFORO_DIR . '/admin/pages/tabs/ai-features-tab-analytics.php';
2176 // Verify nonce
2177 check_ajax_referer( 'wpforo_ai_insights_nonce', 'nonce' );
2178
2179 // Check user permissions
2180 if ( ! current_user_can( 'manage_options' ) ) {
2181 wp_send_json_error( [
2182 'message' => wpforo_phrase( 'Insufficient permissions', false )
2183 ], 403 );
2184 }
2185
2186 // Get parameters
2187 $insight_type = isset( $_POST['insight_type'] ) ? sanitize_key( $_POST['insight_type'] ) : '';
2188 $board_id = isset( $_POST['board_id'] ) ? intval( $_POST['board_id'] ) : 0;
2189
2190 // Validate insight type
2191 $valid_types = [ 'sentiment', 'trending', 'recommendations', 'deep_analysis', 'sentiment_trend' ];
2192 if ( ! in_array( $insight_type, $valid_types, true ) ) {
2193 wp_send_json_error( [
2194 'message' => wpforo_phrase( 'Invalid insight type', false )
2195 ] );
2196 return;
2197 }
2198
2199 // Get credit costs
2200 $credit_costs = [
2201 'sentiment' => 2,
2202 'trending' => 1,
2203 'recommendations' => 1,
2204 'deep_analysis' => 5,
2205 'sentiment_trend' => 4,
2206 ];
2207 $credit_cost = $credit_costs[ $insight_type ];
2208
2209 // Insight types with daily limits
2210 $daily_limit_types = [ 'recommendations' ];
2211
2212 // Check daily limit for restricted insight types
2213 if ( in_array( $insight_type, $daily_limit_types, true ) ) {
2214 $cached_insights = wpforo_ai_get_cached_insights( $board_id );
2215 if ( isset( $cached_insights[ $insight_type ] ) && ! empty( $cached_insights[ $insight_type ]['timestamp'] ) ) {
2216 $cached_date = date( 'Y-m-d', $cached_insights[ $insight_type ]['timestamp'] );
2217 $today_date = date( 'Y-m-d', current_time( 'timestamp' ) );
2218 if ( $cached_date === $today_date ) {
2219 wp_send_json_error( [
2220 'message' => wpforo_phrase( 'This analysis is limited to once per day. Please try again tomorrow.', false )
2221 ] );
2222 return;
2223 }
2224 }
2225 }
2226
2227 // Check if tenant has enough credits
2228 $status = $this->get_tenant_status();
2229 $credits_remaining = 0;
2230 if ( ! is_wp_error( $status ) && isset( $status['subscription']['credits_remaining'] ) ) {
2231 $credits_remaining = (int) $status['subscription']['credits_remaining'];
2232 }
2233
2234 if ( $credits_remaining < $credit_cost ) {
2235 wp_send_json_error( [
2236 'message' => wpforo_phrase( 'Insufficient credits for this analysis', false )
2237 ] );
2238 return;
2239 }
2240
2241 // Switch to the board if needed
2242 if ( $board_id > 0 ) {
2243 WPF()->change_board( $board_id );
2244 }
2245
2246 // Gather forum data for analysis
2247 $content_sample = $this->gather_insight_content( $insight_type );
2248
2249 if ( empty( $content_sample ) ) {
2250 wp_send_json_error( [
2251 'message' => wpforo_phrase( 'Not enough forum content for analysis', false )
2252 ] );
2253 return;
2254 }
2255
2256 // Send to backend for AI analysis
2257 $result = $this->run_ai_insight( $insight_type, $content_sample );
2258
2259 if ( is_wp_error( $result ) ) {
2260 // Log the error
2261 $duration_ms = (int) ( ( microtime( true ) - $start_time ) * 1000 );
2262 WPF()->ai_logs->log( [
2263 'action_type' => AILogs::ACTION_ANALYTICS_INSIGHTS,
2264 'credits_used' => 0,
2265 'status' => AILogs::STATUS_ERROR,
2266 'request_summary' => 'Insight type: ' . $insight_type,
2267 'error_message' => $result->get_error_message(),
2268 'duration_ms' => $duration_ms,
2269 'user_type' => 'admin',
2270 ] );
2271
2272 wp_send_json_error( [
2273 'message' => $result->get_error_message()
2274 ] );
2275 return;
2276 }
2277
2278 // For deep_analysis, merge database metrics with LLM results
2279 if ( $insight_type === 'deep_analysis' ) {
2280 $db_metrics = $this->get_deep_analysis_db_metrics();
2281 $result = array_merge( $db_metrics, $result );
2282 }
2283
2284 // Cache the result
2285 wpforo_ai_cache_insight( $board_id, $insight_type, $result );
2286
2287 // Log successful insight generation
2288 $duration_ms = (int) ( ( microtime( true ) - $start_time ) * 1000 );
2289 WPF()->ai_logs->log( [
2290 'action_type' => AILogs::ACTION_ANALYTICS_INSIGHTS,
2291 'credits_used' => $credit_cost,
2292 'status' => AILogs::STATUS_SUCCESS,
2293 'request_summary' => 'Insight type: ' . $insight_type,
2294 'response_summary' => 'Generated ' . $insight_type . ' analysis successfully',
2295 'duration_ms' => $duration_ms,
2296 'user_type' => 'admin',
2297 ] );
2298
2299 // Get updated credits (clear cache first to force refresh)
2300 $this->clear_status_cache();
2301 $new_status = $this->get_tenant_status();
2302 $new_credits = 0;
2303 if ( ! is_wp_error( $new_status ) && isset( $new_status['subscription']['credits_remaining'] ) ) {
2304 $new_credits = (int) $new_status['subscription']['credits_remaining'];
2305 }
2306
2307 // Render HTML for the results
2308 ob_start();
2309 wpforo_ai_render_insight_results( $insight_type, $result );
2310 $html = ob_get_clean();
2311
2312 wp_send_json_success( [
2313 'html' => $html,
2314 'data' => $result,
2315 'credits_remaining' => $new_credits,
2316 ] );
2317 }
2318
2319 /**
2320 * Gather forum content for AI insight analysis
2321 *
2322 * @param string $insight_type Type of insight
2323 * @return array Content sample for analysis
2324 */
2325 private function gather_insight_content( $insight_type ) {
2326 $content = [];
2327
2328 switch ( $insight_type ) {
2329 case 'sentiment':
2330 // Get recent posts for sentiment analysis
2331 $posts = WPF()->db->get_results(
2332 "SELECT p.body, p.created, t.title as topic_title
2333 FROM " . WPF()->tables->posts . " p
2334 LEFT JOIN " . WPF()->tables->topics . " t ON p.topicid = t.topicid
2335 WHERE p.status = 0
2336 ORDER BY p.created DESC
2337 LIMIT 200",
2338 ARRAY_A
2339 );
2340 foreach ( $posts as $post ) {
2341 $content[] = [
2342 'text' => wp_strip_all_tags( $post['body'] ),
2343 'topic' => $post['topic_title'],
2344 ];
2345 }
2346 break;
2347
2348 case 'trending':
2349 // Get recent topics with activity metrics using JOIN instead of correlated subquery
2350 $seven_days_ago = time() - ( 7 * DAY_IN_SECONDS );
2351 $topics = WPF()->db->get_results(
2352 WPF()->db->prepare(
2353 "SELECT t.title, t.posts, t.views, t.created, COALESCE(rp.recent_posts, 0) as recent_posts
2354 FROM " . WPF()->tables->topics . " t
2355 LEFT JOIN (
2356 SELECT topicid, COUNT(*) as recent_posts
2357 FROM " . WPF()->tables->posts . "
2358 WHERE created > %d
2359 GROUP BY topicid
2360 ) rp ON t.topicid = rp.topicid
2361 WHERE t.status = 0
2362 AND t.created > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY)
2363 ORDER BY t.created DESC
2364 LIMIT 100",
2365 $seven_days_ago
2366 ),
2367 ARRAY_A
2368 );
2369 foreach ( $topics as $topic ) {
2370 $content[] = [
2371 'title' => $topic['title'],
2372 'posts' => (int) $topic['posts'],
2373 'views' => (int) $topic['views'],
2374 'recent_posts' => (int) $topic['recent_posts'],
2375 ];
2376 }
2377 break;
2378
2379 case 'recommendations':
2380 // Get forum statistics for recommendations
2381 $stats = [];
2382
2383 // Total topics and posts
2384 $stats['total_topics'] = (int) WPF()->db->get_var(
2385 "SELECT COUNT(*) FROM " . WPF()->tables->topics . " WHERE status = 0"
2386 );
2387 $stats['total_posts'] = (int) WPF()->db->get_var(
2388 "SELECT COUNT(*) FROM " . WPF()->tables->posts . " WHERE status = 0"
2389 );
2390
2391 // Unanswered topics
2392 $stats['unanswered_topics'] = (int) WPF()->db->get_var(
2393 "SELECT COUNT(*) FROM " . WPF()->tables->topics . " WHERE status = 0 AND posts = 1"
2394 );
2395
2396 // Active users this week
2397 $stats['active_users_week'] = (int) WPF()->db->get_var(
2398 "SELECT COUNT(DISTINCT userid) FROM " . WPF()->tables->posts . "
2399 WHERE created > UNIX_TIMESTAMP(NOW() - INTERVAL 7 DAY)"
2400 );
2401
2402 // Average response time (first reply)
2403 $stats['avg_response_hours'] = WPF()->db->get_var(
2404 "SELECT AVG(TIMESTAMPDIFF(HOUR, FROM_UNIXTIME(t.created), FROM_UNIXTIME(
2405 (SELECT MIN(p.created) FROM " . WPF()->tables->posts . " p
2406 WHERE p.topicid = t.topicid AND p.is_first_post = 0)
2407 )))
2408 FROM " . WPF()->tables->topics . " t
2409 WHERE t.posts > 1 AND t.created > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY)"
2410 );
2411
2412 // Recent topic titles for context
2413 $recent_topics = WPF()->db->get_col(
2414 "SELECT title FROM " . WPF()->tables->topics . "
2415 WHERE status = 0
2416 ORDER BY created DESC LIMIT 50"
2417 );
2418
2419 $content = [
2420 'stats' => $stats,
2421 'recent_topics' => $recent_topics,
2422 ];
2423 break;
2424
2425 case 'deep_analysis':
2426 // Get comprehensive forum data for deep analysis
2427 $data = [];
2428
2429 // Get usergroup IDs that have 'aum' (admin user management) permission
2430 // These are admins/moderators who should be excluded from contributor analysis
2431 $admin_groupids = [];
2432 $all_groups = WPF()->db->get_results(
2433 "SELECT groupid, cans FROM " . WPF()->tables->usergroups,
2434 ARRAY_A
2435 );
2436 foreach ( $all_groups as $group ) {
2437 $cans = maybe_unserialize( $group['cans'] );
2438 if ( is_array( $cans ) && ! empty( $cans['aum'] ) ) {
2439 $admin_groupids[] = (int) $group['groupid'];
2440 }
2441 }
2442 // Fallback to default admin/mod groups if none found
2443 if ( empty( $admin_groupids ) ) {
2444 $admin_groupids = [ 1, 2 ];
2445 }
2446 $admin_groupids_str = implode( ',', $admin_groupids );
2447
2448 // User engagement data - exclude users with admin permissions (aum capability)
2449 $data['user_stats'] = WPF()->db->get_results(
2450 "SELECT p.userid, COUNT(*) as post_count
2451 FROM " . WPF()->tables->posts . " p
2452 INNER JOIN " . WPF()->tables->profiles . " pr ON p.userid = pr.userid
2453 WHERE p.status = 0
2454 AND p.created > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY)
2455 AND pr.groupid NOT IN ({$admin_groupids_str})
2456 GROUP BY p.userid
2457 ORDER BY post_count DESC
2458 LIMIT 20",
2459 ARRAY_A
2460 );
2461
2462 // Topic and post length metrics
2463 $data['content_metrics'] = WPF()->db->get_row(
2464 "SELECT
2465 AVG(LENGTH(p.body)) as avg_post_length,
2466 AVG(CASE WHEN p.is_first_post = 1 THEN LENGTH(p.body) END) as avg_topic_length
2467 FROM " . WPF()->tables->posts . " p
2468 WHERE p.status = 0 AND p.created > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY)",
2469 ARRAY_A
2470 );
2471
2472 // Recent posts with content for keyword/sentiment analysis
2473 // Exclude users with admin permissions (aum capability)
2474 $posts = WPF()->db->get_results(
2475 "SELECT p.body, p.created, p.userid, t.title as topic_title
2476 FROM " . WPF()->tables->posts . " p
2477 LEFT JOIN " . WPF()->tables->topics . " t ON p.topicid = t.topicid
2478 INNER JOIN " . WPF()->tables->profiles . " pr ON p.userid = pr.userid
2479 WHERE p.status = 0
2480 AND pr.groupid NOT IN ({$admin_groupids_str})
2481 ORDER BY p.created DESC
2482 LIMIT 150",
2483 ARRAY_A
2484 );
2485
2486 // Batch fetch all user display names for user_stats and posts
2487 $all_user_ids = array_merge(
2488 array_column( $data['user_stats'], 'userid' ),
2489 array_column( $posts, 'userid' )
2490 );
2491 $user_names = $this->batch_get_user_display_names( $all_user_ids );
2492 $guest_label = wpforo_phrase( 'Guest', false );
2493
2494 // Get usernames for top posters
2495 foreach ( $data['user_stats'] as &$user ) {
2496 $user['username'] = $user_names[ $user['userid'] ] ?? $guest_label;
2497 }
2498
2499 // Build posts array with usernames
2500 $data['posts'] = [];
2501 foreach ( $posts as $post ) {
2502 $timestamp = is_numeric( $post['created'] ) ? $post['created'] : strtotime( $post['created'] );
2503 $data['posts'][] = [
2504 'text' => wp_strip_all_tags( $post['body'] ),
2505 'topic' => $post['topic_title'],
2506 'username' => $user_names[ $post['userid'] ] ?? $guest_label,
2507 'date' => date( 'Y-m-d H:i', $timestamp ),
2508 ];
2509 }
2510
2511 // Reply frequency data
2512 $data['reply_stats'] = WPF()->db->get_row(
2513 "SELECT
2514 COUNT(*) as total_posts,
2515 SUM(CASE WHEN is_first_post = 0 THEN 1 ELSE 0 END) as total_replies,
2516 COUNT(DISTINCT userid) as unique_users
2517 FROM " . WPF()->tables->posts . "
2518 WHERE status = 0 AND created > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY)",
2519 ARRAY_A
2520 );
2521
2522 $content = $data;
2523 break;
2524
2525 case 'sentiment_trend':
2526 // Get posts with timestamps for trend analysis
2527 $posts = WPF()->db->get_results(
2528 "SELECT p.body, p.created, t.title as topic_title, u.display_name
2529 FROM " . WPF()->tables->posts . " p
2530 LEFT JOIN " . WPF()->tables->topics . " t ON p.topicid = t.topicid
2531 LEFT JOIN " . WPF()->db->users . " u ON p.userid = u.ID
2532 WHERE p.status = 0 AND p.created > DATE_SUB(NOW(), INTERVAL 30 DAY)
2533 ORDER BY p.created ASC
2534 LIMIT 300",
2535 ARRAY_A
2536 );
2537
2538 foreach ( $posts as $post ) {
2539 $timestamp = is_numeric( $post['created'] ) ? $post['created'] : strtotime( $post['created'] );
2540 $content[] = [
2541 'text' => wp_strip_all_tags( $post['body'] ),
2542 'topic' => $post['topic_title'],
2543 'timestamp' => date( 'Y-m-d', $timestamp ),
2544 'author' => $post['display_name'] ?: 'Guest',
2545 ];
2546 }
2547 break;
2548 }
2549
2550 return $content;
2551 }
2552
2553 /**
2554 * Calculate deep analysis metrics from database
2555 *
2556 * These are factual metrics that should be computed from the database,
2557 * not generated by the LLM.
2558 *
2559 * @return array Database-calculated metrics
2560 */
2561 private function get_deep_analysis_db_metrics() {
2562 $metrics = [];
2563
2564 // Get total users and active users
2565 $total_members = (int) WPF()->db->get_var(
2566 "SELECT COUNT(*) FROM " . WPF()->tables->members
2567 );
2568
2569 $active_users = (int) WPF()->db->get_var(
2570 "SELECT COUNT(DISTINCT userid) FROM " . WPF()->tables->posts . "
2571 WHERE status = 0 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY)"
2572 );
2573
2574 // Get reply stats
2575 $reply_stats = WPF()->db->get_row(
2576 "SELECT
2577 COUNT(*) as total_posts,
2578 SUM(CASE WHEN is_first_post = 0 THEN 1 ELSE 0 END) as total_replies,
2579 COUNT(DISTINCT userid) as unique_users
2580 FROM " . WPF()->tables->posts . "
2581 WHERE status = 0 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY)",
2582 ARRAY_A
2583 );
2584
2585 $avg_replies_per_user = 0;
2586 if ( $reply_stats && $reply_stats['unique_users'] > 0 ) {
2587 $avg_replies_per_user = round( (int) $reply_stats['total_replies'] / (int) $reply_stats['unique_users'], 1 );
2588 }
2589
2590 $active_users_percent = 0;
2591 if ( $total_members > 0 ) {
2592 $active_users_percent = round( ( $active_users / $total_members ) * 100, 1 );
2593 }
2594
2595 // Get average response time (hours between topic creation and first reply)
2596 $avg_response_hours = WPF()->db->get_var(
2597 "SELECT AVG(response_time) FROM (
2598 SELECT TIMESTAMPDIFF(HOUR, t.created,
2599 (SELECT MIN(p.created) FROM " . WPF()->tables->posts . " p
2600 WHERE p.topicid = t.topicid AND p.is_first_post = 0)
2601 ) as response_time
2602 FROM " . WPF()->tables->topics . " t
2603 WHERE t.posts > 1 AND t.created > DATE_SUB(NOW(), INTERVAL 30 DAY)
2604 ) as response_times WHERE response_time IS NOT NULL"
2605 );
2606
2607 // Get top repliers with usernames
2608 $top_repliers_raw = WPF()->db->get_results(
2609 "SELECT userid, COUNT(*) as reply_count
2610 FROM " . WPF()->tables->posts . "
2611 WHERE status = 0 AND is_first_post = 0 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY)
2612 GROUP BY userid
2613 ORDER BY reply_count DESC
2614 LIMIT 5",
2615 ARRAY_A
2616 );
2617
2618 // Batch fetch user display names with proper fallback chain
2619 $replier_user_ids = array_column( $top_repliers_raw, 'userid' );
2620 $replier_names = $this->batch_get_user_display_names( $replier_user_ids );
2621 $guest_label = wpforo_phrase( 'Guest', false );
2622
2623 $top_repliers = [];
2624 foreach ( $top_repliers_raw as $replier ) {
2625 $top_repliers[] = [
2626 'username' => $replier_names[ $replier['userid'] ] ?? $guest_label,
2627 'reply_count' => (int) $replier['reply_count'],
2628 'sentiment' => 'neutral', // Will be filled by LLM if available
2629 ];
2630 }
2631
2632 // Get content metrics
2633 $content_stats = WPF()->db->get_row(
2634 "SELECT
2635 AVG(LENGTH(body) / 5) as avg_reply_words,
2636 AVG(CASE WHEN is_first_post = 1 THEN LENGTH(body) / 5 END) as avg_topic_words
2637 FROM " . WPF()->tables->posts . "
2638 WHERE status = 0 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY)",
2639 ARRAY_A
2640 );
2641
2642 // Build user_engagement data
2643 $metrics['user_engagement'] = [
2644 'avg_replies_per_user' => $avg_replies_per_user,
2645 'active_users_percent' => $active_users_percent,
2646 'lurker_percent' => max( 0, 100 - $active_users_percent ),
2647 'avg_response_time_hours' => round( floatval( $avg_response_hours ) ?: 0, 1 ),
2648 'top_repliers' => $top_repliers,
2649 'summary' => '', // Will be filled by LLM
2650 ];
2651
2652 // Build content_metrics data
2653 $avg_topic_words = round( floatval( $content_stats['avg_topic_words'] ?? 0 ) );
2654 $avg_reply_words = round( floatval( $content_stats['avg_reply_words'] ?? 0 ) );
2655
2656 $detailed_percent = 0;
2657 if ( $avg_reply_words > 0 ) {
2658 // Consider replies > 100 words as "detailed"
2659 $detailed_count = (int) WPF()->db->get_var(
2660 "SELECT COUNT(*) FROM " . WPF()->tables->posts . "
2661 WHERE status = 0 AND is_first_post = 0 AND LENGTH(body) / 5 > 100
2662 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY)"
2663 );
2664 $total_replies = (int) ( $reply_stats['total_replies'] ?? 1 );
2665 $detailed_percent = $total_replies > 0 ? round( ( $detailed_count / $total_replies ) * 100 ) : 0;
2666 }
2667
2668 $metrics['content_metrics'] = [
2669 'avg_topic_length_words' => $avg_topic_words,
2670 'avg_reply_length_words' => $avg_reply_words,
2671 'detailed_discussions_percent' => $detailed_percent,
2672 'quick_exchanges_percent' => max( 0, 100 - $detailed_percent ),
2673 'summary' => '', // Will be filled by LLM
2674 ];
2675
2676 // Get activity patterns from database
2677 $peak_hours_raw = WPF()->db->get_results(
2678 "SELECT HOUR(created) as hour, COUNT(*) as cnt
2679 FROM " . WPF()->tables->posts . "
2680 WHERE status = 0 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY)
2681 GROUP BY HOUR(created)
2682 ORDER BY cnt DESC
2683 LIMIT 3",
2684 ARRAY_A
2685 );
2686
2687 $peak_hours = [];
2688 foreach ( $peak_hours_raw as $h ) {
2689 $peak_hours[] = sprintf( '%02d:00', $h['hour'] );
2690 }
2691
2692 $peak_days_raw = WPF()->db->get_results(
2693 "SELECT DAYNAME(created) as day_name, COUNT(*) as cnt
2694 FROM " . WPF()->tables->posts . "
2695 WHERE status = 0 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY)
2696 GROUP BY DAYNAME(created)
2697 ORDER BY cnt DESC
2698 LIMIT 3",
2699 ARRAY_A
2700 );
2701
2702 $peak_days = [];
2703 foreach ( $peak_days_raw as $d ) {
2704 $peak_days[] = $d['day_name'];
2705 }
2706
2707 // Determine trend by comparing last 15 days to previous 15 days
2708 $recent_count = (int) WPF()->db->get_var(
2709 "SELECT COUNT(*) FROM " . WPF()->tables->posts . "
2710 WHERE status = 0 AND created > DATE_SUB(NOW(), INTERVAL 15 DAY)"
2711 );
2712 $previous_count = (int) WPF()->db->get_var(
2713 "SELECT COUNT(*) FROM " . WPF()->tables->posts . "
2714 WHERE status = 0
2715 AND created > DATE_SUB(NOW(), INTERVAL 30 DAY)
2716 AND created <= DATE_SUB(NOW(), INTERVAL 15 DAY)"
2717 );
2718
2719 $trend = 'stable';
2720 if ( $previous_count > 0 ) {
2721 $change = ( $recent_count - $previous_count ) / $previous_count;
2722 if ( $change > 0.1 ) {
2723 $trend = 'increasing';
2724 } elseif ( $change < -0.1 ) {
2725 $trend = 'decreasing';
2726 }
2727 }
2728
2729 $metrics['activity_patterns'] = [
2730 'peak_hours' => $peak_hours,
2731 'peak_days' => $peak_days,
2732 'trend' => $trend,
2733 'summary' => '', // Will be filled by LLM
2734 ];
2735
2736 return $metrics;
2737 }
2738
2739 /**
2740 * Run AI insight analysis via backend API
2741 *
2742 * @param string $insight_type Type of insight
2743 * @param array $content Content to analyze
2744 * @return array|WP_Error Analysis result or error
2745 */
2746 private function run_ai_insight( $insight_type, $content ) {
2747 $data = [
2748 'insight_type' => $insight_type,
2749 'content' => $content,
2750 ];
2751
2752 $response = $this->post( '/analytics/insights', $data );
2753
2754 if ( is_wp_error( $response ) ) {
2755 return $response;
2756 }
2757
2758 // Extract result from response
2759 $result = wpfval( $response, 'data' ) ?: $response;
2760
2761 // Ensure expected structure based on type
2762 switch ( $insight_type ) {
2763 case 'sentiment':
2764 // 7 emotion categories
2765 $result = [
2766 'happy' => (int) wpfval( $result, 'happy' ) ?: 0,
2767 'excited' => (int) wpfval( $result, 'excited' ) ?: 0,
2768 'neutral' => (int) wpfval( $result, 'neutral' ) ?: 0,
2769 'confused' => (int) wpfval( $result, 'confused' ) ?: 0,
2770 'frustrated' => (int) wpfval( $result, 'frustrated' ) ?: 0,
2771 'angry' => (int) wpfval( $result, 'angry' ) ?: 0,
2772 'sad' => (int) wpfval( $result, 'sad' ) ?: 0,
2773 'summary' => wpfval( $result, 'summary' ) ?: '',
2774 ];
2775 break;
2776
2777 case 'trending':
2778 $result = [
2779 'topics' => wpfval( $result, 'topics' ) ?: [],
2780 'summary' => wpfval( $result, 'summary' ) ?: '',
2781 ];
2782 break;
2783
2784 case 'recommendations':
2785 $result = [
2786 'recommendations' => wpfval( $result, 'recommendations' ) ?: [],
2787 'summary' => wpfval( $result, 'summary' ) ?: '',
2788 ];
2789 break;
2790 }
2791
2792 return $result;
2793 }
2794
2795 /**
2796 * Get forum IDs the current user can view (for search filtering)
2797 *
2798 * Uses cached WPF()->current_user_accesses (board-specific).
2799 * Returns null if user can access all forums (no filtering needed).
2800 *
2801 * @return array|null Array of accessible forum IDs, or null for full access
2802 */
2803 public function get_accessible_forumids() {
2804 // Admins see everything
2805 if ( current_user_can( 'administrator' ) ) {
2806 return null;
2807 }
2808
2809 // Get all forums for current board (cached by usergroup)
2810 $all_forums = WPF()->forum->get_forums( [ 'type' => 'forum' ] );
2811 if ( empty( $all_forums ) ) {
2812 return null;
2813 }
2814
2815 $accessible = [];
2816 $total_forums = 0;
2817
2818 foreach ( $all_forums as $forum ) {
2819 if ( empty( $forum['is_cat'] ) ) { // Skip categories
2820 $total_forums++;
2821 // 'vf' = can view forum
2822 if ( WPF()->perm->forum_can( 'vf', $forum['forumid'] ) ) {
2823 $accessible[] = (int) $forum['forumid'];
2824 }
2825 }
2826 }
2827
2828 // If user can access all forums, return null (no filtering needed)
2829 if ( count( $accessible ) === $total_forums ) {
2830 return null;
2831 }
2832
2833 return $accessible;
2834 }
2835
2836 /**
2837 * Perform semantic search query
2838 *
2839 * @param string $query Search query text
2840 * @param int $limit Maximum number of results to return
2841 * @param array $filters Optional filters
2842 * @return array|WP_Error Search results or error object
2843 */
2844 public function semantic_search( $query, $limit = 10, $filters = [] ) {
2845 if ( empty( $query ) ) {
2846 return new \WP_Error( 'empty_query', wpforo_phrase( 'Search query cannot be empty', false ) );
2847 }
2848
2849 // Get tenant ID from stored status
2850 $status = $this->get_tenant_status();
2851 if ( is_wp_error( $status ) ) {
2852 return $status;
2853 }
2854
2855 $tenant_id = wpfval( $status, 'tenant_id' );
2856 if ( empty( $tenant_id ) ) {
2857 return new \WP_Error( 'no_tenant_id', wpforo_phrase( 'Tenant ID not found', false ) );
2858 }
2859
2860 // Automatically add current board_id to filters (multi-board support)
2861 // Note: board_id is stored as string in vector metadata, so we send it as string
2862 $current_boardid = (string) WPF()->board->get_current( 'boardid' );
2863 if ( ! isset( $filters['board_id'] ) ) {
2864 $filters['board_id'] = $current_boardid;
2865 }
2866
2867 // Add forum access filtering (only forums current user can view)
2868 // Skip if already set by VectorStorageManager (avoids double-add)
2869 // Returns null for admins or users with full access (no filtering needed)
2870 if ( ! isset( $filters['accessible_forumids'] ) ) {
2871 $accessible_forumids = $this->get_accessible_forumids();
2872 if ( $accessible_forumids !== null ) {
2873 $filters['accessible_forumids'] = $accessible_forumids;
2874 }
2875 }
2876
2877 $data = [
2878 'tenant_id' => $tenant_id,
2879 'query' => sanitize_text_field( $query ),
2880 'limit' => min( (int) $limit, 100 ), // Cap at 100 results
2881 ];
2882
2883 if ( ! empty( $filters ) ) {
2884 $data['filters'] = $filters;
2885 }
2886
2887 // Add quality parameter from settings (for re-ranking model selection)
2888 $search_quality = wpfval( WPF()->settings->ai, 'search_quality' );
2889 if ( ! empty( $search_quality ) ) {
2890 $data['quality'] = sanitize_text_field( $search_quality );
2891 }
2892
2893 // Add minimum score threshold from settings (server-side filtering)
2894 $min_score_setting = (int) wpfval( WPF()->settings->ai, 'search_min_score' );
2895 if ( $min_score_setting > 0 ) {
2896 $data['min_score'] = $min_score_setting / 100; // Convert percentage to 0-1
2897 }
2898
2899 // Add custom knowledge parameters (Business+ plans)
2900 if ( $this->is_custom_knowledge_enabled() ) {
2901 $data['include_custom_knowledge'] = true;
2902 $data['knowledge_priority'] = [
2903 'search_priority' => $this->get_knowledge_priorities( 'search' ),
2904 ];
2905 }
2906
2907 $response = $this->post( '/search/semantic', $data );
2908
2909 if ( is_wp_error( $response ) ) {
2910 $this->log_error( 'semantic_search_failed', $response->get_error_message() );
2911 return $response;
2912 }
2913
2914 $this->log_info( 'semantic_search_completed', [
2915 'query' => $query,
2916 'results_count' => wpfval( $response, 'total' ) ?: 0
2917 ] );
2918
2919 return $response;
2920 }
2921
2922 /**
2923 * Generate embedding vector for content
2924 *
2925 * Used for local storage mode - generates embeddings via cloud API
2926 * but stores them locally in WordPress database.
2927 *
2928 * Supports multimodal image indexing (Professional+ plans):
2929 * - Pass images array with URLs from site domain
2930 * - Images are processed by vision models
2931 * - Returns processed_content with image descriptions appended
2932 *
2933 * Supports document indexing (Professional+ plans):
2934 * - Pass documents array with URLs from site domain
2935 * - Documents are processed (text extraction, OCR, embedded images)
2936 * - Returns processed_content with document text appended
2937 *
2938 * @param string $content Content text to embed
2939 * @param array $images Optional. Array of image data: [['url' => '...', 'attach_id' => 123], ...]
2940 * @param string $topic_context Optional. Topic title for better image/document descriptions
2941 * @param array $documents Optional. Array of document data: [['url' => '...', 'attach_id' => 123], ...]
2942 * @return array|WP_Error Array with 'embedding' key or error object. Also includes
2943 * 'processed_content' if images/documents were processed.
2944 */
2945 public function generate_embedding( $content, $images = [], $topic_context = '', $documents = [] ) {
2946 if ( empty( $content ) ) {
2947 return new \WP_Error( 'empty_content', wpforo_phrase( 'Content cannot be empty', false ) );
2948 }
2949
2950 // Get tenant ID from stored status
2951 $status = $this->get_tenant_status();
2952 if ( is_wp_error( $status ) ) {
2953 return $status;
2954 }
2955
2956 $tenant_id = wpfval( $status, 'tenant_id' );
2957 if ( empty( $tenant_id ) ) {
2958 return new \WP_Error( 'no_tenant_id', wpforo_phrase( 'Tenant ID not found', false ) );
2959 }
2960
2961 $data = [
2962 'tenant_id' => $tenant_id,
2963 'content' => $content,
2964 ];
2965
2966 // Add image processing parameters if images provided
2967 if ( ! empty( $images ) && is_array( $images ) ) {
2968 // Get site domain (without protocol)
2969 $site_url = get_site_url();
2970 $parsed = wp_parse_url( $site_url );
2971 $domain = $parsed['host'] ?? '';
2972
2973 $data['images'] = $images;
2974 $data['site_domain'] = $domain;
2975
2976 if ( ! empty( $topic_context ) ) {
2977 $data['topic_context'] = $topic_context;
2978 }
2979 }
2980
2981 // Add document processing parameters if documents provided
2982 if ( ! empty( $documents ) && is_array( $documents ) ) {
2983 if ( empty( $data['site_domain'] ) ) {
2984 $site_url = get_site_url();
2985 $parsed = wp_parse_url( $site_url );
2986 $data['site_domain'] = $parsed['host'] ?? '';
2987 }
2988 $data['documents'] = $documents;
2989 if ( ! empty( $topic_context ) && empty( $data['topic_context'] ) ) {
2990 $data['topic_context'] = $topic_context;
2991 }
2992 }
2993
2994 $response = $this->post( '/search/embedding/generate', $data );
2995
2996 if ( is_wp_error( $response ) ) {
2997 $this->log_error( 'embedding_generation_failed', $response->get_error_message() );
2998 return $response;
2999 }
3000
3001 // Validate response
3002 if ( ! isset( $response['embedding'] ) || ! is_array( $response['embedding'] ) ) {
3003 return new \WP_Error( 'invalid_response', wpforo_phrase( 'Invalid embedding response from API', false ) );
3004 }
3005
3006 $log_data = [
3007 'dimensions' => count( $response['embedding'] ),
3008 'credits_used' => $response['credits_used'] ?? 1,
3009 ];
3010
3011 // Log image processing stats if present
3012 if ( ! empty( $response['image_processing'] ) ) {
3013 $log_data['images_processed'] = $response['image_processing']['images_processed'] ?? 0;
3014 $log_data['images_skipped'] = $response['image_processing']['images_skipped'] ?? 0;
3015 }
3016
3017 // Log document processing stats if present
3018 if ( ! empty( $response['document_processing'] ) ) {
3019 $log_data['documents_processed'] = $response['document_processing']['documents_processed'] ?? 0;
3020 $log_data['total_pages'] = $response['document_processing']['total_pages'] ?? 0;
3021 }
3022
3023 $this->log_info( 'embedding_generated', $log_data );
3024
3025 return $response;
3026 }
3027
3028 /**
3029 * Generate embedding vectors for multiple content items in a single request
3030 *
3031 * Used for efficient local storage indexing - generates all embeddings
3032 * in one API call, matching the cloud indexing pattern.
3033 *
3034 * @param array $items Array of items: [ ['id' => 'post_123', 'content' => '...'], ... ]
3035 * @return array|WP_Error Response with 'results' array containing embeddings, or error
3036 */
3037 public function generate_embeddings_batch( $items, $topic_count = null ) {
3038 if ( empty( $items ) || ! is_array( $items ) ) {
3039 return new \WP_Error( 'empty_items', wpforo_phrase( 'Items array cannot be empty', false ) );
3040 }
3041
3042 // Get tenant ID from stored status
3043 $status = $this->get_tenant_status();
3044 if ( is_wp_error( $status ) ) {
3045 return $status;
3046 }
3047
3048 $tenant_id = wpfval( $status, 'tenant_id' );
3049 if ( empty( $tenant_id ) ) {
3050 return new \WP_Error( 'no_tenant_id', wpforo_phrase( 'Tenant ID not found', false ) );
3051 }
3052
3053 // Format items for API
3054 $api_items = [];
3055 foreach ( $items as $item ) {
3056 if ( ! empty( $item['id'] ) && ! empty( $item['content'] ) ) {
3057 $api_items[] = [
3058 'id' => (string) $item['id'],
3059 'content' => $item['content'],
3060 ];
3061 }
3062 }
3063
3064 if ( empty( $api_items ) ) {
3065 return new \WP_Error( 'no_valid_items', wpforo_phrase( 'No valid items to process', false ) );
3066 }
3067
3068 $data = [
3069 'tenant_id' => $tenant_id,
3070 'items' => $api_items,
3071 ];
3072
3073 // Add topic_count for credit charging (charge per topic, not per post)
3074 // IMPORTANT: Always send topic_count when provided, including 0 for continuation chunks
3075 // If topic_count is not sent, API falls back to per-item charging (expensive!)
3076 if ( $topic_count !== null ) {
3077 $data['topic_count'] = (int) $topic_count;
3078 }
3079
3080 \wpforo_ai_log( 'debug', sprintf(
3081 'generate_embeddings_batch: topic_count=%s, items=%d, data_keys=%s',
3082 $topic_count !== null ? (string) $topic_count : 'NULL',
3083 count( $api_items ),
3084 implode( ',', array_keys( $data ) )
3085 ), 'Client' );
3086
3087 // API Gateway has 29-second hard limit, so use 25 seconds to fail fast
3088 // With smaller batches (5 topics for local mode), this should be sufficient
3089 $response = $this->api_post( '/search/embedding/generate-batch', $data, 25 );
3090
3091 if ( is_wp_error( $response ) ) {
3092 $this->log_error( 'batch_embedding_failed', $response->get_error_message() );
3093 return $response;
3094 }
3095
3096 // Validate response
3097 if ( ! isset( $response['results'] ) || ! is_array( $response['results'] ) ) {
3098 return new \WP_Error( 'invalid_response', wpforo_phrase( 'Invalid batch embedding response from API', false ) );
3099 }
3100
3101 $this->log_info( 'batch_embedding_completed', [
3102 'total_items' => $response['total_items'] ?? count( $api_items ),
3103 'successful_items' => $response['successful_items'] ?? 0,
3104 'failed_items' => $response['failed_items'] ?? 0,
3105 'credits_used' => $response['credits_used'] ?? 0,
3106 ] );
3107
3108 return $response;
3109 }
3110
3111 /**
3112 * Enhance search results with AI-generated summary and recommendations
3113 *
3114 * Credits are consumed based on quality tier selected.
3115 *
3116 * @param string $query The original search query
3117 * @param array $results Array of search results (title, excerpt, url, score)
3118 * @param string $user_language Language for AI response (default: English)
3119 * @return array|WP_Error Enhancement data or error object
3120 */
3121 public function enhance_search_results( $query, $results, $user_language = 'English' ) {
3122 // Check if AI Summary & Recommendations is enabled
3123 // Handle various stored formats: true, "1", 1, "on" = enabled; false, "0", 0, "" = disabled
3124 $enhance_setting = wpfval( WPF()->settings->ai, 'search_enhance' );
3125 // Consider enabled if value is truthy and not explicitly "0" or 0
3126 $enhance_enabled = ! empty( $enhance_setting ) && $enhance_setting !== '0' && $enhance_setting !== 0 && $enhance_setting !== 'false';
3127 if ( ! $enhance_enabled ) {
3128 $this->log_info( 'search_enhance_disabled', [
3129 'setting_value' => $enhance_setting,
3130 'setting_type' => gettype( $enhance_setting ),
3131 ] );
3132 return [
3133 'success' => false,
3134 'disabled' => true,
3135 'summary' => '',
3136 'quick_answer' => '',
3137 'recommendations' => [],
3138 ];
3139 }
3140
3141 if ( empty( $query ) || empty( $results ) ) {
3142 return new \WP_Error( 'invalid_params', wpforo_phrase( 'Query and results are required', false ) );
3143 }
3144
3145 // Get tenant ID from stored status
3146 $status = $this->get_tenant_status();
3147 if ( is_wp_error( $status ) ) {
3148 return $status;
3149 }
3150
3151 $tenant_id = wpfval( $status, 'tenant_id' );
3152 if ( empty( $tenant_id ) ) {
3153 return new \WP_Error( 'no_tenant_id', wpforo_phrase( 'Tenant ID not found', false ) );
3154 }
3155
3156 // Format results for the API (max 5 results)
3157 $formatted_results = [];
3158 $result_num = 1;
3159 foreach ( array_slice( $results, 0, 5 ) as $result ) {
3160 $formatted_results[] = [
3161 'result_number' => $result_num,
3162 'title' => wpfval( $result, 'title' ) ?: '',
3163 'excerpt' => wpfval( $result, 'content' ) ?: '',
3164 'url' => wpfval( $result, 'url' ) ?: '',
3165 'score' => ( wpfval( $result, 'score' ) ?: 0 ) / 100, // Convert from % back to 0-1
3166 ];
3167 $result_num++;
3168 }
3169
3170 $data = [
3171 'tenant_id' => $tenant_id,
3172 'query' => sanitize_text_field( $query ),
3173 'user_language' => sanitize_text_field( $user_language ),
3174 'results' => $formatted_results,
3175 ];
3176
3177 // Add quality parameter from settings (for AI summary/recommendations model selection)
3178 $enhance_quality = wpfval( WPF()->settings->ai, 'search_enhance_quality' );
3179 if ( ! empty( $enhance_quality ) ) {
3180 $data['quality'] = sanitize_text_field( $enhance_quality );
3181 }
3182
3183 $response = $this->post( '/search/enhance', $data );
3184
3185 if ( is_wp_error( $response ) ) {
3186 $this->log_error( 'search_enhance_failed', $response->get_error_message() );
3187 return $response;
3188 }
3189
3190 $this->log_info( 'search_enhance_completed', [
3191 'query' => $query,
3192 'results_count' => count( $formatted_results ),
3193 'processing_time' => wpfval( $response, 'processing_time_ms' ) ?: 0,
3194 ] );
3195
3196 return $response;
3197 }
3198
3199 /**
3200 * Get user's language for AI responses
3201 *
3202 * Priority order:
3203 * 1. Explicit language code parameter (from POST/request)
3204 * 2. User preference (from user_meta if logged in)
3205 * 3. Board AI settings (search_language)
3206 * 4. Board locale
3207 * 5. WordPress locale
3208 * 6. Default (English)
3209 *
3210 * @param string|null $language_code Explicit language code (e.g., 'en_US', 'de_DE')
3211 * @return string Language name (e.g., "English", "Spanish", "French")
3212 */
3213 public function get_user_language( $language_code = null, $setting_key = 'search_language' ) {
3214 // Build language map from master list (2-letter code => English name)
3215 $language_map = [];
3216 foreach ( wpforo_get_ai_languages() as $lang ) {
3217 if ( ! isset( $language_map[ $lang['code'] ] ) ) {
3218 $language_map[ $lang['code'] ] = $lang['name'];
3219 }
3220 }
3221
3222 $locale = null;
3223
3224 // 1. Use explicit language code if provided
3225 if ( ! empty( $language_code ) ) {
3226 $locale = $language_code;
3227 }
3228
3229 // 2. If no explicit code, try user preferences (logged in users only)
3230 if ( empty( $locale ) ) {
3231 $user_id = WPF()->current_userid;
3232 if ( $user_id > 0 ) {
3233 $saved_prefs = get_user_meta( $user_id, 'wpforo_ai_search', true );
3234 if ( is_array( $saved_prefs ) && ! empty( $saved_prefs['language'] ) ) {
3235 $locale = $saved_prefs['language'];
3236 }
3237 }
3238 }
3239
3240 // 3. If still no locale, try board AI settings
3241 if ( empty( $locale ) ) {
3242 $locale = wpforo_setting( 'ai', $setting_key );
3243 }
3244
3245 // 4. If still no locale, try board locale
3246 if ( empty( $locale ) ) {
3247 $locale = wpfval( WPF()->board, 'locale' );
3248 }
3249
3250 // 5. If still no locale, use WordPress locale
3251 if ( empty( $locale ) ) {
3252 $locale = get_locale();
3253 }
3254
3255 // Extract 2-letter language code from locale (e.g., 'en_US' -> 'en')
3256 $lang_code = substr( $locale, 0, 2 );
3257
3258 if ( isset( $language_map[ $lang_code ] ) ) {
3259 return $language_map[ $lang_code ];
3260 }
3261
3262 // Default to English
3263 return 'English';
3264 }
3265
3266 /**
3267 * Replace AI link markers with actual HTML links
3268 *
3269 * Converts [[#N]] and [[#N:Title]] markers to clickable links
3270 *
3271 * @param string $text Text containing link markers
3272 * @param array $url_map Map of result numbers to URLs (1-indexed)
3273 * @return string Text with markers replaced by HTML links
3274 */
3275 private function replace_ai_link_markers( $text, $url_map ) {
3276 if ( empty( $text ) ) {
3277 return '';
3278 }
3279
3280 // Replace [[#N:Title]] format - title with link
3281 $text = preg_replace_callback(
3282 '/\[\[#(\d+):([^\]]+)\]\]/',
3283 function ( $matches ) use ( $url_map ) {
3284 $num = (int) $matches[1];
3285 // Strip guillemet quotes «» from title (AI uses them as formatting markers)
3286 $title = trim( $matches[2], '«» ' );
3287 $title = esc_html( $title );
3288 $url = isset( $url_map[ $num ] ) ? esc_url( $url_map[ $num ] ) : '#';
3289 return '<a href="' . $url . '" class="wpf-ai-result-link" target="_blank" rel="noopener">' . $title . '</a>';
3290 },
3291 $text
3292 );
3293
3294 // Replace [[#N]] format - just number with link
3295 $text = preg_replace_callback(
3296 '/\[\[#(\d+)\]\]/',
3297 function ( $matches ) use ( $url_map ) {
3298 $num = (int) $matches[1];
3299 $url = isset( $url_map[ $num ] ) ? esc_url( $url_map[ $num ] ) : '#';
3300 return '<a href="' . $url . '" class="wpf-ai-result-link" target="_blank" rel="noopener">#' . $num . '</a>';
3301 },
3302 $text
3303 );
3304
3305 return $text;
3306 }
3307
3308 /**
3309 * Generate HTML for AI search recommendations section
3310 *
3311 * Builds the complete HTML for the recommendations section server-side
3312 * to avoid JavaScript having to handle HTML escaping issues.
3313 *
3314 * @param array $recommendations Array of recommendation objects with title, recommendation, url, result_number
3315 * @return string Complete HTML for the recommendations section
3316 */
3317 private function render_recommendations_html( $recommendations ) {
3318 if ( empty( $recommendations ) ) {
3319 return '';
3320 }
3321
3322 $html = '<div class="wpf-ai-recommendations-section">';
3323 $html .= '<div class="wpf-ai-section-header"><i class="fas fa-lightbulb"></i> ' . esc_html( wpforo_phrase( 'AI Recommendations', false ) ) . '</div>';
3324 $html .= '<div class="wpf-ai-recommendations-list">';
3325
3326 foreach ( $recommendations as $rec ) {
3327 $url = ! empty( $rec['url'] ) ? esc_url( $rec['url'] ) : '#';
3328 $result_number = isset( $rec['result_number'] ) ? (int) $rec['result_number'] : 0;
3329 // title and recommendation already contain safe HTML from replace_ai_link_markers()
3330 $title = $rec['title'] ?? '';
3331 $recommendation = $rec['recommendation'] ?? '';
3332
3333 $html .= '<div class="wpf-ai-recommendation-card">';
3334 $html .= '<div class="wpf-ai-recommendation-number">' . $result_number . '</div>';
3335 $html .= '<div class="wpf-ai-recommendation-content">';
3336 $html .= '<div class="wpf-ai-recommendation-title"><a href="' . $url . '" target="_blank" rel="noopener">' . $title . '</a></div>';
3337 $html .= '<div class="wpf-ai-recommendation-text">' . $recommendation . '</div>';
3338 $html .= '</div>';
3339 $html .= '</div>';
3340 }
3341
3342 $html .= '</div>';
3343 $html .= '</div>';
3344
3345 return $html;
3346 }
3347
3348 /**
3349 * Replace topic summary link markers with actual HTML links
3350 *
3351 * Converts [[#POST_ID]] and [[#POST_ID:Title]] markers to clickable links to specific posts.
3352 * The POST_ID is the actual database post ID, not a sequential number.
3353 *
3354 * @param string $text Text containing link markers
3355 * @param int $topicid Topic ID for fallback URL
3356 * @return string Text with markers replaced by HTML links
3357 */
3358 private function replace_summary_link_markers( $text, $topicid ) {
3359 if ( empty( $text ) ) {
3360 return '';
3361 }
3362
3363 // Replace [[#POST_ID:Title]] format - title with link to post
3364 $text = preg_replace_callback(
3365 '/\[\[#(\d+):([^\]]+)\]\]/',
3366 function ( $matches ) use ( $topicid ) {
3367 $postid = (int) $matches[1];
3368 $title = esc_html( $matches[2] );
3369 $url = WPF()->post->get_url( $postid );
3370 if ( empty( $url ) || $url === wpforo_home_url() ) {
3371 // Fallback to topic URL if post URL not found
3372 $url = WPF()->topic->get_url( $topicid );
3373 }
3374 return '<a href="' . esc_url( $url ) . '" class="wpf-ai-post-link">' . $title . '</a>';
3375 },
3376 $text
3377 );
3378
3379 // Replace [[#POST_ID]] format - just number with "Reply #N" link
3380 $text = preg_replace_callback(
3381 '/\[\[#(\d+)\]\]/',
3382 function ( $matches ) use ( $topicid ) {
3383 $postid = (int) $matches[1];
3384 $url = WPF()->post->get_url( $postid );
3385 if ( empty( $url ) || $url === wpforo_home_url() ) {
3386 // Fallback to topic URL if post URL not found
3387 $url = WPF()->topic->get_url( $topicid );
3388 }
3389 return '<a href="' . esc_url( $url ) . '" class="wpf-ai-post-link">#' . $postid . '</a>';
3390 },
3391 $text
3392 );
3393
3394 return $text;
3395 }
3396
3397 /**
3398 * AJAX handler for semantic search
3399 *
3400 * @return void
3401 */
3402 public function ajax_semantic_search() {
3403 // Verify nonce
3404 check_ajax_referer( 'wpforo_ai_features_nonce', '_wpnonce' );
3405
3406 // Check user permissions
3407 if ( ! current_user_can( 'manage_options' ) ) {
3408 wp_send_json_error( [
3409 'message' => wpforo_phrase( 'Insufficient permissions', false )
3410 ], 403 );
3411 }
3412
3413 // Get search parameters
3414 $query = sanitize_text_field( wpfval( $_POST, 'query' ) );
3415 $limit = isset( $_POST['limit'] ) ? min( (int) $_POST['limit'], 100 ) : 10;
3416
3417 if ( empty( $query ) ) {
3418 wp_send_json_error( [
3419 'message' => wpforo_phrase( 'Search query is required', false )
3420 ], 400 );
3421 }
3422
3423 // Perform search via VectorStorageManager (handles local vs cloud routing)
3424 $results = WPF()->vector_storage->semantic_search( $query, $limit );
3425
3426 if ( is_wp_error( $results ) ) {
3427 wp_send_json_error( [
3428 'message' => $results->get_error_message()
3429 ], 500 );
3430 }
3431
3432 // Clean content/excerpt for display (strip Lambda processing markers)
3433 if ( ! empty( $results['results'] ) ) {
3434 foreach ( $results['results'] as &$result ) {
3435 if ( ! empty( $result['excerpt'] ) ) {
3436 $result['excerpt'] = $this->clean_content_for_search_display( $result['excerpt'] );
3437 }
3438 if ( ! empty( $result['content'] ) ) {
3439 $result['content'] = $this->clean_content_for_search_display( $result['content'] );
3440 }
3441 }
3442 unset( $result );
3443 }
3444
3445 // Return success response
3446 wp_send_json_success( $results );
3447 }
3448
3449 /**
3450 * AJAX handler for public front-end semantic search
3451 *
3452 * Accessible to all users (logged-in and guests)
3453 * Returns enriched results with topic metadata
3454 *
3455 * @return void
3456 */
3457 public function ajax_public_semantic_search() {
3458 // Track start time for logging
3459 $_log_start_time = microtime( true );
3460
3461 // Verify nonce (action name matches nonce key in wpforo.nonces object)
3462 check_ajax_referer( 'wpforo_ai_public_search', '_wpnonce' );
3463
3464 // Note: Rate limit check moved after cache check
3465 // Cached search results should bypass rate limits since they don't use API resources
3466
3467 // Check if AI service is available
3468 if ( ! $this->is_service_available() ) {
3469 wp_send_json_error( [
3470 'message' => wpforo_phrase( 'AI service is not available', false )
3471 ], 403 );
3472 }
3473
3474 // Check if AI semantic search is enabled
3475 if ( ! wpforo_setting( 'ai', 'search' ) ) {
3476 wp_send_json_error( [
3477 'message' => wpforo_phrase( 'AI Semantic Search is disabled', false )
3478 ], 403 );
3479 }
3480
3481 // Check usergroup permission
3482 if ( ! WPF()->usergroup->can( 'ai_search' ) ) {
3483 wp_send_json_error( [
3484 'message' => wpforo_phrase( 'You do not have permission to use this feature', false )
3485 ], 403 );
3486 }
3487
3488 // Check if any content has been indexed
3489 if ( WPF()->vector_storage && ! WPF()->vector_storage->has_indexed_content() ) {
3490 $message = wpforo_phrase( 'No content has been indexed from this forum yet', false );
3491 if ( current_user_can( 'manage_options' ) ) {
3492 $message .= '. ' . wpforo_phrase( 'Please go to Dashboard > wpForo > AI Features > AI Content Indexing and start the content indexing', false );
3493 }
3494 wp_send_json_success( [
3495 'results' => [],
3496 'total' => 0,
3497 'has_more' => false,
3498 'no_indexed_content' => true,
3499 'message' => $message,
3500 ] );
3501 }
3502
3503 // Get search parameters
3504 $query = sanitize_text_field( wpfval( $_POST, 'query' ) );
3505 $limit = isset( $_POST['limit'] ) ? min( (int) $_POST['limit'], 20 ) : 5;
3506 $offset = isset( $_POST['offset'] ) ? (int) $_POST['offset'] : 0;
3507 $language_code = sanitize_text_field( wpfval( $_POST, 'language' ) );
3508
3509 if ( empty( $query ) ) {
3510 wp_send_json_error( [
3511 'message' => wpforo_phrase( 'Search query is required', false )
3512 ], 400 );
3513 }
3514
3515 $current_boardid = (string) WPF()->board->get_current( 'boardid' );
3516 $search_quality = wpfval( WPF()->settings->ai, 'search_quality' ) ?: 'balanced';
3517 $accessible_forumids = $this->get_accessible_forumids();
3518 $search_cache_key = $this->build_search_cache_key( $query, $limit, $offset, $current_boardid, $search_quality, $accessible_forumids );
3519 $search_from_cache = false;
3520
3521 $cached_results = $this->get_ai_cache( self::CACHE_TYPE_SEARCH, $search_cache_key );
3522
3523 if ( $cached_results ) {
3524 $results = $cached_results;
3525 $search_from_cache = true;
3526 } else {
3527 $this->check_rate_limit( 'search' );
3528
3529 $filters = [ 'accessible_forumids' => $accessible_forumids ];
3530 $results = WPF()->vector_storage->semantic_search( $query, $limit + $offset, $filters );
3531
3532 if ( is_wp_error( $results ) ) {
3533 wp_send_json_error( [
3534 'message' => $results->get_error_message()
3535 ], 500 );
3536 }
3537
3538 $this->set_ai_cache( self::CACHE_TYPE_SEARCH, $search_cache_key, $results );
3539 }
3540
3541 if ( is_wp_error( $results ) ) {
3542 wp_send_json_error( [
3543 'message' => $results->get_error_message()
3544 ], 500 );
3545 }
3546
3547 // Get results array
3548 $search_results = wpfval( $results, 'results' ) ?: [];
3549 $total = wpfval( $results, 'total' ) ?: 0;
3550
3551 // Apply offset and limit for pagination
3552 if ( $offset > 0 ) {
3553 $search_results = array_slice( $search_results, $offset, $limit );
3554 } else {
3555 $search_results = array_slice( $search_results, 0, $limit );
3556 }
3557
3558 // Get minimum score threshold from settings (default 30%)
3559 // Local cosine similarities are on a different scale (5-25%) than cloud scores (30-90%),
3560 // so apply 1/3 of the configured threshold for local mode.
3561 // Absolute minimum of 15% for local mode prevents garbage results (random vectors
3562 // have ~5-15% cosine similarity with any query).
3563 $is_local_mode = WPF()->vector_storage && WPF()->vector_storage->is_local_mode();
3564 $min_score_setting = (int) wpfval( WPF()->settings->ai, 'search_min_score' );
3565 if ( $is_local_mode ) {
3566 $local_threshold = round( $min_score_setting / 3 );
3567 $min_score_percent = max( 15, $local_threshold ); // Absolute minimum 15%
3568 } else {
3569 $min_score_percent = max( 0, min( 100, $min_score_setting ) );
3570 }
3571
3572 // Relevance label thresholds - different for local vs cloud modes
3573 // Local cosine scores: 5-25% range (raw similarity)
3574 // Cloud re-ranked scores: 30-90% range (LLM re-ranked)
3575 if ( $is_local_mode ) {
3576 $threshold_excellent = 25;
3577 $threshold_good = 20;
3578 $threshold_relevant = 15;
3579 } else {
3580 $threshold_excellent = 80;
3581 $threshold_good = 60;
3582 $threshold_relevant = 40;
3583 }
3584
3585 // Enrich results with wpForo/WordPress data
3586 // Cloud API returns: id, score, title, excerpt, url, content_source, metadata{...}
3587 // Local storage returns: topic_id, post_id, forum_id, title, content, score, url, post_url, created, user_id, content_type
3588 $enriched_results = [];
3589 foreach ( $search_results as $result ) {
3590 $content_source = wpfval( $result, 'content_source' ) ?: wpfval( $result, 'metadata', 'content_source' );
3591
3592 if ( $content_source === 'wordpress' ) {
3593 // ── WordPress CPT result ──
3594 $wp_post_id = wpfval( $result, 'metadata', 'post_id' );
3595 if ( empty( $wp_post_id ) ) continue;
3596
3597 $wp_post = get_post( (int) $wp_post_id );
3598 if ( ! $wp_post || $wp_post->post_status !== 'publish' ) continue;
3599
3600 // Use real WP post_type (post, page, product, etc.)
3601 $post_type_obj = get_post_type_object( $wp_post->post_type );
3602 $post_type_label = $post_type_obj ? $post_type_obj->labels->singular_name : ucfirst( $wp_post->post_type );
3603
3604 $url = get_permalink( $wp_post );
3605
3606 $author = get_user_by( 'id', $wp_post->post_author );
3607 $author_name = $author ? $author->display_name : '';
3608
3609 $content = wpfval( $result, 'content' ) ?: wpfval( $result, 'excerpt' ) ?: '';
3610 $content = $this->clean_content_for_search_display( $content );
3611
3612 $score = wpfval( $result, 'score' ) ?: 0;
3613 $score_percent = round( $score * 100 );
3614
3615 if ( $min_score_percent > 0 && $score_percent < $min_score_percent ) continue;
3616
3617 if ( $score_percent >= $threshold_excellent ) {
3618 $relevance_label = wpforo_phrase( 'Excellent match', false );
3619 } elseif ( $score_percent >= $threshold_good ) {
3620 $relevance_label = wpforo_phrase( 'Good match', false );
3621 } elseif ( $score_percent >= $threshold_relevant ) {
3622 $relevance_label = wpforo_phrase( 'Relevant', false );
3623 } else {
3624 $relevance_label = wpforo_phrase( 'Possibly relevant', false );
3625 }
3626
3627 $enriched_results[] = [
3628 'title' => wpfval( $result, 'title' ) ?: $wp_post->post_title,
3629 'url' => $url,
3630 'content' => $content,
3631 'score' => $score_percent,
3632 'relevance_label' => $relevance_label,
3633 'content_source' => 'wordpress',
3634 'post_type_label' => $post_type_label,
3635 'post_id' => (int) $wp_post_id,
3636 'forum_title' => '',
3637 'forum_url' => '',
3638 'author_name' => $author_name,
3639 'author_url' => '',
3640 'created' => $wp_post->post_date ? date( 'Y-m-d H:i', strtotime( $wp_post->post_date ) ) : '',
3641 'created_ago' => $wp_post->post_date ? human_time_diff( strtotime( $wp_post->post_date ) ) . ' ago' : '',
3642 ];
3643 } elseif ( $content_source === 'custom_knowledge' ) {
3644 // ── Custom Knowledge result (Business+ cloud mode only) ──
3645 $title = wpfval( $result, 'title' ) ?: wpfval( $result, 'metadata', 'title' ) ?: wpforo_phrase( 'Knowledge Base', false );
3646 $content = wpfval( $result, 'excerpt' ) ?: wpfval( $result, 'metadata', 'content_preview' ) ?: '';
3647 $content = $this->clean_content_for_search_display( $content );
3648
3649 $score = wpfval( $result, 'score' ) ?: 0;
3650 $score_percent = round( $score * 100 );
3651
3652 if ( $min_score_percent > 0 && $score_percent < $min_score_percent ) {
3653 continue;
3654 }
3655
3656 if ( $score_percent >= $threshold_excellent ) {
3657 $relevance_label = wpforo_phrase( 'Excellent match', false );
3658 } elseif ( $score_percent >= $threshold_good ) {
3659 $relevance_label = wpforo_phrase( 'Good match', false );
3660 } elseif ( $score_percent >= $threshold_relevant ) {
3661 $relevance_label = wpforo_phrase( 'Relevant', false );
3662 } else {
3663 $relevance_label = wpforo_phrase( 'Possibly relevant', false );
3664 }
3665
3666 $enriched_results[] = [
3667 'title' => $title,
3668 'url' => '', // Custom knowledge has no URL
3669 'content' => $content,
3670 'score' => $score_percent,
3671 'relevance_label' => $relevance_label,
3672 'content_source' => 'custom_knowledge',
3673 'post_type_label' => wpforo_phrase( 'Knowledge Base', false ),
3674 'post_id' => 0,
3675 'forum_title' => '',
3676 'forum_url' => '',
3677 'author_name' => '',
3678 'author_url' => '',
3679 'created' => '',
3680 'created_ago' => '',
3681 ];
3682 } else {
3683 // ── Forum result ──
3684 $topic_id = wpfval( $result, 'topic_id' ) ?: wpfval( $result, 'metadata', 'thread_id' );
3685
3686 if ( empty( $topic_id ) ) {
3687 continue;
3688 }
3689
3690 $topic = wpforo_topic( $topic_id );
3691 if ( empty( $topic ) ) {
3692 continue;
3693 }
3694
3695 if ( ! WPF()->topic->view_access( $topic ) ) {
3696 continue;
3697 }
3698
3699 // Get URL - use /postid/<postid>/ format for direct post linking
3700 $chunk_post_id = wpfval( $result, 'post_id' ) ?: wpfval( $result, 'metadata', 'chunk_post_id' );
3701 if ( $chunk_post_id ) {
3702 $url = wpforo_home_url( wpforo_settings_get_slug( 'postid' ) . '/' . intval( $chunk_post_id ) . '/' );
3703 } else {
3704 $url = wpfval( $result, 'url' ) ?: wpforo_topic( $topic_id, 'url' );
3705 }
3706
3707 // Author: prefer chunk author (specific post), fallback to topic author
3708 $author_id = wpfval( $result, 'user_id' ) ?: wpfval( $result, 'metadata', 'chunk_author_id' ) ?: wpfval( $result, 'metadata', 'topic_author_id' ) ?: $topic['userid'];
3709 $author_name = wpfval( $result, 'metadata', 'chunk_display_name' ) ?: wpfval( $result, 'metadata', 'topic_display_name' );
3710 if ( ! $author_name ) {
3711 $author = wpforo_member( $author_id );
3712 $author_name = wpfval( $author, 'display_name' ) ?: wpfval( $author, 'user_login' );
3713 }
3714
3715 $content = wpfval( $result, 'content' ) ?: wpfval( $result, 'excerpt' ) ?: '';
3716 $content = $this->clean_content_for_search_display( $content );
3717
3718 $score = wpfval( $result, 'score' ) ?: 0;
3719 $score_percent = round( $score * 100 );
3720
3721 if ( $min_score_percent > 0 && $score_percent < $min_score_percent ) {
3722 continue;
3723 }
3724
3725 if ( $score_percent >= $threshold_excellent ) {
3726 $relevance_label = wpforo_phrase( 'Excellent match', false );
3727 } elseif ( $score_percent >= $threshold_good ) {
3728 $relevance_label = wpforo_phrase( 'Good match', false );
3729 } elseif ( $score_percent >= $threshold_relevant ) {
3730 $relevance_label = wpforo_phrase( 'Relevant', false );
3731 } else {
3732 $relevance_label = wpforo_phrase( 'Possibly relevant', false );
3733 }
3734
3735 $forum_title = wpfval( $result, 'metadata', 'forum_name' ) ?: wpforo_forum( $topic['forumid'], 'title' );
3736 $forum_url = wpforo_forum( $topic['forumid'], 'url' );
3737
3738 $title = wpfval( $result, 'title' ) ?: wpfval( $result, 'metadata', 'topic_title' ) ?: $topic['title'];
3739
3740 $enriched_results[] = [
3741 'title' => $title,
3742 'url' => $url,
3743 'content' => $content,
3744 'score' => $score_percent,
3745 'relevance_label' => $relevance_label,
3746 'content_source' => 'forum',
3747 'post_type_label' => '',
3748 'post_id' => $chunk_post_id ? (int) $chunk_post_id : 0,
3749 'forum_title' => $forum_title,
3750 'forum_url' => $forum_url,
3751 'author_name' => $author_name,
3752 'author_url' => wpforo_member( $author_id, 'profile_url' ),
3753 'created' => wpforo_date( $topic['created'], 'Y-m-d H:i', false ),
3754 'created_ago' => wpforo_date( $topic['created'], '', false ),
3755 ];
3756 }
3757 }
3758
3759 // Get AI enhancement (summary + recommendations) if we have 3+ results
3760 $ai_enhancement = null;
3761 $enhance_credits_used = 0;
3762 $enhance_from_cache = false;
3763 $_debug_enhance = [
3764 'results_count' => count( $enriched_results ),
3765 'min_required' => 3,
3766 'setting_value' => wpfval( WPF()->settings->ai, 'search_enhance' ),
3767 'setting_type' => gettype( wpfval( WPF()->settings->ai, 'search_enhance' ) ),
3768 'all_ai_settings' => WPF()->settings->ai,
3769 ];
3770 if ( count( $enriched_results ) >= 3 ) {
3771 $user_language = $this->get_user_language( $language_code );
3772
3773 // Build cache key based on query + results + language
3774 $cache_key = $this->build_enhance_cache_key( $query, $enriched_results, $user_language );
3775
3776 // Check cache first
3777 $cached_enhancement = $this->get_ai_cache( self::CACHE_TYPE_SEARCH_ENHANCE, $cache_key );
3778
3779 if ( $cached_enhancement ) {
3780 // Cache hit - use cached response, but reprocess recommendations for link markers
3781 // (older cached entries may have unprocessed [[#N:Title]] markers)
3782 $enhance_from_cache = true;
3783 $url_map = [];
3784 foreach ( $enriched_results as $index => $result ) {
3785 $url_map[ $index + 1 ] = wpfval( $result, 'url' ) ?: '#';
3786 }
3787 $cached_recs = wpfval( $cached_enhancement, 'recommendations' ) ?: [];
3788 foreach ( $cached_recs as &$rec ) {
3789 if ( ! empty( $rec['title'] ) ) {
3790 $rec['title'] = $this->replace_ai_link_markers( $rec['title'], $url_map );
3791 }
3792 if ( ! empty( $rec['description'] ) ) {
3793 $rec['description'] = $this->replace_ai_link_markers( $rec['description'], $url_map );
3794 }
3795 }
3796 unset( $rec );
3797 $cached_enhancement['recommendations'] = $cached_recs;
3798 // Generate recommendations HTML server-side
3799 $cached_enhancement['recommendations_html'] = $this->render_recommendations_html( $cached_recs );
3800 $ai_enhancement = $cached_enhancement;
3801 $_debug_enhance['source'] = 'cache';
3802 } else {
3803 // Cache miss - call enhance API
3804 $enhance_response = $this->enhance_search_results(
3805 $query,
3806 $enriched_results,
3807 $user_language
3808 );
3809 $_debug_enhance['source'] = 'api';
3810 $_debug_enhance['api_response'] = $enhance_response;
3811
3812 if ( ! is_wp_error( $enhance_response ) && wpfval( $enhance_response, 'success' ) ) {
3813 // Track credits used by AI enhancement
3814 $enhance_credits_used = wpfval( $enhance_response, 'credits_used' ) ?: 0;
3815
3816 // Build URL map for link replacement (1-indexed)
3817 $url_map = [];
3818 foreach ( $enriched_results as $index => $result ) {
3819 $url_map[ $index + 1 ] = wpfval( $result, 'url' ) ?: '#';
3820 }
3821
3822 // Process recommendations to add URL for each and convert link markers
3823 $recommendations = wpfval( $enhance_response, 'recommendations' ) ?: [];
3824 foreach ( $recommendations as &$rec ) {
3825 $result_num = wpfval( $rec, 'result_number' );
3826 $rec['url'] = isset( $url_map[ $result_num ] ) ? $url_map[ $result_num ] : '#';
3827 // Process link markers in title and recommendation
3828 if ( ! empty( $rec['title'] ) ) {
3829 $rec['title'] = $this->replace_ai_link_markers( $rec['title'], $url_map );
3830 }
3831 if ( ! empty( $rec['recommendation'] ) ) {
3832 $rec['recommendation'] = $this->replace_ai_link_markers( $rec['recommendation'], $url_map );
3833 }
3834 }
3835 unset( $rec ); // Break reference
3836
3837 $ai_enhancement = [
3838 'summary' => $this->replace_ai_link_markers( wpfval( $enhance_response, 'summary' ) ?: '', $url_map ),
3839 'quick_answer' => $this->replace_ai_link_markers( wpfval( $enhance_response, 'quick_answer' ) ?: '', $url_map ),
3840 'recommendations' => $recommendations,
3841 // Generate recommendations HTML server-side
3842 'recommendations_html' => $this->render_recommendations_html( $recommendations ),
3843 ];
3844
3845 // Store in cache for future requests
3846 $this->set_ai_cache( self::CACHE_TYPE_SEARCH_ENHANCE, $cache_key, $ai_enhancement );
3847 }
3848 }
3849 }
3850
3851 // Log the action
3852 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
3853 // Determine status: cached only if both search and enhance (if used) came from cache
3854 $all_from_cache = $search_from_cache && ( ! $ai_enhancement || $enhance_from_cache );
3855 $log_status = $all_from_cache ? AILogs::STATUS_CACHED : AILogs::STATUS_SUCCESS;
3856
3857 // Build response summary including enhancement info
3858 $response_parts = [ sprintf( '%d results', $total ) ];
3859 if ( $ai_enhancement ) {
3860 $response_parts[] = $enhance_from_cache ? 'AI enhanced (cached)' : 'AI enhanced';
3861 }
3862
3863 WPF()->ai_logs->log( [
3864 'action_type' => AILogs::ACTION_PUBLIC_SEARCH,
3865 'credits_used' => $enhance_credits_used,
3866 'status' => $log_status,
3867 'request_summary' => $query,
3868 'response_summary' => implode( ', ', $response_parts ),
3869 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
3870 ] );
3871 }
3872
3873 // Update total to reflect filtered results count
3874 $filtered_total = count( $enriched_results );
3875
3876 // Return enriched results with AI enhancement
3877 wp_send_json_success( [
3878 'results' => $enriched_results,
3879 'total' => $filtered_total,
3880 'has_more' => ( $offset + $limit ) < $total && $filtered_total >= $limit,
3881 'query' => $query,
3882 'ai_enhancement' => $ai_enhancement,
3883 'search_from_cache' => $search_from_cache,
3884 '_debug_enhance' => $_debug_enhance, // TEMP DEBUG - remove after fixing
3885 ] );
3886 }
3887
3888 /**
3889 * AJAX handler to save user AI search preferences
3890 *
3891 * Stores preferences in user_meta as serialized array
3892 * Only accessible to logged-in users
3893 *
3894 * @return void
3895 */
3896 public function ajax_save_ai_preferences() {
3897 // Verify nonce
3898 if ( ! wp_verify_nonce( wpfval( $_POST, 'nonce' ), 'wpforo_ai_preferences' ) ) {
3899 wp_send_json_error( wpforo_phrase( 'Security check failed', false ), 403 );
3900 }
3901
3902 // Must be logged in
3903 $user_id = WPF()->current_userid;
3904 if ( ! $user_id ) {
3905 wp_send_json_error( wpforo_phrase( 'You must be logged in to save preferences', false ), 401 );
3906 }
3907
3908 // Get and validate language
3909 $language = sanitize_text_field( wpfval( $_POST, 'language' ) );
3910 $valid_languages = array_merge( [ '' ], array_column( wpforo_get_ai_languages(), 'locale' ) );
3911 if ( ! in_array( $language, $valid_languages, true ) ) {
3912 $language = get_locale(); // Fallback to WP locale
3913 }
3914
3915 // Get and validate max results (backend validation - max 10)
3916 $max_results = isset( $_POST['max_results'] ) ? (int) $_POST['max_results'] : 5;
3917 $max_results = max( 1, min( 10, $max_results ) ); // Clamp between 1 and 10
3918
3919 // Build preferences array
3920 $preferences = [
3921 'language' => $language,
3922 'max_results' => $max_results,
3923 ];
3924
3925 // Save to user meta
3926 $updated = update_user_meta( $user_id, 'wpforo_ai_search', $preferences );
3927
3928 if ( false === $updated ) {
3929 // Check if it failed or just unchanged
3930 $existing = get_user_meta( $user_id, 'wpforo_ai_search', true );
3931 if ( $existing === $preferences ) {
3932 // No change needed, still success
3933 wp_send_json_success( [ 'message' => wpforo_phrase( 'Preferences saved', false ) ] );
3934 } else {
3935 wp_send_json_error( wpforo_phrase( 'Failed to save preferences', false ), 500 );
3936 }
3937 }
3938
3939 wp_send_json_success( [ 'message' => wpforo_phrase( 'Preferences saved', false ) ] );
3940 }
3941
3942 /**
3943 * Get user AI search preferences
3944 *
3945 * @param int|null $user_id User ID (defaults to current user)
3946 * @return array Preferences array with defaults applied
3947 */
3948 public function get_user_ai_preferences( $user_id = null ) {
3949 if ( null === $user_id ) {
3950 $user_id = WPF()->current_userid;
3951 }
3952
3953 $defaults = [
3954 'language' => get_locale(),
3955 'max_results' => 5,
3956 ];
3957
3958 if ( ! $user_id ) {
3959 return $defaults;
3960 }
3961
3962 $saved = get_user_meta( $user_id, 'wpforo_ai_search', true );
3963 if ( is_array( $saved ) ) {
3964 return wp_parse_args( $saved, $defaults );
3965 }
3966
3967 return $defaults;
3968 }
3969
3970 /**
3971 * AJAX handler for generic AI actions (refresh_status, etc.)
3972 *
3973 * @return void
3974 */
3975 public function ajax_generic_action() {
3976 // Verify nonce
3977 check_ajax_referer( 'wpforo_ai_features_nonce', '_wpnonce' );
3978
3979 // Check user permissions
3980 if ( ! current_user_can( 'manage_options' ) ) {
3981 wp_send_json_error( [
3982 'message' => wpforo_phrase( 'Insufficient permissions', false )
3983 ], 403 );
3984 }
3985
3986 // Get the specific action
3987 $wpforo_ai_action = sanitize_text_field( wpfval( $_POST, 'wpforo_ai_action' ) );
3988
3989 if ( empty( $wpforo_ai_action ) ) {
3990 wp_send_json_error( [
3991 'message' => wpforo_phrase( 'Action is required', false )
3992 ], 400 );
3993 }
3994
3995 // Handle different actions
3996 switch ( $wpforo_ai_action ) {
3997 case 'refresh_status':
3998 // Clear status cache and fetch fresh data
3999 $this->clear_status_cache();
4000 $status = $this->get_tenant_status();
4001
4002 if ( is_wp_error( $status ) ) {
4003 \wpforo_ai_log( 'error', 'Error getting status: ' . $status->get_error_message(), 'Client' );
4004 wp_send_json_error( [
4005 'message' => $status->get_error_message()
4006 ], 500 );
4007 }
4008
4009 wp_send_json_success( [
4010 'status' => $status,
4011 'message' => wpforo_phrase( 'Status refreshed successfully', false )
4012 ] );
4013 break;
4014
4015 case 'start_local_indexing':
4016 // Include helper file if not already loaded
4017 if ( ! function_exists( 'wpforo_ai_handle_start_local_indexing' ) ) {
4018 require_once WPFORO_DIR . '/admin/pages/tabs/ai-features-helpers.php';
4019 }
4020 $result = wpforo_ai_handle_start_local_indexing();
4021 if ( isset( $result['type'] ) && $result['type'] === 'success' ) {
4022 wp_send_json_success( $result );
4023 } else {
4024 wp_send_json_error( $result );
4025 }
4026 break;
4027
4028 case 'process_local_batch':
4029 try {
4030 // Include helper file if not already loaded
4031 if ( ! function_exists( 'wpforo_ai_handle_process_local_batch' ) ) {
4032 require_once WPFORO_DIR . '/admin/pages/tabs/ai-features-helpers.php';
4033 }
4034 $result = wpforo_ai_handle_process_local_batch();
4035 if ( isset( $result['type'] ) && $result['type'] === 'success' ) {
4036 wp_send_json_success( $result );
4037 } else {
4038 wp_send_json_error( $result );
4039 }
4040 } catch ( \Exception $e ) {
4041 \wpforo_ai_log( 'error', 'process_local_batch AJAX exception: ' . $e->getMessage(), 'Client' );
4042 wp_send_json_error( [ 'message' => 'Exception: ' . $e->getMessage() ] );
4043 } catch ( \Error $e ) {
4044 \wpforo_ai_log( 'error', 'process_local_batch AJAX error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine(), 'Client' );
4045 wp_send_json_error( [ 'message' => 'PHP Error: ' . $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine() ] );
4046 }
4047 break;
4048
4049 case 'get_indexing_progress':
4050 // Include helper file if not already loaded
4051 if ( ! function_exists( 'wpforo_ai_handle_get_indexing_progress' ) ) {
4052 require_once WPFORO_DIR . '/admin/pages/tabs/ai-features-helpers.php';
4053 }
4054 $result = wpforo_ai_handle_get_indexing_progress();
4055 if ( isset( $result['type'] ) && $result['type'] === 'success' ) {
4056 wp_send_json_success( $result );
4057 } else {
4058 wp_send_json_error( $result );
4059 }
4060 break;
4061
4062 case 'stop_local_indexing':
4063 // Include helper file if not already loaded
4064 if ( ! function_exists( 'wpforo_ai_handle_stop_local_indexing' ) ) {
4065 require_once WPFORO_DIR . '/admin/pages/tabs/ai-features-helpers.php';
4066 }
4067 $result = wpforo_ai_handle_stop_local_indexing();
4068 if ( isset( $result['type'] ) && $result['type'] === 'success' ) {
4069 wp_send_json_success( $result );
4070 } else {
4071 wp_send_json_error( $result );
4072 }
4073 break;
4074
4075 case 'clear_local_embeddings':
4076 // Include helper file if not already loaded
4077 if ( ! function_exists( 'wpforo_ai_handle_clear_local_embeddings' ) ) {
4078 require_once WPFORO_DIR . '/admin/pages/tabs/ai-features-helpers.php';
4079 }
4080 $result = wpforo_ai_handle_clear_local_embeddings();
4081 if ( isset( $result['type'] ) && $result['type'] === 'success' ) {
4082 wp_send_json_success( $result );
4083 } else {
4084 wp_send_json_error( $result );
4085 }
4086 break;
4087
4088 default:
4089 wp_send_json_error( [
4090 'message' => sprintf( wpforo_phrase( 'Unknown action: %s', false ), $wpforo_ai_action )
4091 ], 400 );
4092 }
4093 }
4094
4095 /**
4096 * WP Cron handler for processing batches in background
4097 *
4098 * @param array $args Arguments containing topic_ids, chunk_size, and overlap_percent
4099 * @return void
4100 */
4101 public function cron_process_batch( $args, $chunk_size_param = null, $overlap_percent_param = null ) {
4102 // Handle both old format (args spread as separate params) and new format (single associative array)
4103 // Old format: $args = topic_ids array, $chunk_size_param = chunk_size, $overlap_percent_param = overlap
4104 // New format: $args = ['topic_ids' => [...], 'chunk_size' => ..., 'overlap_percent' => ...]
4105 if ( is_array( $args ) && isset( $args['topic_ids'] ) ) {
4106 // New format: associative array with all params
4107 $topic_ids = $args['topic_ids'];
4108 // Use explicit checks because wpfval returns 0/null if that's the stored value
4109 $chunk_size = ! empty( $args['chunk_size'] ) ? (int) $args['chunk_size'] : 512;
4110 $overlap_percent = isset( $args['overlap_percent'] ) && $args['overlap_percent'] !== null ? (int) $args['overlap_percent'] : 20;
4111 } else {
4112 // Old format: args are spread as separate parameters
4113 $topic_ids = is_array( $args ) ? $args : [];
4114 $chunk_size = ! empty( $chunk_size_param ) ? (int) $chunk_size_param : 512;
4115 $overlap_percent = $overlap_percent_param !== null ? (int) $overlap_percent_param : 20;
4116 }
4117
4118 if ( empty( $topic_ids ) ) {
4119 $this->log_error( 'cron_batch_empty', 'No topic IDs in batch' );
4120 return;
4121 }
4122
4123 $this->log_info( 'cron_batch_started', [
4124 'topics_count' => count( $topic_ids ),
4125 'chunk_size' => $chunk_size,
4126 'overlap_percent' => $overlap_percent,
4127 ] );
4128
4129 // Use VectorStorageManager to route to appropriate storage backend
4130 $storage_manager = WPF()->vector_storage;
4131 $storage_mode = $storage_manager->get_storage_mode();
4132
4133 $this->log_info( 'cron_batch_storage_mode', [
4134 'storage_mode' => $storage_mode,
4135 ] );
4136
4137 if ( $storage_manager->is_local_mode() ) {
4138 // Local storage: use batch embedding API for efficiency
4139 // Check if batch API flag is set (new format) or fall back to old per-topic method
4140 $use_batch_api = isset( $args['use_batch_api'] ) && $args['use_batch_api'];
4141
4142 if ( $use_batch_api ) {
4143 // New efficient batch method: one API call for all topics
4144 $result = $storage_manager->index_topics_batch_local( $topic_ids, [
4145 'chunk_size' => $chunk_size,
4146 'overlap_percent' => $overlap_percent,
4147 ] );
4148
4149 if ( is_array( $result ) ) {
4150 $this->log_info( 'cron_batch_completed', [
4151 'topics_count' => count( $topic_ids ),
4152 'indexed_count' => $result['indexed_count'] ?? 0,
4153 'skipped_count' => $result['skipped_count'] ?? 0,
4154 'credits_used' => $result['credits_used'] ?? 0,
4155 'errors' => $result['errors'] ?? [],
4156 'storage_mode' => 'local_batch',
4157 ] );
4158 } else {
4159 $this->log_error( 'cron_batch_failed', [
4160 'error' => is_wp_error( $result ) ? $result->get_error_message() : 'Unknown error',
4161 'topics_count' => count( $topic_ids ),
4162 ] );
4163 }
4164 } else {
4165 // Legacy per-topic method (for backwards compatibility)
4166 $success_count = 0;
4167 $error_count = 0;
4168
4169 foreach ( $topic_ids as $topic_id ) {
4170 $result = $storage_manager->index_topic( $topic_id, [
4171 'chunk_size' => $chunk_size,
4172 'overlap_percent' => $overlap_percent,
4173 ] );
4174
4175 if ( is_wp_error( $result ) ) {
4176 $this->log_error( 'cron_topic_index_failed', [
4177 'topic_id' => $topic_id,
4178 'error' => $result->get_error_message(),
4179 ] );
4180 $error_count++;
4181 } else {
4182 $success_count += $result['indexed_count'] ?? 0;
4183 }
4184 }
4185
4186 $this->log_info( 'cron_batch_completed', [
4187 'topics_count' => count( $topic_ids ),
4188 'success_count' => $success_count,
4189 'error_count' => $error_count,
4190 'storage_mode' => 'local',
4191 ] );
4192 }
4193 } else {
4194 // Cloud storage: use existing batch ingestion
4195 $response = $this->ingest_topics( $topic_ids, $chunk_size, $overlap_percent );
4196
4197 if ( is_wp_error( $response ) ) {
4198 $this->log_error( 'cron_batch_failed', [
4199 'error' => $response->get_error_message(),
4200 'topics_count' => count( $topic_ids ),
4201 ] );
4202 } else {
4203 $this->log_info( 'cron_batch_completed', [
4204 'topics_count' => count( $topic_ids ),
4205 'response' => $response,
4206 'storage_mode' => 'cloud',
4207 ] );
4208 }
4209 }
4210
4211 // Clear status caches to update progress and credits
4212 $this->clear_rag_status_cache();
4213 $this->clear_status_cache(); // Also clear tenant status to refresh credits
4214 }
4215
4216 /**
4217 * WP Cron handler for processing local indexing queue
4218 *
4219 * This uses a self-rescheduling pattern to process large batches efficiently:
4220 * - Processes 50 topics per execution
4221 * - Reschedules itself if more topics remain in queue
4222 * - Only ONE cron job exists at a time (no job pile-up)
4223 *
4224 * @param int $board_id Board ID for multi-board support
4225 * @return void
4226 */
4227 public function cron_process_queue( $board_id = 0 ) {
4228 // Ensure WPF classes are initialized (may not be in cron context)
4229 if ( is_null( WPF()->vector_storage ) ) {
4230 WPF()->init();
4231 }
4232
4233 $storage_manager = WPF()->vector_storage;
4234 if ( ! $storage_manager ) {
4235 return; // Still null, can't proceed
4236 }
4237
4238 // Delegate to VectorStorageManager which handles the queue processing
4239 $storage_manager->cron_process_queue( $board_id );
4240
4241 // Clear status caches to update progress and credits
4242 $this->clear_rag_status_cache();
4243 $this->clear_status_cache();
4244 }
4245
4246 /**
4247 * Process the LOCAL auto-indexing queue (mode-specific cron handler)
4248 *
4249 * This ensures topics queued for local indexing are processed with local indexing,
4250 * even if the user has since switched to cloud mode.
4251 *
4252 * @param int $board_id Board ID
4253 */
4254 public function cron_process_queue_local( $board_id = 0 ) {
4255 // Ensure WPF classes are initialized (may not be in cron context)
4256 if ( is_null( WPF()->vector_storage ) ) {
4257 WPF()->init();
4258 }
4259
4260 $storage_manager = WPF()->vector_storage;
4261 if ( ! $storage_manager ) {
4262 return;
4263 }
4264
4265 // Process the local-specific queue
4266 $storage_manager->cron_process_queue_mode( $board_id, 'local' );
4267
4268 // Clear status caches
4269 $this->clear_rag_status_cache();
4270 $this->clear_status_cache();
4271 }
4272
4273 /**
4274 * Process the CLOUD auto-indexing queue (mode-specific cron handler)
4275 *
4276 * This ensures topics queued for cloud indexing are processed with cloud indexing,
4277 * even if the user has since switched to local mode.
4278 *
4279 * @param int $board_id Board ID
4280 */
4281 public function cron_process_queue_cloud( $board_id = 0 ) {
4282 // Ensure WPF classes are initialized (may not be in cron context)
4283 if ( is_null( WPF()->vector_storage ) ) {
4284 WPF()->init();
4285 }
4286
4287 $storage_manager = WPF()->vector_storage;
4288 if ( ! $storage_manager ) {
4289 return;
4290 }
4291
4292 // Process the cloud-specific queue
4293 $storage_manager->cron_process_queue_mode( $board_id, 'cloud' );
4294
4295 // Clear status caches
4296 $this->clear_rag_status_cache();
4297 $this->clear_status_cache();
4298 }
4299
4300 /**
4301 * Ingest specific topics by ID
4302 *
4303 * @param array $topic_ids Array of topic IDs to ingest
4304 * @return array|WP_Error Response data or error object
4305 */
4306 public function ingest_topics( $topic_ids, $chunk_size = 512, $overlap_percent = 20 ) {
4307 // Check if database clearing is in progress
4308 if ( $this->is_clearing_in_progress() ) {
4309 $remaining = $this->get_clearing_time_remaining();
4310 $minutes = ceil( $remaining / 60 );
4311 return new \WP_Error(
4312 'clearing_in_progress',
4313 sprintf(
4314 /* translators: %d: minutes remaining */
4315 wpforo_phrase( 'Database clearing is in progress. Please wait approximately %d minute(s) before starting new indexing.', false ),
4316 $minutes
4317 )
4318 );
4319 }
4320
4321 if ( empty( $topic_ids ) || ! is_array( $topic_ids ) ) {
4322 return new \WP_Error( 'invalid_topic_ids', wpforo_phrase( 'Please provide valid topic IDs', false ) );
4323 }
4324
4325 // Prepare topic data for indexing
4326 $threads = [];
4327
4328 foreach ( $topic_ids as $topic_id ) {
4329 // Bypass user permission check - this is admin-initiated backend indexing.
4330 // Private/unapproved topics are filtered below; forum-level permissions
4331 // don't apply because WP Cron runs without a logged-in user.
4332 $topic = WPF()->topic->get_topic( $topic_id, false );
4333
4334 if ( empty( $topic ) ) {
4335 $this->log_error( 'topic_not_found', "Topic ID {$topic_id} not found" );
4336 continue;
4337 }
4338
4339 // Skip private topics - they should never be indexed
4340 if ( ! empty( $topic['private'] ) ) {
4341 $this->log_info( 'skipping_private_topic', "Skipping private topic ID {$topic_id}" );
4342 continue;
4343 }
4344
4345 // Skip unapproved topics
4346 if ( isset( $topic['status'] ) && (int) $topic['status'] !== 0 ) {
4347 $this->log_info( 'skipping_unapproved_topic', "Skipping unapproved topic ID {$topic_id}" );
4348 continue;
4349 }
4350
4351 // Get topic posts/replies
4352 // Bypass user permission check - this is admin-initiated backend indexing,
4353 // not a user-facing request. Private/unapproved topics are already filtered
4354 // at the SQL level in forum_ingest handler and above in this function.
4355 $posts = WPF()->post->get_posts(
4356 [
4357 'topicid' => $topic_id,
4358 'orderby' => 'created',
4359 'order' => 'ASC',
4360 'check_private' => false,
4361 ]
4362 );
4363
4364 // Format thread data
4365 $thread = $this->format_thread_data( $topic, $posts );
4366
4367 if ( $thread ) {
4368 // Check if topic needs force re-indexing
4369 // When cloud = 0, it means the topic has changed (new post added)
4370 // and backend should delete old vectors before re-indexing
4371 $cloud_status = isset( $topic['cloud'] ) ? (int) $topic['cloud'] : 0;
4372 if ( $cloud_status === 0 ) {
4373 $thread['force_reindex'] = true;
4374 }
4375
4376 $threads[] = $thread;
4377 }
4378 }
4379
4380 if ( empty( $threads ) ) {
4381 return new \WP_Error( 'no_threads_to_ingest', wpforo_phrase( 'No valid threads found to ingest', false ) );
4382 }
4383
4384 // Get site domain for image/document URL validation on Lambda side
4385 $site_url = get_site_url();
4386 $parsed = wp_parse_url( $site_url );
4387 $domain = $parsed['host'] ?? '';
4388
4389 $data = [
4390 'threads' => $threads,
4391 'chunk_size' => (int) $chunk_size,
4392 'overlap_percent' => (int) $overlap_percent,
4393 'site_domain' => $domain,
4394 ];
4395
4396 // Bumped to 60s (from default 30s) as a safety margin. The backend
4397 // now processes images/documents asynchronously so /rag/ingest
4398 // typically returns in <5s, but large text-only batches can still
4399 // approach the old limit.
4400 $response = $this->post( '/rag/ingest', $data, 60 );
4401
4402 if ( is_wp_error( $response ) ) {
4403 $this->log_error( 'ingest_failed', $response->get_error_message() );
4404
4405 // Log error to AILogs database
4406 if ( isset( WPF()->ai_logs ) ) {
4407 $user_type = AILogs::USER_TYPE_USER;
4408 if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
4409 $user_type = AILogs::USER_TYPE_CRON;
4410 } elseif ( ! get_current_user_id() ) {
4411 $user_type = AILogs::USER_TYPE_SYSTEM;
4412 }
4413
4414 WPF()->ai_logs->log( [
4415 'action_type' => AILogs::ACTION_CONTENT_INDEXING,
4416 'user_type' => $user_type,
4417 'credits_used' => 0,
4418 'status' => AILogs::STATUS_ERROR,
4419 'content_type' => 'topic',
4420 'request_summary' => sprintf( 'Cloud indexing: %d topics', count( $topic_ids ) ),
4421 'error_message' => $response->get_error_message(),
4422 'extra_data' => wp_json_encode( [
4423 'storage_mode' => 'cloud',
4424 'topic_ids' => array_slice( $topic_ids, 0, 20 ),
4425 ] ),
4426 ] );
4427 }
4428
4429 return $response;
4430 }
4431
4432 // Update indexed hash in wpforo_topics table for each indexed topic
4433 // This enables summarization to use cloud-stored posts instead of sending from WordPress
4434 if ( ! empty( $response['indexed_hashes'] ) && is_array( $response['indexed_hashes'] ) ) {
4435 $this->update_topics_indexed_hash( $response['indexed_hashes'] );
4436 }
4437
4438 $this->log_info( 'topics_ingested', [ 'count' => count( $threads ) ] );
4439 $this->clear_rag_status_cache();
4440
4441 do_action( 'wpforo_ai_topics_ingested', $topic_ids, $response );
4442
4443 // Log to AILogs database for tracking
4444 $indexed_count = count( $response['indexed_hashes'] ?? [] );
4445 if ( $indexed_count > 0 && isset( WPF()->ai_logs ) ) {
4446 $user_type = AILogs::USER_TYPE_USER;
4447 if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
4448 $user_type = AILogs::USER_TYPE_CRON;
4449 } elseif ( ! get_current_user_id() ) {
4450 $user_type = AILogs::USER_TYPE_SYSTEM;
4451 }
4452
4453 // Build response summary with image/document stats
4454 $summary_parts = [ sprintf( 'Indexed %d topics to cloud storage', $indexed_count ) ];
4455 $extra = [
4456 'storage_mode' => 'cloud',
4457 'topic_ids' => array_slice( $topic_ids, 0, 20 ),
4458 'topics_indexed' => $indexed_count,
4459 'chunk_size' => $chunk_size,
4460 'overlap_percent' => $overlap_percent,
4461 ];
4462
4463 if ( ! empty( $response['image_processing'] ) ) {
4464 $img_count = $response['image_processing']['images_processed'] ?? 0;
4465 if ( $img_count > 0 ) {
4466 $summary_parts[] = sprintf( '%d images', $img_count );
4467 }
4468 $extra['image_processing'] = $response['image_processing'];
4469 }
4470
4471 if ( ! empty( $response['document_processing'] ) ) {
4472 $doc_count = $response['document_processing']['documents_processed'] ?? 0;
4473 $page_count = $response['document_processing']['total_pages'] ?? 0;
4474 if ( $doc_count > 0 ) {
4475 $summary_parts[] = sprintf( '%d documents (%d pages)', $doc_count, $page_count );
4476 }
4477 $extra['document_processing'] = $response['document_processing'];
4478 }
4479
4480 WPF()->ai_logs->log( [
4481 'action_type' => AILogs::ACTION_CONTENT_INDEXING,
4482 'user_type' => $user_type,
4483 'credits_used' => $response['credits_consumed'] ?? $response['credits_used'] ?? $indexed_count,
4484 'status' => AILogs::STATUS_SUCCESS,
4485 'content_type' => 'topic',
4486 'request_summary' => sprintf( 'Cloud indexing: %d topics', count( $topic_ids ) ),
4487 'response_summary' => implode( ', ', $summary_parts ),
4488 'extra_data' => wp_json_encode( $extra ),
4489 ] );
4490 }
4491
4492 return $response;
4493 }
4494
4495 /**
4496 * Update indexed hash for topics after successful indexing
4497 *
4498 * The indexed hash is an MD5 of "topicid_postcount" which changes when posts are added/removed.
4499 * This enables the summarization feature to use cloud-stored posts instead of requiring WordPress to send them.
4500 *
4501 * @param array $indexed_hashes Array of topicid => hash mappings
4502 * @return int Number of topics updated
4503 */
4504 private function update_topics_indexed_hash( $indexed_hashes ) {
4505 if ( empty( $indexed_hashes ) ) {
4506 return 0;
4507 }
4508
4509 global $wpdb;
4510
4511 // Build CASE statement for hash values (each topic has a specific hash from API)
4512 $case_parts = [];
4513 $topic_ids = [];
4514 foreach ( $indexed_hashes as $topic_id => $hash ) {
4515 $topic_id = (int) $topic_id;
4516 $hash = sanitize_text_field( $hash );
4517 $topic_ids[] = $topic_id;
4518 $case_parts[] = "WHEN {$topic_id} THEN '{$hash}'";
4519 }
4520
4521 if ( empty( $topic_ids ) ) {
4522 return 0;
4523 }
4524
4525 $ids_list = implode( ',', $topic_ids );
4526 $case_statement = implode( ' ', $case_parts );
4527
4528 // Update indexed hash and cloud column in a single query
4529 $updated = $wpdb->query(
4530 "UPDATE `" . WPF()->tables->topics . "`
4531 SET `indexed` = CASE topicid {$case_statement} END,
4532 `cloud` = 1
4533 WHERE topicid IN ({$ids_list})"
4534 );
4535
4536 if ( $updated > 0 ) {
4537 $this->log_info( 'topics_indexed_hash_updated', [ 'count' => $updated ] );
4538 // Clear topic cache to reflect indexed status
4539 wpforo_clean_cache( 'topic' );
4540 // Clear indexing stats cache for fresh counts in UI
4541 WPF()->vector_storage->clear_indexing_stats_cache();
4542 }
4543
4544 return (int) $updated;
4545 }
4546
4547 /**
4548 * Clear indexed status for all topics
4549 *
4550 * Called when:
4551 * - Clear Database is clicked (vectors deleted)
4552 * - Re-Index All is clicked (forcing re-indexing)
4553 * - Disconnect Service is clicked (vectors deleted)
4554 *
4555 * Clears both the `indexed` hash and the `cloud` column.
4556 *
4557 * @return int Number of topics updated
4558 */
4559 private function clear_topics_indexed_status() {
4560 $result = WPF()->db->query(
4561 "UPDATE `" . WPF()->tables->topics . "` SET `indexed` = NULL, `cloud` = 0 WHERE `indexed` IS NOT NULL OR `cloud` = 1"
4562 );
4563
4564 if ( $result !== false && $result > 0 ) {
4565 $this->log_info( 'topics_indexed_status_cleared', [ 'count' => $result ] );
4566 wpforo_clean_cache( 'topic' );
4567 // Clear indexing stats cache for fresh counts
4568 WPF()->vector_storage->clear_indexing_stats_cache();
4569 }
4570
4571 return (int) $result;
4572 }
4573
4574 /**
4575 * Re-index all topics
4576 *
4577 * Memory-efficient implementation using pagination to handle large forums (100,000+ topics)
4578 * Processes topics in batches without loading all topics into memory at once
4579 *
4580 * @param int $chunk_size Chunk size for text splitting in tokens (default: 512 tokens)
4581 * @param int $overlap_percent Overlap percentage for chunking (default: 20%)
4582 * @return array|WP_Error Response data or error object
4583 */
4584 public function reindex_all_topics( $chunk_size = 512, $overlap_percent = 20 ) {
4585 // Check if database clearing is in progress
4586 if ( $this->is_clearing_in_progress() ) {
4587 $remaining = $this->get_clearing_time_remaining();
4588 $minutes = ceil( $remaining / 60 );
4589 return new \WP_Error(
4590 'clearing_in_progress',
4591 sprintf(
4592 /* translators: %d: minutes remaining */
4593 wpforo_phrase( 'Database clearing is in progress. Please wait approximately %d minute(s) before starting new indexing.', false ),
4594 $minutes
4595 )
4596 );
4597 }
4598
4599 $storage_manager = WPF()->vector_storage;
4600
4601 // Get total topic count
4602 $total_topics = WPF()->topic->get_count();
4603
4604 if ( empty( $total_topics ) || $total_topics <= 0 ) {
4605 return new \WP_Error( 'no_topics', wpforo_phrase( 'No topics found in forum', false ) );
4606 }
4607
4608 // Check available credits before starting
4609 $status = $this->get_tenant_status( true ); // Force fresh status
4610 if ( is_wp_error( $status ) ) {
4611 return $status;
4612 }
4613
4614 $credits_available = isset( $status['subscription']['credits_remaining'] ) ? (int) $status['subscription']['credits_remaining'] : 0;
4615
4616 if ( $credits_available <= 0 ) {
4617 return new \WP_Error(
4618 'no_credits',
4619 wpforo_phrase( 'No credits available for indexing. Please wait for your monthly credit reset or upgrade your plan.', false )
4620 );
4621 }
4622
4623 // Get unindexed topics (cloud = 0)
4624 $unindexed_topic_ids = $storage_manager->get_unindexed_topic_ids();
4625 $unindexed_count = count( $unindexed_topic_ids );
4626
4627 // Determine if we're doing incremental indexing or full re-index
4628 $is_reindex_all = ( $unindexed_count === 0 );
4629
4630 if ( $is_reindex_all ) {
4631 // All topics are indexed - user wants to re-index everything
4632 // Clear indexed status to force re-indexing
4633 $this->clear_topics_indexed_status();
4634
4635 $this->log_info( 'reindex_mode', [
4636 'mode' => 'reindex_all',
4637 'total_topics' => $total_topics,
4638 ] );
4639
4640 // Get all topic IDs for re-indexing
4641 $all_topic_ids = $storage_manager->get_unindexed_topic_ids(); // Now all have cloud=0
4642 } else {
4643 // Some topics need indexing - only index those (don't clear status)
4644 $all_topic_ids = $unindexed_topic_ids;
4645
4646 $this->log_info( 'reindex_mode', [
4647 'mode' => 'incremental',
4648 'unindexed_count' => $unindexed_count,
4649 'total_topics' => $total_topics,
4650 ] );
4651 }
4652
4653 // Limit topics to available credits
4654 if ( count( $all_topic_ids ) > $credits_available ) {
4655 $all_topic_ids = array_slice( $all_topic_ids, 0, $credits_available );
4656 }
4657
4658 $this->log_info( 'reindex_credit_check', [
4659 'topics_to_index' => count( $all_topic_ids ),
4660 'credits_available' => $credits_available,
4661 'is_reindex_all' => $is_reindex_all,
4662 ] );
4663
4664 if ( empty( $all_topic_ids ) ) {
4665 return new \WP_Error( 'no_topics', wpforo_phrase( 'No topic IDs collected', false ) );
4666 }
4667
4668 // Configuration for memory-efficient processing
4669 $pagination_size = (int) wpforo_get_option( 'ai_pagination_size', 20 );
4670 $api_batch_size = $pagination_size;
4671
4672 $this->log_info( 'reindex_started', [
4673 'topics_to_index' => count( $all_topic_ids ),
4674 'api_batch_size' => $api_batch_size,
4675 'chunk_size' => $chunk_size,
4676 'overlap_percent' => $overlap_percent,
4677 ] );
4678
4679 // Check if we limited topics due to credits
4680 $original_count = $is_reindex_all ? $total_topics : $unindexed_count;
4681 $topics_limited = count( $all_topic_ids ) < $original_count;
4682 $skipped_topics = $topics_limited ? $original_count - count( $all_topic_ids ) : 0;
4683
4684 // Split topic IDs into batches for API calls
4685 $api_batches = array_chunk( $all_topic_ids, $api_batch_size );
4686 $total_batches = count( $api_batches );
4687
4688 $this->log_info( 'reindex_topic_ids_collected', [
4689 'topic_ids_count' => count( $all_topic_ids ),
4690 'api_batch_size' => $api_batch_size,
4691 'total_batches' => $total_batches,
4692 'batches_to_queue' => max( 0, $total_batches - 1 ), // -1 because first batch is processed immediately
4693 'topics_limited' => $topics_limited,
4694 'skipped_topics' => $skipped_topics,
4695 ] );
4696
4697 // Process first batch immediately (synchronous)
4698 $first_batch = array_shift( $api_batches );
4699 $response = $this->ingest_topics( $first_batch, $chunk_size, $overlap_percent );
4700
4701 if ( is_wp_error( $response ) ) {
4702 $this->log_error( 'reindex_first_batch_failed', $response->get_error_message() );
4703 return $response;
4704 }
4705
4706 // Queue remaining batches for background processing via WP Cron
4707 $scheduled_count = 0;
4708 $failed_schedules = [];
4709
4710 if ( ! empty( $api_batches ) ) {
4711 // Get plan-based interval (Enterprise: 5s, Business: 10s, Professional: 10s, Starter/Free: 20s)
4712 $batch_interval = $this->get_cron_interval_for_plan();
4713
4714 // Reset array keys after array_shift to ensure sequential indices starting from 0
4715 $api_batches = array_values( $api_batches );
4716
4717 foreach ( $api_batches as $batch_index => $batch ) {
4718 // Schedule with staggered timing based on subscription plan
4719 // Higher tier plans get faster indexing speeds
4720 // Note: Args must be wrapped in indexed array so the entire associative array
4721 // is passed as the first argument to the callback
4722 $scheduled_time = time() + ( ( $batch_index + 1 ) * $batch_interval );
4723 $cron_args = [
4724 [
4725 'topic_ids' => $batch,
4726 'chunk_size' => $chunk_size,
4727 'overlap_percent' => $overlap_percent,
4728 ]
4729 ];
4730
4731 $result = wp_schedule_single_event( $scheduled_time, 'wpforo_ai_process_batch', $cron_args );
4732
4733 if ( $result === false ) {
4734 $failed_schedules[] = [
4735 'batch_index' => $batch_index,
4736 'topics_count' => count( $batch ),
4737 'scheduled_time' => $scheduled_time,
4738 ];
4739 } else {
4740 $scheduled_count++;
4741 }
4742 }
4743
4744 $this->log_info( 'reindex_batches_scheduled', [
4745 'scheduled_batches' => $scheduled_count,
4746 'failed_schedules' => count( $failed_schedules ),
4747 'topics_per_batch' => $api_batch_size,
4748 'batch_interval_seconds' => $batch_interval,
4749 'failed_details' => $failed_schedules,
4750 ] );
4751 } else {
4752 $this->log_info( 'reindex_no_batches_to_schedule', [
4753 'reason' => 'All topics fit in first batch',
4754 'first_batch_size' => count( $first_batch ),
4755 ] );
4756 }
4757
4758 // Clear cached status to force refresh
4759 $this->clear_rag_status_cache();
4760
4761 // Trigger action hook for external integrations
4762 do_action( 'wpforo_ai_reindex_started', count( $all_topic_ids ) );
4763
4764 // Build message with credit limiting info if applicable
4765 if ( $topics_limited ) {
4766 $message = sprintf(
4767 wpforo_phrase( 'Re-indexing started: %1$d topics queued (limited by %2$d available credits). %3$d topics skipped.', false ),
4768 count( $all_topic_ids ),
4769 $credits_available,
4770 $skipped_topics
4771 );
4772 } else {
4773 $message = sprintf(
4774 wpforo_phrase( 'Re-indexing started: %d topics queued', false ),
4775 count( $all_topic_ids )
4776 );
4777 }
4778
4779 return [
4780 'success' => true,
4781 'message' => $message,
4782 'topics_queued' => count( $all_topic_ids ),
4783 'topics_limited' => $topics_limited,
4784 'skipped_topics' => $skipped_topics,
4785 'total_topics' => $total_topics,
4786 'total_batches' => $total_batches,
4787 'first_batch_sent' => count( $first_batch ),
4788 'scheduled_crons' => $scheduled_count,
4789 'failed_schedules' => count( $failed_schedules ),
4790 ];
4791 }
4792
4793 /**
4794 * Clear RAG database
4795 *
4796 * The actual deletion is queued and processed asynchronously (up to 5 minutes).
4797 * This returns immediately after queueing the operation.
4798 *
4799 * Local database is updated immediately to reflect the clearing state.
4800 *
4801 * @param int $board_id Board ID (reserved for future multi-board support)
4802 * @return array|WP_Error Response data or error object
4803 */
4804 public function clear_rag_database( $board_id = 0 ) {
4805 // API returns 202 Accepted immediately, actual deletion happens async
4806 $response = $this->delete( '/rag/clear', [], 30 );
4807
4808 if ( is_wp_error( $response ) ) {
4809 $this->log_error( 'rag_clear_failed', $response->get_error_message() );
4810 return $response;
4811 }
4812
4813 $this->log_info( 'rag_database_clear_queued', [
4814 'status' => $response['status'] ?? 'unknown',
4815 ] );
4816 $this->clear_rag_status_cache();
4817
4818 // Set clearing lock to prevent new indexing during async clear
4819 // Lock expires after 5 minutes (async clear should complete by then)
4820 $this->set_clearing_lock();
4821
4822 // Clear indexed status for all topics immediately
4823 // The cloud data will be deleted asynchronously, but we update local state now
4824 // so the UI shows correct counts right away
4825 $this->clear_topics_indexed_status();
4826
4827 do_action( 'wpforo_ai_rag_cleared', $response );
4828
4829 // Return response with note about async processing
4830 return [
4831 'success' => true,
4832 'message' => $response['message'] ?? wpforo_phrase( 'Database clear operation queued', false ),
4833 'status' => 'clearing',
4834 'note' => $response['note'] ?? wpforo_phrase( 'The database is being cleared in the background. This may take a few minutes.', false ),
4835 ];
4836 }
4837
4838 /**
4839 * Clear index for specific forums.
4840 *
4841 * @param array $forum_ids Forum IDs to clear
4842 * @param int $board_id Board ID
4843 * @return array|WP_Error Result or error
4844 */
4845 public function clear_forum_index( $forum_ids, $board_id = 0 ) {
4846 $response = $this->delete( '/rag/forums', [
4847 'forum_ids' => array_values( array_map( 'intval', (array) $forum_ids ) ),
4848 'board_id' => (int) $board_id,
4849 ] );
4850
4851 if ( is_wp_error( $response ) ) {
4852 $this->log_error( 'clear_forum_index_failed', $response->get_error_message(), [ 'forum_ids' => $forum_ids ] );
4853 return $response;
4854 }
4855
4856 $this->log_info( 'forum_index_clear_queued', [
4857 'forum_ids' => $forum_ids,
4858 'board_id' => $board_id,
4859 'forums_count' => $response['forums_count'] ?? count( $forum_ids ),
4860 ] );
4861
4862 $this->clear_rag_status_cache();
4863
4864 // Set clearing lock to prevent new indexing during async clear
4865 // Lock expires after 5 minutes (same as full clear)
4866 $this->set_clearing_lock();
4867
4868 // Return async response - actual deletion happens in background
4869 return [
4870 'success' => true,
4871 'status' => 'clearing',
4872 'message' => $response['message'] ?? wpforo_phrase( 'Forum index clear operation queued. Processing in background.', false ),
4873 'forums_count' => $response['forums_count'] ?? count( $forum_ids ),
4874 ];
4875 }
4876
4877 /**
4878 * Set a lock to prevent new indexing during async database clear
4879 *
4880 * The lock expires after 5 minutes (the maximum time for async clear to complete).
4881 */
4882 private function set_clearing_lock() {
4883 set_transient( 'wpforo_ai_clearing_in_progress', time(), 5 * MINUTE_IN_SECONDS );
4884 }
4885
4886 /**
4887 * Check if database clearing is in progress
4888 *
4889 * @return bool True if clearing is in progress, false otherwise
4890 */
4891 public function is_clearing_in_progress() {
4892 return (bool) get_transient( 'wpforo_ai_clearing_in_progress' );
4893 }
4894
4895 /**
4896 * Get remaining time until clearing lock expires
4897 *
4898 * @return int Seconds remaining, or 0 if not clearing
4899 */
4900 public function get_clearing_time_remaining() {
4901 $started = get_transient( 'wpforo_ai_clearing_in_progress' );
4902 if ( ! $started ) {
4903 return 0;
4904 }
4905 $elapsed = time() - (int) $started;
4906 $remaining = ( 5 * MINUTE_IN_SECONDS ) - $elapsed;
4907 return max( 0, $remaining );
4908 }
4909
4910 /**
4911 * Clear RAG status cache
4912 */
4913 public function clear_rag_status_cache() {
4914 delete_transient( 'wpforo_ai_rag_status' );
4915 }
4916
4917 /**
4918 * Queue a topic for cloud indexing
4919 *
4920 * This method is called by VectorStorageManager when in cloud mode.
4921 * It queues the topic for background processing via the cloud RAG pipeline.
4922 *
4923 * @param int $topicid Topic ID to queue
4924 * @param int $board_id Board ID (reserved for future multi-board support)
4925 * @return array|WP_Error Result or error
4926 */
4927 public function queue_topic_for_indexing( $topicid, $board_id = 0 ) {
4928 $response = $this->post( '/rag/queue', [
4929 'topic_ids' => [ $topicid ],
4930 'board_id' => $board_id,
4931 ] );
4932
4933 if ( is_wp_error( $response ) ) {
4934 $this->log_error( 'queue_topic_failed', $response->get_error_message(), [ 'topicid' => $topicid ] );
4935 return $response;
4936 }
4937
4938 $this->log_info( 'topic_queued_for_indexing', [
4939 'topicid' => $topicid,
4940 'board_id' => $board_id,
4941 ] );
4942
4943 return $response;
4944 }
4945
4946 /**
4947 * Delete a topic from the cloud index
4948 *
4949 * This method is called by VectorStorageManager when in cloud mode.
4950 * It removes all embeddings associated with the topic from the cloud storage.
4951 *
4952 * @param int $topicid Topic ID to delete
4953 * @param int $board_id Board ID (reserved for future multi-board support)
4954 * @return bool|WP_Error True on success, WP_Error on failure
4955 */
4956 public function delete_topic_from_index( $topicid, $board_id = 0 ) {
4957 $response = $this->delete( '/rag/topic/' . intval( $topicid ), [
4958 'board_id' => $board_id,
4959 ] );
4960
4961 if ( is_wp_error( $response ) ) {
4962 $this->log_error( 'delete_topic_failed', $response->get_error_message(), [ 'topicid' => $topicid ] );
4963 return $response;
4964 }
4965
4966 $this->log_info( 'topic_deleted_from_index', [
4967 'topicid' => $topicid,
4968 'board_id' => $board_id,
4969 ] );
4970
4971 return true;
4972 }
4973
4974 /**
4975 * Delete a post from the cloud index
4976 *
4977 * This method is called by VectorStorageManager when in cloud mode.
4978 * It removes the embedding for a specific post from the cloud storage.
4979 *
4980 * @param int $postid Post ID to delete
4981 * @param int $board_id Board ID (reserved for future multi-board support)
4982 * @return bool|WP_Error True on success, WP_Error on failure
4983 */
4984 public function delete_post_from_index( $postid, $board_id = 0 ) {
4985 $response = $this->delete( '/rag/post/' . intval( $postid ), [
4986 'board_id' => $board_id,
4987 ] );
4988
4989 if ( is_wp_error( $response ) ) {
4990 $this->log_error( 'delete_post_failed', $response->get_error_message(), [ 'postid' => $postid ] );
4991 return $response;
4992 }
4993
4994 $this->log_info( 'post_deleted_from_index', [
4995 'postid' => $postid,
4996 'board_id' => $board_id,
4997 ] );
4998
4999 return true;
5000 }
5001
5002 /**
5003 * Find similar topics via cloud API
5004 *
5005 * This method is called by VectorStorageManager when in cloud mode.
5006 * It uses the cloud semantic search to find topics similar to the given one.
5007 *
5008 * @param int $topic_id Topic ID to find similar topics for
5009 * @param int $limit Maximum number of results to return
5010 * @return array|WP_Error Array of similar topic IDs with scores, or WP_Error on failure
5011 */
5012 public function find_similar_topics( $topic_id, $limit = 5 ) {
5013 $response = $this->api_get( '/rag/similar', [
5014 'topic_id' => $topic_id,
5015 'limit' => $limit,
5016 ] );
5017
5018 if ( is_wp_error( $response ) ) {
5019 $this->log_error( 'find_similar_failed', $response->get_error_message(), [ 'topic_id' => $topic_id ] );
5020 return $response;
5021 }
5022
5023 return isset( $response['results'] ) ? $response['results'] : [];
5024 }
5025
5026 /**
5027 * Get cron interval (in seconds) based on subscription plan
5028 *
5029 * Higher tier plans get faster indexing speeds:
5030 * - Free trial & Starter: 20 seconds between batches
5031 * - Professional & Business: 10 seconds
5032 * - Enterprise: 5 seconds
5033 *
5034 * Note: Faster intervals mean topics get QUEUED faster, but actual processing
5035 * speed depends on API concurrency limits. SQS handles message accumulation.
5036 *
5037 * @return int Interval in seconds
5038 */
5039 public function get_cron_interval_for_plan() {
5040 // Use cached plan from database (no API calls during cron)
5041 $plan = strtolower( $this->get_subscription_plan() );
5042
5043 // Plan-based intervals (in seconds)
5044 // Faster intervals queue topics quicker; actual processing depends on API concurrency
5045 $intervals = [
5046 'enterprise' => 5, // Fastest queuing for enterprise
5047 'business' => 10,
5048 'professional' => 10,
5049 'starter' => 20,
5050 'free_trial' => 20,
5051 ];
5052
5053 // Return interval for plan, default to 20 seconds for unknown plans
5054 return isset( $intervals[ $plan ] ) ? $intervals[ $plan ] : 20;
5055 }
5056
5057 /**
5058 * Check if there are pending WP Cron jobs for background indexing
5059 *
5060 * @return array Status information about pending jobs
5061 */
5062 /**
5063 * admin_init wrapper: only nudge on real wpForo AI admin page loads.
5064 *
5065 * Checks `$_GET['page']` against the wpForo AI admin slugs and skips
5066 * everything else (other admin pages, AJAX requests, cron requests,
5067 * REST requests). This is the single entry point for the nudge — it
5068 * does NOT run on AJAX status polls.
5069 *
5070 * @return void
5071 */
5072 public function maybe_nudge_wp_cron_on_admin_page() {
5073 // Never run inside AJAX / cron / REST contexts — only on a real
5074 // admin page load (i.e. an admin hitting Refresh on the wpForo AI
5075 // admin page, or opening it from the menu).
5076 if ( wp_doing_ajax() || wp_doing_cron() ) {
5077 return;
5078 }
5079 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
5080 return;
5081 }
5082
5083 // Only on the wpForo AI admin pages.
5084 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : '';
5085 if ( $page === '' || strpos( $page, 'wpforo-ai' ) !== 0 ) {
5086 return;
5087 }
5088
5089 $this->maybe_nudge_wp_cron();
5090 }
5091
5092 /**
5093 * Self-heal stalled WP-Cron for indexing background jobs.
5094 *
5095 * On some hosts (managed hosts where the loopback HTTP used by
5096 * `spawn_cron()` is blocked, sites with `DISABLE_WP_CRON`, sites with
5097 * plugins that suppress cron, or sites with no recent front-end traffic)
5098 * a scheduled `wpforo_ai_process_queue_*` event sits in the cron array
5099 * forever and the admin sees "Indexing..." with zero progress.
5100 *
5101 * Scope — this is intentionally narrow:
5102 * - Per-board forum queue hooks: wpforo_ai_process_queue_cloud,
5103 * wpforo_ai_process_queue_local, wpforo_ai_process_queue.
5104 * Only events whose first arg equals the CURRENT board id.
5105 * - WordPress content hook: wpforo_ai_process_wp_batch (global, no
5106 * board scope — WP posts/pages are site-wide, not per-board).
5107 * - Only events overdue by >= 2 minutes.
5108 * - No other plugin's cron jobs, ever.
5109 *
5110 * Previous attempts used `spawn_cron()` to kick off WP's normal cron
5111 * loopback. That fails on hosts that block or strip the loopback
5112 * request, which is exactly the environments we need to heal. This
5113 * version does what `leira-cron-jobs` does in its "Run now" action:
5114 * it fires the scheduled hooks INLINE in the current PHP request via
5115 * `do_action_ref_array()` — no loopback HTTP, no background request.
5116 *
5117 * Flow (per overdue hook):
5118 * 1. Find the scheduled event in `_get_cron_array()`.
5119 * 2. Reschedule recurring events for their next tick, or unschedule
5120 * single-run events so they don't fire twice.
5121 * 3. `do_action_ref_array( $hook, $args )` to run the handler inline.
5122 *
5123 * This helper only runs on wpForo AI admin page loads (refresh), NOT
5124 * on AJAX status polls. The admin hitting Refresh is effectively the
5125 * user asking "please run any stalled indexing work right now".
5126 *
5127 * Safety notes:
5128 * - The forum indexing processors have their own lock transient
5129 * (`wpforo_ai_indexing_lock_{mode}_{board_id}`, 300s TTL) so rapid
5130 * refreshes cannot cause duplicate batch processing.
5131 * - The WP content indexer has its own lock transient
5132 * (`wpforo_ai_wp_indexing_lock`, 300s TTL) to prevent duplicate
5133 * batch processing on rapid admin refreshes.
5134 * - We only fire hooks that are already OVERDUE (>= 2 min past due).
5135 * On a healthy site WP-Cron fires events on schedule, nothing is
5136 * overdue, and this is a pure no-op.
5137 * - Not called during AJAX polling → zero race window with Stop Indexing.
5138 * - Not called during REST / cron / non-wpforo-ai pages.
5139 * - No new settings, no new UI, no new cron hooks.
5140 *
5141 * Tradeoff: the page refresh will take as long as one batch of
5142 * indexing work takes (typically a few seconds). Admins refreshing
5143 * a stalled indexing dialog already expect work to happen.
5144 *
5145 * @return void
5146 */
5147 public function maybe_nudge_wp_cron() {
5148 if ( ! function_exists( 'wp_next_scheduled' ) || ! function_exists( '_get_cron_array' ) ) {
5149 return;
5150 }
5151
5152 // Scope the nudge to the current board. Each board has its own
5153 // queue cron (args = [ $board_id ]) and admins view the AI page
5154 // one board at a time — we should only run indexing for the board
5155 // the admin is currently looking at, nothing else.
5156 if ( ! isset( WPF()->board ) || ! method_exists( WPF()->board, 'get_current' ) ) {
5157 return;
5158 }
5159 $current_board_id = WPF()->board->get_current( 'boardid' );
5160 if ( $current_board_id === null || $current_board_id === false ) {
5161 return;
5162 }
5163 $current_board_id = (int) $current_board_id;
5164
5165 $now = time();
5166 // Grace period so a normal brief WP-Cron tardiness on healthy sites
5167 // does not trigger the inline run.
5168 $overdue_threshold = 120;
5169
5170 // Per-board forum indexing hooks. These are scheduled by
5171 // VectorStorageManager with args = [ $board_id ] and protected by
5172 // the wpforo_ai_indexing_lock_{mode}_{board_id} transient.
5173 $forum_hooks = [
5174 'wpforo_ai_process_queue_cloud' => true,
5175 'wpforo_ai_process_queue_local' => true,
5176 'wpforo_ai_process_queue' => true,
5177 ];
5178
5179 // Global WordPress content indexing hook (no board scope).
5180 // Protected by wpforo_ai_wp_indexing_lock transient in
5181 // AIWordPressIndexer::process_batch_with_lock().
5182 $wp_content_hooks = [
5183 'wpforo_ai_process_wp_batch' => true,
5184 ];
5185
5186 $crons = _get_cron_array();
5187 if ( ! is_array( $crons ) || empty( $crons ) ) {
5188 return;
5189 }
5190
5191 // Collect overdue events.
5192 // Forum hooks: must match current board.
5193 // WP content hooks: global (no board scope), always include if overdue.
5194 $to_run = [];
5195 foreach ( $crons as $timestamp => $events ) {
5196 if ( ( $now - $timestamp ) < $overdue_threshold ) {
5197 continue;
5198 }
5199 if ( ! is_array( $events ) ) {
5200 continue;
5201 }
5202 foreach ( $events as $hook => $dings ) {
5203 $is_forum_hook = isset( $forum_hooks[ $hook ] );
5204 $is_wp_content_hook = isset( $wp_content_hooks[ $hook ] );
5205
5206 if ( ! $is_forum_hook && ! $is_wp_content_hook ) {
5207 continue;
5208 }
5209 if ( ! is_array( $dings ) ) {
5210 continue;
5211 }
5212
5213 foreach ( $dings as $sig => $event ) {
5214 $args = isset( $event['args'] ) ? (array) $event['args'] : [];
5215
5216 // Forum hooks: must be scoped to the board admin is viewing.
5217 if ( $is_forum_hook ) {
5218 $event_board_id = isset( $args[0] ) ? (int) $args[0] : 0;
5219 if ( $event_board_id !== $current_board_id ) {
5220 continue;
5221 }
5222 }
5223 // WP content hooks: global, no board scope check needed.
5224
5225 $to_run[] = [
5226 'timestamp' => $timestamp,
5227 'hook' => $hook,
5228 'args' => $args,
5229 'schedule' => isset( $event['schedule'] ) ? $event['schedule'] : false,
5230 ];
5231 }
5232 }
5233 }
5234
5235 if ( empty( $to_run ) ) {
5236 return;
5237 }
5238
5239 // Mark the current request as a cron context so the inline handlers
5240 // behave identically to a normal WP-Cron invocation.
5241 if ( ! defined( 'DOING_CRON' ) ) {
5242 define( 'DOING_CRON', true );
5243 }
5244
5245 foreach ( $to_run as $event ) {
5246 // Defensive: skip hooks with no registered handler.
5247 if ( ! has_action( $event['hook'] ) ) {
5248 continue;
5249 }
5250
5251 // Single-run events only in this whitelist — unschedule so
5252 // do_action below is the only run. (Guard against recurring
5253 // anyway in case a future caller schedules one recurring.)
5254 if ( false !== $event['schedule'] ) {
5255 wp_reschedule_event( $event['timestamp'], $event['schedule'], $event['hook'], $event['args'] );
5256 }
5257 wp_unschedule_event( $event['timestamp'], $event['hook'], $event['args'] );
5258
5259 // Fire the handler inline. Wrapped in try/catch so a fatal
5260 // in the indexing handler does not white-screen the admin
5261 // page — log and move on.
5262 try {
5263 do_action_ref_array( $event['hook'], $event['args'] );
5264 } catch ( \Throwable $e ) {
5265 if ( method_exists( $this, 'log_error' ) ) {
5266 $this->log_error(
5267 'inline_cron_run_failed',
5268 [
5269 'hook' => $event['hook'],
5270 'error' => $e->getMessage(),
5271 ]
5272 );
5273 }
5274 }
5275 }
5276 }
5277
5278 public function get_pending_cron_jobs() {
5279 $crons = _get_cron_array();
5280 $pending_jobs = 0;
5281 $pending_topics = 0;
5282 $is_actively_processing = false;
5283 $board_id = WPF()->board->get_current( 'boardid' ) ?: 0;
5284 $now = time();
5285
5286 // Check for queue-based processing (self-rescheduling pattern)
5287 // Mode-specific keys (current format): wpforo_ai_indexing_queue_{mode}_{board_id}
5288 // Legacy key (old format): wpforo_ai_indexing_queue_{board_id}
5289 foreach ( [ 'local', 'cloud', '' ] as $mode ) {
5290 $queue_key = 'wpforo_ai_indexing_queue_' . ( $mode ? $mode . '_' : '' ) . $board_id;
5291 $queue = get_option( $queue_key, [] );
5292 if ( ! empty( $queue ) ) {
5293 $pending_topics += count( $queue );
5294 $pending_jobs = max( $pending_jobs, 1 );
5295 }
5296 }
5297
5298 // Check if any queue processor cron is scheduled (mode-specific or legacy)
5299 // and whether it's due now (actively processing) or scheduled for the future (just queued)
5300 foreach ( [ 'wpforo_ai_process_queue_local', 'wpforo_ai_process_queue_cloud', 'wpforo_ai_process_queue' ] as $cron_hook ) {
5301 $next = wp_next_scheduled( $cron_hook, [ $board_id ] );
5302 if ( $next ) {
5303 $pending_jobs = max( $pending_jobs, 1 );
5304 // Cron is due or overdue — batch processing is imminent or in progress
5305 if ( $next <= $now ) {
5306 $is_actively_processing = true;
5307 }
5308 }
5309 }
5310
5311 // Also check if a processing lock is held (batch is running right now)
5312 if ( get_transient( 'wpforo_ai_indexing_lock_local_' . $board_id )
5313 || get_transient( 'wpforo_ai_indexing_lock_cloud_' . $board_id )
5314 || get_transient( 'wpforo_ai_indexing_lock_' . $board_id ) ) {
5315 $is_actively_processing = true;
5316 }
5317
5318 // Legacy: Search for wpforo_ai_process_batch scheduled events (old pattern)
5319 if ( ! empty( $crons ) ) {
5320 foreach ( $crons as $timestamp => $cron ) {
5321 if ( isset( $cron['wpforo_ai_process_batch'] ) ) {
5322 foreach ( $cron['wpforo_ai_process_batch'] as $key => $job ) {
5323 $pending_jobs++;
5324 if ( $timestamp <= $now ) {
5325 $is_actively_processing = true;
5326 }
5327 // Count topics in this batch
5328 $args = isset( $job['args'] ) ? $job['args'] : [];
5329 if ( isset( $args[0] ) && is_array( $args[0] ) ) {
5330 $args = $args[0];
5331 }
5332 $topic_ids = isset( $args['topic_ids'] ) ? $args['topic_ids'] : [];
5333 $pending_topics += count( $topic_ids );
5334 }
5335 }
5336 }
5337 }
5338
5339 return [
5340 'has_pending_jobs' => $pending_jobs > 0,
5341 'is_actively_processing' => $is_actively_processing,
5342 'pending_jobs' => $pending_jobs,
5343 'pending_topics' => $pending_topics,
5344 ];
5345 }
5346
5347 /**
5348 * Clear all pending WP Cron jobs for background indexing
5349 *
5350 * @return array Status information about cleared jobs
5351 */
5352 public function clear_pending_cron_jobs() {
5353 $crons = _get_cron_array();
5354 $cleared_jobs = 0;
5355 $cleared_topics = 0;
5356 $board_id = WPF()->board->get_current( 'boardid' ) ?: 0;
5357
5358 // Clear queue-based pattern: mode-specific keys (current) and legacy key
5359 foreach ( [ 'local', 'cloud', '' ] as $mode ) {
5360 $queue_key = 'wpforo_ai_indexing_queue_' . ( $mode ? $mode . '_' : '' ) . $board_id;
5361 $queue = get_option( $queue_key, [] );
5362 if ( ! empty( $queue ) ) {
5363 $cleared_topics += count( $queue );
5364 $cleared_jobs++;
5365 delete_option( $queue_key );
5366 }
5367 }
5368
5369 // Clear all queue processor crons (mode-specific and legacy)
5370 wp_clear_scheduled_hook( 'wpforo_ai_process_queue_local', [ $board_id ] );
5371 wp_clear_scheduled_hook( 'wpforo_ai_process_queue_cloud', [ $board_id ] );
5372 wp_clear_scheduled_hook( 'wpforo_ai_process_queue', [ $board_id ] );
5373
5374 // Clear forum indexing lock transients (legacy and mode-specific)
5375 // so new indexing can start immediately after stop.
5376 delete_transient( 'wpforo_ai_indexing_lock_' . $board_id );
5377 delete_transient( 'wpforo_ai_indexing_lock_local_' . $board_id );
5378 delete_transient( 'wpforo_ai_indexing_lock_cloud_' . $board_id );
5379
5380 // Legacy: Find and remove all wpforo_ai_process_batch scheduled events
5381 if ( ! empty( $crons ) ) {
5382 foreach ( $crons as $timestamp => $cron ) {
5383 if ( isset( $cron['wpforo_ai_process_batch'] ) ) {
5384 foreach ( $cron['wpforo_ai_process_batch'] as $key => $job ) {
5385 // Count topics in this batch before removing
5386 $args = isset( $job['args'] ) ? $job['args'] : [];
5387 if ( isset( $args[0] ) && is_array( $args[0] ) ) {
5388 $args = $args[0];
5389 }
5390 $topic_ids = isset( $args['topic_ids'] ) ? $args['topic_ids'] : [];
5391 $cleared_topics += count( $topic_ids );
5392 $cleared_jobs++;
5393
5394 // Unschedule this specific event
5395 wp_unschedule_event( $timestamp, 'wpforo_ai_process_batch', $job['args'] );
5396 }
5397 }
5398 }
5399 }
5400
5401 // Also clear any recurring hooks (just in case)
5402 wp_clear_scheduled_hook( 'wpforo_ai_process_batch' );
5403
5404 // Clear WordPress content indexing state (global, not board-scoped).
5405 // This ensures Stop Indexing works for WP content as well as forum.
5406 $wp_queue = get_option( 'wpforo_ai_wp_indexing_queue' );
5407 if ( ! empty( $wp_queue ) ) {
5408 $wp_posts_count = 0;
5409 if ( isset( $wp_queue['batches'] ) && is_array( $wp_queue['batches'] ) ) {
5410 foreach ( $wp_queue['batches'] as $batch ) {
5411 $wp_posts_count += is_array( $batch ) ? count( $batch ) : 0;
5412 }
5413 }
5414 $cleared_topics += $wp_posts_count;
5415 $cleared_jobs++;
5416 delete_option( 'wpforo_ai_wp_indexing_queue' );
5417 }
5418 wp_clear_scheduled_hook( 'wpforo_ai_process_wp_batch' );
5419 delete_transient( 'wpforo_ai_wp_indexing_status' );
5420 delete_transient( 'wpforo_ai_wp_indexing_lock' );
5421
5422 return [
5423 'cleared_jobs' => $cleared_jobs,
5424 'cleared_topics' => $cleared_topics,
5425 'success' => true,
5426 ];
5427 }
5428
5429 /**
5430 * Get user display name with proper fallbacks
5431 *
5432 * Delegates to AIUserTrait::get_single_user_display_name() for consistent
5433 * user name resolution with proper fallback chain.
5434 *
5435 * @param int $userid User ID (0 for guests)
5436 * @return string User display name
5437 */
5438 private function get_user_display_name( $userid ) {
5439 return $this->get_single_user_display_name( (int) $userid );
5440 }
5441
5442 /**
5443 * Format thread data for RAG Indexing
5444 *
5445 * @param array $topic Topic data
5446 * @param array $posts Array of posts/replies
5447 * @return array|null Formatted thread data or null
5448 */
5449 private function format_thread_data( $topic, $posts ) {
5450 if ( empty( $topic ) ) {
5451 return null;
5452 }
5453
5454 // Get topic URL
5455 $topic_url = wpforo_topic( $topic['topicid'], 'url' );
5456
5457 // Separate first post (topic) from replies
5458 $first_post = ! empty( $posts ) ? array_shift( $posts ) : null;
5459
5460 if ( ! $first_post ) {
5461 return null;
5462 }
5463
5464 // Get current board ID (for multi-board support)
5465 $boardid = (int) WPF()->board->get_current( 'boardid' );
5466
5467 // Get display names with proper fallbacks
5468 $topic_display_name = $this->get_user_display_name( $first_post['userid'] );
5469
5470 // Check if image/document indexing is enabled (Professional+ only)
5471 $include_images = $this->is_image_indexing_enabled();
5472 $include_documents = $this->is_document_indexing_enabled();
5473
5474 // Clean post content for indexing (strip quoted content, etc.)
5475 // This removes quoted replies to prevent duplicate content in the index
5476 $cleaned_first_post_body = $this->clean_content_for_indexing( $first_post['body'] );
5477
5478 // Format topic data
5479 $thread_data = [
5480 'thread_id' => (string) $topic['topicid'],
5481 'board_id' => $boardid,
5482 'topic' => [
5483 'title' => $topic['title'],
5484 'body' => $cleaned_first_post_body,
5485 'author' => wpforo_member( $first_post['userid'], 'user_login' ) ?: $topic_display_name,
5486 'author_id' => (string) $first_post['userid'],
5487 'display_name' => $topic_display_name,
5488 'post_id' => (string) $first_post['postid'],
5489 'created_at' => is_numeric( $first_post['created'] ) ? date( 'Y-m-d H:i:s', (int) $first_post['created'] ) : $first_post['created'],
5490 'url' => $topic_url,
5491 'forum_id' => (int) $topic['forumid'],
5492 'forum_name' => wpforo_forum( $topic['forumid'], 'title' ),
5493 // New fields for embedding strategy
5494 'is_solved' => ! empty( $topic['solved'] ),
5495 'likes_count' => (int) wpfval( $first_post, 'likes', 0 ),
5496 ],
5497 'replies' => [],
5498 ];
5499
5500 // Add images for first post if image indexing is enabled
5501 // Note: Extract images from cleaned content (quotes already removed)
5502 if ( $include_images ) {
5503 $topic_images = $this->extract_post_images( $cleaned_first_post_body );
5504 if ( ! empty( $topic_images ) ) {
5505 $thread_data['topic']['images'] = $topic_images;
5506 }
5507 }
5508
5509 // Add documents for first post if document indexing is enabled
5510 if ( $include_documents ) {
5511 $topic_documents = $this->extract_post_documents( $cleaned_first_post_body );
5512 if ( ! empty( $topic_documents ) ) {
5513 $thread_data['topic']['documents'] = $topic_documents;
5514 }
5515 }
5516
5517 // Format replies
5518 foreach ( $posts as $post ) {
5519 $reply_display_name = $this->get_user_display_name( $post['userid'] );
5520
5521 // Clean reply content for indexing (strip quoted content, etc.)
5522 $cleaned_reply_body = $this->clean_content_for_indexing( $post['body'] );
5523
5524 $reply_data = [
5525 'body' => $cleaned_reply_body,
5526 'author' => wpforo_member( $post['userid'], 'user_login' ) ?: $reply_display_name,
5527 'author_id' => (string) $post['userid'],
5528 'display_name' => $reply_display_name,
5529 'post_id' => (string) $post['postid'],
5530 'created_at' => is_numeric( $post['created'] ) ? date( 'Y-m-d H:i:s', (int) $post['created'] ) : $post['created'],
5531 // New fields for embedding strategy
5532 'is_best_answer' => ! empty( $post['is_answer'] ),
5533 'likes_count' => (int) wpfval( $post, 'likes', 0 ),
5534 ];
5535
5536 // Add images for reply if image indexing is enabled
5537 // Note: Extract images from cleaned content (quotes already removed)
5538 if ( $include_images ) {
5539 $reply_images = $this->extract_post_images( $cleaned_reply_body );
5540 if ( ! empty( $reply_images ) ) {
5541 $reply_data['images'] = $reply_images;
5542 }
5543 }
5544
5545 // Add documents for reply if document indexing is enabled
5546 if ( $include_documents ) {
5547 $reply_documents = $this->extract_post_documents( $cleaned_reply_body );
5548 if ( ! empty( $reply_documents ) ) {
5549 $reply_data['documents'] = $reply_documents;
5550 }
5551 }
5552
5553 $thread_data['replies'][] = $reply_data;
5554 }
5555
5556 return $thread_data;
5557 }
5558
5559 /**
5560 * Make GET request to API
5561 *
5562 * @param string $endpoint API endpoint (e.g., '/tenant/status')
5563 * @param array $query_params Query parameters
5564 * @return array|WP_Error Response data or error object
5565 */
5566 private function get( $endpoint, $query_params = [] ) {
5567 return $this->make_request( 'GET', $endpoint, [], $query_params );
5568 }
5569
5570 /**
5571 * Make POST request to API
5572 *
5573 * @param string $endpoint API endpoint
5574 * @param array $data Request body data
5575 * @param int $timeout Optional custom timeout in seconds (0 = use default)
5576 * @return array|WP_Error Response data or error object
5577 */
5578 private function post( $endpoint, $data = [], $timeout = 0 ) {
5579 return $this->make_request( 'POST', $endpoint, $data, [], $timeout );
5580 }
5581
5582 /**
5583 * Make public POST request to API
5584 *
5585 * This method is for external use by other classes (e.g., TaskManager)
5586 * that need to make API calls with custom timeouts.
5587 *
5588 * @param string $endpoint API endpoint
5589 * @param array $data Request body data
5590 * @param int $timeout Optional custom timeout in seconds (default: 30)
5591 * @return array|WP_Error Response data or error object
5592 */
5593 public function api_post( $endpoint, $data = [], $timeout = 30 ) {
5594 return $this->make_request( 'POST', $endpoint, $data, [], $timeout );
5595 }
5596
5597 /**
5598 * Make public GET request to API
5599 *
5600 * This method is for external use by other classes (e.g., VectorStorageManager)
5601 * that need to make API calls.
5602 *
5603 * @param string $endpoint API endpoint
5604 * @param array $query_params Query parameters
5605 * @param int $timeout Optional custom timeout in seconds (default: 30)
5606 * @return array|WP_Error Response data or error object
5607 */
5608 public function api_get( $endpoint, $query_params = [], $timeout = 30 ) {
5609 return $this->make_request( 'GET', $endpoint, [], $query_params, $timeout );
5610 }
5611
5612 /**
5613 * Make PUT request to API
5614 *
5615 * @param string $endpoint API endpoint
5616 * @param array $data Request body data
5617 * @return array|WP_Error Response data or error object
5618 */
5619 private function put( $endpoint, $data = [] ) {
5620 return $this->make_request( 'PUT', $endpoint, $data );
5621 }
5622
5623 /**
5624 * Make DELETE request to API
5625 *
5626 * @param string $endpoint API endpoint
5627 * @param array $data Request body data
5628 * @param int $timeout Optional custom timeout in seconds (default: 30)
5629 * @return array|WP_Error Response data or error object
5630 */
5631 private function delete( $endpoint, $data = [], $timeout = 30 ) {
5632 return $this->make_request( 'DELETE', $endpoint, $data, [], $timeout );
5633 }
5634
5635 /**
5636 * Central request method with automatic fallback domain support
5637 *
5638 * Tries the primary API domain first. If the request fails due to a connection
5639 * error (DNS failure, timeout, blocked domain), automatically retries on the
5640 * fallback domain.
5641 *
5642 * @param string $method HTTP method (GET, POST, PUT, DELETE)
5643 * @param string $endpoint API endpoint
5644 * @param array $data Request body data
5645 * @param array $query_params Query parameters (for GET requests)
5646 * @param int $timeout Optional custom timeout in seconds (0 = use default)
5647 * @return array|WP_Error Response data or error object
5648 */
5649 private function make_request( $method, $endpoint, $data = [], $query_params = [], $timeout = 0 ) {
5650 $args = $this->build_request_args( $method, $data, $endpoint );
5651 if ( $timeout > 0 ) {
5652 $args['timeout'] = $timeout;
5653 }
5654
5655 $this->log_request( $method, $endpoint, ! empty( $data ) ? $data : $query_params );
5656
5657 // Try primary domain
5658 $url = $this->build_url( $endpoint, $query_params );
5659 $response = $this->dispatch_request( $method, $url, $args );
5660
5661 // If connection failed and fallback domain is available, retry
5662 if ( is_wp_error( $response ) && $this->is_connection_error( $response ) && $this->fallback_api_url ) {
5663 $this->log_error( 'Primary API domain failed', $response->get_error_message() );
5664
5665 $fallback_url = $this->build_url( $endpoint, $query_params, $this->fallback_api_url );
5666 $response = $this->dispatch_request( $method, $fallback_url, $args );
5667
5668 if ( ! is_wp_error( $response ) ) {
5669 $this->log_error( 'Fallback domain succeeded', $this->fallback_api_url );
5670 }
5671 }
5672
5673 return $this->process_response( $response );
5674 }
5675
5676 /**
5677 * Dispatch an HTTP request using the appropriate WordPress function
5678 *
5679 * @param string $method HTTP method
5680 * @param string $url Full request URL
5681 * @param array $args Request arguments
5682 * @return array|WP_Error Raw response
5683 */
5684 private function dispatch_request( $method, $url, $args ) {
5685 switch ( $method ) {
5686 case 'GET':
5687 return wp_remote_get( $url, $args );
5688 case 'POST':
5689 return wp_remote_post( $url, $args );
5690 default:
5691 return wp_remote_request( $url, $args );
5692 }
5693 }
5694
5695 /**
5696 * Check if a WP_Error represents a connection-level failure
5697 *
5698 * These are errors where the request never reached the server:
5699 * DNS failures, connection refused, timeouts, SSL errors, blocked requests.
5700 *
5701 * @param \WP_Error $error The error to check
5702 * @return bool True if this is a connection error worth retrying on fallback
5703 */
5704 private function is_connection_error( $error ) {
5705 $code = $error->get_error_code();
5706 $message = strtolower( $error->get_error_message() );
5707
5708 // WordPress HTTP API error codes for connection failures
5709 $connection_codes = [
5710 'http_request_failed',
5711 'http_request_not_executed',
5712 ];
5713
5714 if ( in_array( $code, $connection_codes, true ) ) {
5715 return true;
5716 }
5717
5718 // Check error message for connection-related keywords
5719 $connection_keywords = [
5720 'could not resolve host',
5721 'connection refused',
5722 'connection timed out',
5723 'operation timed out',
5724 'name or service not known',
5725 'network is unreachable',
5726 'no route to host',
5727 'ssl',
5728 'curl error',
5729 ];
5730
5731 foreach ( $connection_keywords as $keyword ) {
5732 if ( strpos( $message, $keyword ) !== false ) {
5733 return true;
5734 }
5735 }
5736
5737 return false;
5738 }
5739
5740 /**
5741 * Build full API URL
5742 *
5743 * @param string $endpoint API endpoint
5744 * @param array $query_params Query parameters
5745 * @param string $base_url Optional base URL override (for fallback domain)
5746 * @return string Full URL
5747 */
5748 private function build_url( $endpoint, $query_params = [], $base_url = '' ) {
5749 $base = $base_url ?: $this->api_base_url;
5750 $url = rtrim( $base, '/' ) . '/' . ltrim( $endpoint, '/' );
5751
5752 if ( ! empty( $query_params ) ) {
5753 $url = add_query_arg( $query_params, $url );
5754 }
5755
5756 return $url;
5757 }
5758
5759 /**
5760 * Build request arguments
5761 *
5762 * @param string $method HTTP method
5763 * @param array $data Request body data
5764 * @return array Request arguments
5765 */
5766 private function build_request_args( $method = 'GET', $data = [], $endpoint = '' ) {
5767 $headers = [
5768 'Content-Type' => 'application/json',
5769 ];
5770
5771 // Add Origin header for cross-domain security validation
5772 // The backend validates that requests come from the registered site_url
5773 $site_url = get_site_url();
5774 if ( $site_url ) {
5775 $headers['Origin'] = $site_url;
5776 }
5777
5778 // Add development key header for localhost access
5779 // Define WPFORO_AI_DEV_KEY in wp-config.php to enable localhost API access
5780 if ( defined( 'WPFORO_AI_DEV_KEY' ) && WPFORO_AI_DEV_KEY ) {
5781 $headers['X-Dev-Key'] = WPFORO_AI_DEV_KEY;
5782 }
5783
5784 // Add API key header if available (for authenticated requests)
5785 // Skip API key for registration endpoint
5786 $api_key = $this->get_stored_api_key();
5787 if ( $api_key && strpos( $endpoint, '/register' ) === false ) {
5788 $headers['Authorization'] = 'Bearer ' . $api_key;
5789 }
5790
5791 $args = [
5792 'method' => $method,
5793 'timeout' => $this->timeout,
5794 'redirection' => 5,
5795 'httpversion' => '1.1',
5796 'blocking' => true,
5797 'headers' => $headers,
5798 'sslverify' => apply_filters( 'wpforo_ai_ssl_verify', true ),
5799 ];
5800
5801 // Add body for POST/DELETE requests
5802 if ( ! empty( $data ) && in_array( $method, [ 'POST', 'PUT', 'DELETE', 'PATCH' ] ) ) {
5803 $args['body'] = wp_json_encode( $data );
5804 }
5805
5806 return apply_filters( 'wpforo_ai_request_args', $args, $method, $data );
5807 }
5808
5809 /**
5810 * Process API response
5811 *
5812 * @param array|\WP_Error $response Raw response
5813 * @return array|\WP_Error Processed response data or error
5814 */
5815 private function process_response( $response ) {
5816 if ( is_wp_error( $response ) ) {
5817 $this->log_error( 'API Request Failed', $response->get_error_message() );
5818 return new \WP_Error(
5819 'api_request_failed',
5820 sprintf( wpforo_phrase( 'API request failed: %s', false ), $response->get_error_message() )
5821 );
5822 }
5823
5824 $response_code = wp_remote_retrieve_response_code( $response );
5825 $body = wp_remote_retrieve_body( $response );
5826 $data = json_decode( $body, true );
5827
5828 $this->log_response( $response_code, $data );
5829
5830 // Handle HTTP error codes
5831 if ( $response_code >= 400 ) {
5832 $error_message = $this->get_error_message( $response_code, $data );
5833 return new \WP_Error( 'api_error_' . $response_code, $error_message, $data );
5834 }
5835
5836 // Handle API-level errors
5837 if ( isset( $data['success'] ) && false === $data['success'] ) {
5838 $error_message = wpfval( $data, 'message' ) ?: wpforo_phrase( 'Unknown API error occurred', false );
5839 return new \WP_Error( 'api_error', $error_message, $data );
5840 }
5841
5842 return $data;
5843 }
5844
5845 /**
5846 * Get user-friendly error message based on response code
5847 *
5848 * @param int $code HTTP status code
5849 * @param array $data Response data
5850 * @return string Error message
5851 */
5852 private function get_error_message( $code, $data ) {
5853 // Check for FastAPI 'detail' field (common in 4xx/5xx errors)
5854 if ( isset( $data['detail'] ) ) {
5855 // If detail is an object/array with message or error fields
5856 if ( is_array( $data['detail'] ) ) {
5857 if ( isset( $data['detail']['message'] ) ) {
5858 return sanitize_text_field( $data['detail']['message'] );
5859 }
5860 if ( isset( $data['detail']['error'] ) ) {
5861 return sanitize_text_field( $data['detail']['error'] );
5862 }
5863 }
5864 // If detail is a string
5865 if ( is_string( $data['detail'] ) ) {
5866 return sanitize_text_field( $data['detail'] );
5867 }
5868 }
5869
5870 // Check for API-provided message first
5871 if ( isset( $data['message'] ) ) {
5872 $message = sanitize_text_field( $data['message'] );
5873 // In debug mode, append additional details
5874 if ( $this->is_debug_mode() && isset( $data['error'] ) ) {
5875 $message .= ' [' . sanitize_text_field( $data['error'] ) . ']';
5876 }
5877 return $message;
5878 }
5879
5880 // Check for error field
5881 if ( isset( $data['error'] ) ) {
5882 return sanitize_text_field( $data['error'] );
5883 }
5884
5885 // Fallback to standard HTTP status messages
5886 $messages = [
5887 400 => wpforo_phrase( 'Bad request. Please check your input and try again.', false ),
5888 401 => wpforo_phrase( 'Unauthorized. This may be due to localhost URL or missing authentication.', false ),
5889 402 => wpforo_phrase( 'Insufficient credits available for this search. Please upgrade your plan or purchase additional credits.', false ),
5890 403 => wpforo_phrase( 'Access forbidden. This feature is not available in your plan.', false ),
5891 404 => wpforo_phrase( 'Resource not found. The requested endpoint does not exist.', false ),
5892 429 => wpforo_phrase( 'Rate limit exceeded. Please try again later.', false ),
5893 500 => wpforo_phrase( 'Internal server error. Please try again later.', false ),
5894 503 => wpforo_phrase( 'Service temporarily unavailable. Please try again later.', false ),
5895 ];
5896
5897 return wpfval( $messages, $code ) ?: sprintf( wpforo_phrase( 'API error (HTTP %d)', false ), $code );
5898 }
5899
5900 /**
5901 * Get stored API key (decrypted)
5902 *
5903 * Uses global option (shared across all boards)
5904 *
5905 * @return string|null API key or null if not set
5906 */
5907 public function get_stored_api_key() {
5908 $encrypted = $this->get_api_key(); // Uses global option
5909
5910 if ( empty( $encrypted ) ) {
5911 return null;
5912 }
5913
5914 return $this->decrypt_api_key( $encrypted );
5915 }
5916
5917 /**
5918 * Encrypt API key for storage
5919 *
5920 * @param string $key Plain text API key
5921 * @return string Encrypted key
5922 */
5923 public function encrypt_api_key( $key ) {
5924 // Use base64 encoding as minimum obfuscation
5925 return base64_encode( $key );
5926 }
5927
5928 /**
5929 * Decrypt API key from storage
5930 *
5931 * @param string $encrypted Encrypted API key
5932 * @return string Plain text API key
5933 */
5934 public function decrypt_api_key( $encrypted ) {
5935 return base64_decode( $encrypted );
5936 }
5937
5938 /**
5939 * Get masked API key for display in UI
5940 *
5941 * @param string $key Full API key
5942 * @return string Masked key (e.g., "wp_abc***")
5943 */
5944 public function mask_api_key( $key ) {
5945 if ( empty( $key ) || strlen( $key ) < 10 ) {
5946 return '***';
5947 }
5948
5949 $prefix = substr( $key, 0, 6 ); // Show first 6 characters (e.g., "wp_abc")
5950 return $prefix . '***';
5951 }
5952
5953 /**
5954 * Check if debug mode is enabled
5955 *
5956 * @return bool
5957 */
5958 private function is_debug_mode() {
5959 return (bool) wpforo_setting( 'general', 'debug_mode' );
5960 }
5961
5962 /**
5963 * Log API request (only in debug mode)
5964 *
5965 * @param string $method HTTP method
5966 * @param string $endpoint API endpoint
5967 * @param array $data Request data
5968 */
5969 private function log_request( $method, $endpoint, $data = [] ) {
5970 if ( ! $this->is_debug_mode() ) {
5971 return;
5972 }
5973
5974 \wpforo_ai_log( 'debug', sprintf(
5975 '%s %s | Data: %s',
5976 $method,
5977 $endpoint,
5978 wp_json_encode( $this->sanitize_log_data( $data ) )
5979 ), 'Client' );
5980 }
5981
5982 /**
5983 * Log API response (only in debug mode)
5984 *
5985 * @param int $code Response code
5986 * @param array $data Response data
5987 */
5988 private function log_response( $code, $data ) {
5989 if ( ! $this->is_debug_mode() ) {
5990 return;
5991 }
5992
5993 \wpforo_ai_log( 'debug', sprintf(
5994 'Response %d | Data: %s',
5995 $code,
5996 wp_json_encode( $this->sanitize_log_data( $data ) )
5997 ), 'Client' );
5998 }
5999
6000 /**
6001 * Log error message
6002 *
6003 * @param string $context Error context
6004 * @param string $message Error message
6005 * @param array $data Optional additional data
6006 */
6007 private function log_error( $context, $message, $data = [] ) {
6008 $log_message = sprintf( '%s: %s', $context, $message );
6009 if ( ! empty( $data ) ) {
6010 $log_message .= ' ' . wp_json_encode( $data );
6011 }
6012 \wpforo_ai_log( 'error', $log_message, 'Client' );
6013 do_action( 'wpforo_ai_error', $context, $message, $data );
6014 }
6015
6016 /**
6017 * Log info message (only in debug mode)
6018 *
6019 * @param string $context Info context
6020 * @param array $data Additional data
6021 */
6022 private function log_info( $context, $data = [] ) {
6023 if ( ! $this->is_debug_mode() ) {
6024 return;
6025 }
6026
6027 \wpforo_ai_log( 'info', sprintf(
6028 '%s | Data: %s',
6029 $context,
6030 wp_json_encode( $this->sanitize_log_data( $data ) )
6031 ), 'Client' );
6032 }
6033
6034 /**
6035 * Sanitize data for logging (remove sensitive info)
6036 *
6037 * @param array $data Data to sanitize
6038 * @return array Sanitized data
6039 */
6040 private function sanitize_log_data( $data ) {
6041 if ( ! is_array( $data ) ) {
6042 return $data;
6043 }
6044
6045 $sensitive_keys = [ 'api_key', 'new_api_key', 'password', 'token', 'secret' ];
6046
6047 foreach ( $sensitive_keys as $key ) {
6048 if ( isset( $data[ $key ] ) ) {
6049 $data[ $key ] = '***REDACTED***';
6050 }
6051 }
6052
6053 // Recursively sanitize nested arrays
6054 foreach ( $data as $key => $value ) {
6055 if ( is_array( $value ) ) {
6056 $data[ $key ] = $this->sanitize_log_data( $value );
6057 }
6058 }
6059
6060 return $data;
6061 }
6062
6063 // =========================================================================
6064 // AI CACHE METHODS
6065 // =========================================================================
6066
6067 /**
6068 * Cache type constants
6069 */
6070 const CACHE_TYPE_SEARCH = 'search';
6071 const CACHE_TYPE_SEARCH_ENHANCE = 'search_enhance';
6072 const CACHE_TYPE_TRANSLATE = 'translate';
6073 const CACHE_TYPE_TOPIC_SUMMARY = 'topic_summary';
6074
6075 /**
6076 * Cache TTL in seconds (24 hours)
6077 */
6078 const CACHE_TTL = 86400;
6079
6080 /**
6081 * Build cache key for search enhancement
6082 *
6083 * Cache key is based on: query + result IDs + language
6084 * This ensures cache invalidates when results change (e.g., after reindexing)
6085 *
6086 * @param string $query Search query
6087 * @param array $results Search results array
6088 * @param string $language User language
6089 * @return string Raw MD5 hash (16 bytes binary)
6090 */
6091 private function build_enhance_cache_key( $query, $results, $language ) {
6092 // Extract result IDs to include in cache key
6093 // If results change (different topics returned), cache key changes
6094 $result_ids = [];
6095 foreach ( $results as $result ) {
6096 $result_ids[] = wpfval( $result, 'url' ) ?: ''; // Use URL as unique identifier
6097 }
6098
6099 $cache_string = strtolower( trim( $query ) ) . '|' . implode( ',', $result_ids ) . '|' . $language;
6100
6101 // Return raw binary MD5 (16 bytes) for BINARY(16) column
6102 return md5( $cache_string, true );
6103 }
6104
6105 /**
6106 * Build cache key for semantic search
6107 *
6108 * Cache key is based on: query + limit + offset + board_id + quality + accessible_forumids
6109 * Quality tier is included to prevent serving stale cached results
6110 * when the admin changes the search quality setting.
6111 * Accessible forumids ensure users with different permissions get different cache entries.
6112 *
6113 * @param string $query Search query
6114 * @param int $limit Result limit
6115 * @param int $offset Result offset
6116 * @param string $board_id Board ID for multi-board support
6117 * @param string $quality Search quality tier (fast, balanced, advanced, premium)
6118 * @param array|null $accessible_forumids Forums user can access (null = full access, [] = no access)
6119 * @return string Raw MD5 hash (16 bytes binary)
6120 */
6121 private function build_search_cache_key( $query, $limit, $offset, $board_id, $quality = 'fast', $accessible_forumids = null ) {
6122 if ( $accessible_forumids === null ) {
6123 $forums_hash = 'all';
6124 } elseif ( empty( $accessible_forumids ) ) {
6125 $forums_hash = 'none';
6126 } else {
6127 $sorted_forumids = $accessible_forumids;
6128 sort( $sorted_forumids ); // Ensure consistent ordering
6129 $forums_hash = md5( implode( ',', $sorted_forumids ) );
6130 }
6131
6132 $cache_string = 'search:' . strtolower( trim( $query ) ) . '|limit:' . intval( $limit ) . '|offset:' . intval( $offset ) . '|board:' . $board_id . '|quality:' . $quality . '|forums:' . $forums_hash;
6133
6134 // Return raw binary MD5 (16 bytes) for BINARY(16) column
6135 return md5( $cache_string, true );
6136 }
6137
6138 /**
6139 * Build cache key for translation
6140 *
6141 * Cache key is based on: postid + language
6142 *
6143 * @param int $postid Post ID
6144 * @param string $language Target language code
6145 * @return string Raw MD5 hash (16 bytes binary)
6146 */
6147 private function build_translate_cache_key( $postid, $language ) {
6148 $cache_string = 'post:' . intval( $postid ) . '|lang:' . strtolower( trim( $language ) );
6149 return md5( $cache_string, true );
6150 }
6151
6152 /**
6153 * Build cache key for topic summary
6154 *
6155 * Cache key is based on: topicid + reply_count + last_modified + quality + style + language
6156 * This ensures cache invalidates when:
6157 * - New replies are added (reply_count changes)
6158 * - Content is edited (last_modified changes)
6159 * - AI quality tier changes (quality changes)
6160 * - Summary style changes (style changes)
6161 * - Target language changes (language changes)
6162 *
6163 * @param int $topicid Topic ID
6164 * @param int $reply_count Number of replies in the topic
6165 * @param string $last_modified Last modified timestamp of the topic
6166 * @param string $quality AI quality tier (fast, balanced, advanced, premium)
6167 * @param string $style Summary style (compact, structured, conversational, detailed, minimal)
6168 * @param string $language Target language for summary (empty = auto-detect)
6169 * @return string Raw MD5 hash (16 bytes binary)
6170 */
6171 private function build_topic_summary_cache_key( $topicid, $reply_count, $last_modified = '', $quality = 'advanced', $style = 'detailed', $language = '' ) {
6172 $cache_string = 'topic:' . intval( $topicid ) . '|replies:' . intval( $reply_count ) . '|modified:' . $last_modified . '|quality:' . $quality . '|style:' . $style . '|lang:' . $language;
6173 return md5( $cache_string, true );
6174 }
6175
6176 /**
6177 * Get cached AI response
6178 *
6179 * Includes opportunistic cleanup: 1% chance to run garbage collection
6180 * on each cache read. This ensures expired entries are cleaned even if
6181 * WP Cron fails or is disabled.
6182 *
6183 * @param string $type Cache type (e.g., 'search_enhance')
6184 * @param string $cache_key Raw MD5 hash (16 bytes binary)
6185 * @return array|null Cached response or null if not found/expired
6186 */
6187 private function get_ai_cache( $type, $cache_key ) {
6188 global $wpdb;
6189
6190 $table = WPF()->tables->ai_cache;
6191 $now = time();
6192
6193 // Opportunistic cleanup: 1% chance to clean expired entries
6194 // This is a fallback if WP Cron doesn't run
6195 if ( wp_rand( 1, 100 ) === 1 ) {
6196 $this->cleanup_expired_cache();
6197 }
6198
6199 // Query for valid cache entry
6200 // Translation cache (expires_at = 0) never expires, other types check expiry time
6201 $response = $wpdb->get_var( $wpdb->prepare(
6202 "SELECT response FROM `{$table}` WHERE type = %s AND cache_key = %s AND (expires_at = 0 OR expires_at > %d)",
6203 $type,
6204 $cache_key,
6205 $now
6206 ) );
6207
6208 if ( $response ) {
6209 $decoded = json_decode( $response, true );
6210 if ( json_last_error() === JSON_ERROR_NONE ) {
6211 $this->log_info( 'ai_cache_hit', [ 'type' => $type ] );
6212 return $decoded;
6213 }
6214 }
6215
6216 return null;
6217 }
6218
6219 /**
6220 * Store AI response in cache
6221 *
6222 * @param string $type Cache type (e.g., 'search_enhance', 'translate')
6223 * @param string $cache_key Raw MD5 hash (16 bytes binary)
6224 * @param array $response Response data to cache
6225 * @param int $ttl Time-to-live in seconds (default: CACHE_TTL), 0 for no expiry
6226 * @param int $postid Post ID for translation cache (default: 0)
6227 * @return bool True on success, false on failure
6228 */
6229 private function set_ai_cache( $type, $cache_key, $response, $ttl = null, $postid = 0 ) {
6230 global $wpdb;
6231
6232 if ( $ttl === null ) {
6233 $ttl = self::CACHE_TTL;
6234 }
6235
6236 // Auto-schedule cleanup cron if not already scheduled
6237 $this->schedule_cache_cleanup();
6238
6239 $table = WPF()->tables->ai_cache;
6240 $json = wp_json_encode( $response );
6241
6242 // expires_at = 0 means never expires (for translation cache)
6243 $expires_at = ( $ttl === 0 ) ? 0 : time() + $ttl;
6244
6245 // Use REPLACE to insert or update existing cache entry
6246 $result = $wpdb->replace(
6247 $table,
6248 [
6249 'type' => $type,
6250 'cache_key' => $cache_key,
6251 'response' => $json,
6252 'expires_at' => $expires_at,
6253 'postid' => intval( $postid ),
6254 ],
6255 [ '%s', '%s', '%s', '%d', '%d' ]
6256 );
6257
6258 if ( $result !== false ) {
6259 $this->log_info( 'ai_cache_set', [ 'type' => $type, 'ttl' => $ttl, 'postid' => $postid ] );
6260 return true;
6261 }
6262
6263 $this->log_error( 'ai_cache_set_failed', $wpdb->last_error );
6264 return false;
6265 }
6266
6267 /**
6268 * Clean up expired cache entries
6269 *
6270 * Should be called periodically (e.g., via WP Cron)
6271 *
6272 * @return int Number of deleted rows
6273 */
6274 public function cleanup_expired_cache() {
6275 global $wpdb;
6276
6277 $table = WPF()->tables->ai_cache;
6278 $now = time();
6279
6280 $deleted = $wpdb->query( $wpdb->prepare(
6281 "DELETE FROM `{$table}` WHERE expires_at < %d",
6282 $now
6283 ) );
6284
6285 if ( $deleted > 0 ) {
6286 $this->log_info( 'ai_cache_cleanup', [ 'deleted' => $deleted ] );
6287 }
6288
6289 return (int) $deleted;
6290 }
6291
6292 /**
6293 * WP Cron handler for AI cache cleanup
6294 *
6295 * Runs daily to remove expired cache entries
6296 */
6297 public function cron_cache_cleanup() {
6298 $deleted = $this->cleanup_expired_cache();
6299 $this->log_info( 'cron_cache_cleanup_complete', [ 'deleted' => $deleted ] );
6300 }
6301
6302 /**
6303 * Schedule AI cache cleanup cron job
6304 *
6305 * Should be called on plugin activation
6306 */
6307 public function schedule_cache_cleanup() {
6308 if ( ! wp_next_scheduled( 'wpforo_ai_cache_cleanup' ) ) {
6309 wp_schedule_event( time(), 'daily', 'wpforo_ai_cache_cleanup' );
6310 }
6311 }
6312
6313 /**
6314 * Unschedule AI cache cleanup cron job
6315 *
6316 * Should be called on plugin deactivation
6317 */
6318 public function unschedule_cache_cleanup() {
6319 $timestamp = wp_next_scheduled( 'wpforo_ai_cache_cleanup' );
6320 if ( $timestamp ) {
6321 wp_unschedule_event( $timestamp, 'wpforo_ai_cache_cleanup' );
6322 }
6323 }
6324
6325 /**
6326 * Schedule daily pending topics indexing cron job
6327 *
6328 * Should be called on plugin activation or when AI is connected.
6329 * Runs once daily to find and queue topics that need indexing.
6330 */
6331 public function schedule_pending_topics_indexing() {
6332 if ( ! wp_next_scheduled( 'wpforo_ai_pending_topics_indexing' ) ) {
6333 // Schedule to run at 3 AM server time (off-peak hours)
6334 $next_run = strtotime( 'tomorrow 3:00am' );
6335 wp_schedule_event( $next_run, 'daily', 'wpforo_ai_pending_topics_indexing' );
6336 }
6337 }
6338
6339 /**
6340 * Unschedule daily pending topics indexing cron job
6341 *
6342 * Should be called on plugin deactivation.
6343 */
6344 public function unschedule_pending_topics_indexing() {
6345 $timestamp = wp_next_scheduled( 'wpforo_ai_pending_topics_indexing' );
6346 if ( $timestamp ) {
6347 wp_unschedule_event( $timestamp, 'wpforo_ai_pending_topics_indexing' );
6348 }
6349 }
6350
6351 /**
6352 * Schedule daily subscription sync cron job
6353 *
6354 * Syncs subscription status and plan from API once per day.
6355 * This ensures cached subscription info stays up-to-date without
6356 * making API calls on every page load.
6357 */
6358 public function schedule_daily_subscription_sync() {
6359 if ( ! wp_next_scheduled( 'wpforo_ai_daily_subscription_sync' ) ) {
6360 // Schedule to run at 4 AM server time (off-peak hours, after other daily jobs)
6361 $next_run = strtotime( 'tomorrow 4:00am' );
6362 wp_schedule_event( $next_run, 'daily', 'wpforo_ai_daily_subscription_sync' );
6363 }
6364 }
6365
6366 /**
6367 * Unschedule daily subscription sync cron job
6368 *
6369 * Should be called on plugin deactivation.
6370 */
6371 public function unschedule_daily_subscription_sync() {
6372 $timestamp = wp_next_scheduled( 'wpforo_ai_daily_subscription_sync' );
6373 if ( $timestamp ) {
6374 wp_unschedule_event( $timestamp, 'wpforo_ai_daily_subscription_sync' );
6375 }
6376 }
6377
6378 /**
6379 * Cron handler for daily subscription status sync
6380 *
6381 * Fetches fresh status from API and updates cached subscription info.
6382 * Only runs if tenant is connected.
6383 */
6384 public function cron_daily_subscription_sync() {
6385 // Only sync if connected
6386 if ( ! $this->is_connected() ) {
6387 return;
6388 }
6389
6390 // Force fresh API call to get latest subscription status
6391 $this->clear_status_cache();
6392 $status = $this->get_tenant_status( true );
6393
6394 if ( is_wp_error( $status ) ) {
6395 $this->log_error( 'daily_subscription_sync_failed', $status->get_error_message() );
6396 return;
6397 }
6398
6399 $this->log_info( 'daily_subscription_sync_completed', [
6400 'plan' => $status['subscription']['plan'] ?? 'unknown',
6401 'status' => $status['subscription']['status'] ?? 'unknown',
6402 ] );
6403 }
6404
6405 /**
6406 * Clear all AI cache entries of a specific type
6407 *
6408 * @param string $type Cache type to clear (or null for all types)
6409 * @return int Number of deleted rows
6410 */
6411 public function clear_ai_cache( $type = null ) {
6412 global $wpdb;
6413
6414 $table = WPF()->tables->ai_cache;
6415
6416 if ( $type ) {
6417 $deleted = $wpdb->query( $wpdb->prepare(
6418 "DELETE FROM `{$table}` WHERE type = %s",
6419 $type
6420 ) );
6421 } else {
6422 $deleted = $wpdb->query( "TRUNCATE TABLE `{$table}`" );
6423 }
6424
6425 $this->log_info( 'ai_cache_cleared', [ 'type' => $type ?: 'all', 'deleted' => $deleted ] );
6426
6427 return (int) $deleted;
6428 }
6429
6430 /**
6431 * Clear translation cache for a specific post
6432 *
6433 * Called when a post is updated or deleted to invalidate cached translations
6434 *
6435 * @param int $postid Post ID
6436 * @return int Number of deleted cache entries
6437 */
6438 public function clear_translation_cache_by_postid( $postid ) {
6439 global $wpdb;
6440
6441 $postid = intval( $postid );
6442 if ( ! $postid ) {
6443 return 0;
6444 }
6445
6446 $table = WPF()->tables->ai_cache;
6447
6448 $deleted = $wpdb->query( $wpdb->prepare(
6449 "DELETE FROM `{$table}` WHERE type = %s AND postid = %d",
6450 self::CACHE_TYPE_TRANSLATE,
6451 $postid
6452 ) );
6453
6454 if ( $deleted ) {
6455 $this->log_info( 'translation_cache_cleared', [ 'postid' => $postid, 'deleted' => $deleted ] );
6456 }
6457
6458 return (int) $deleted;
6459 }
6460
6461 /**
6462 * Clear topic summary cache for a specific topic
6463 *
6464 * Called when a post is added/edited/deleted to invalidate cached summaries.
6465 * Note: The summary cache key includes reply_count and last_modified, so deleting
6466 * by topicid clears all cached versions of the summary for this topic.
6467 *
6468 * @param int $topicid Topic ID
6469 * @return int Number of deleted cache entries
6470 */
6471 public function clear_topic_summary_cache( $topicid ) {
6472 global $wpdb;
6473
6474 $topicid = intval( $topicid );
6475 if ( ! $topicid ) {
6476 return 0;
6477 }
6478
6479 $table = WPF()->tables->ai_cache;
6480
6481 // Topic summary cache uses postid column to store topicid
6482 $deleted = $wpdb->query( $wpdb->prepare(
6483 "DELETE FROM `{$table}` WHERE type = %s AND postid = %d",
6484 self::CACHE_TYPE_TOPIC_SUMMARY,
6485 $topicid
6486 ) );
6487
6488 if ( $deleted ) {
6489 $this->log_info( 'topic_summary_cache_cleared', [ 'topicid' => $topicid, 'deleted' => $deleted ] );
6490 }
6491
6492 return (int) $deleted;
6493 }
6494
6495 /**
6496 * Callback for post edit action
6497 *
6498 * Clears translation cache when a post is edited
6499 *
6500 * @param array $post Post data
6501 * @param array $topic Topic data
6502 * @param array $forum Forum data
6503 * @param array $args Edit arguments
6504 */
6505 public function on_post_edit( $post, $topic, $forum, $args ) {
6506 $postid = wpfval( $post, 'postid' );
6507 if ( $postid ) {
6508 $this->clear_translation_cache_by_postid( $postid );
6509 }
6510
6511 // Update indexed hash for the topic (invalidates summarization cache)
6512 // Don't set cloud = 0 - per-reply deduplication handles content changes
6513 $topicid = wpfval( $post, 'topicid' );
6514 if ( $topicid ) {
6515 $this->update_topic_indexed_hash( $topicid );
6516 }
6517 }
6518
6519 /**
6520 * Callback for post add action
6521 *
6522 * Invalidates cloud status when a new post is added to an indexed topic.
6523 * This ensures the topic will be force re-indexed on next indexing run.
6524 *
6525 * @param array $post Post data
6526 * @param array $topic Topic data
6527 */
6528 public function on_post_add( $post, $topic ) {
6529 $topicid = wpfval( $post, 'topicid' );
6530 if ( $topicid ) {
6531 // Set local = 0 and cloud = 0 to trigger force re-indexing (new post = structural change)
6532 $this->invalidate_topic_indexed_status( $topicid, 'post_added' );
6533 // Also update indexed hash for cache invalidation
6534 $this->update_topic_indexed_hash( $topicid );
6535 }
6536 }
6537
6538 /**
6539 * Callback for post delete action
6540 *
6541 * When a post is deleted:
6542 * 1. Clear translation cache for the post
6543 * 2. Delete local embedding for the post
6544 * 3. Invalidate topic indexed status (triggers re-indexing)
6545 * 4. Update indexed hash (invalidates summarization cache)
6546 *
6547 * Note: Cloud vectors are cleaned up on next sync when the comparison
6548 * detects the missing post. This avoids blocking the delete operation
6549 * with an API call.
6550 *
6551 * @param array $post Post data
6552 */
6553 public function on_post_delete( $post ) {
6554 $postid = wpfval( $post, 'postid' );
6555 $topicid = wpfval( $post, 'topicid' );
6556
6557 if ( $postid ) {
6558 // 1. Clear translation cache for this post
6559 $this->clear_translation_cache_by_postid( $postid );
6560
6561 // 2. Delete local embedding for this post
6562 if ( WPF()->vector_storage ) {
6563 WPF()->vector_storage->delete_post_embedding( $postid );
6564 }
6565 }
6566
6567 if ( $topicid ) {
6568 // 3. Invalidate topic indexed status (local=0, cloud=0)
6569 // This ensures the topic gets re-indexed without the deleted post
6570 $this->invalidate_topic_indexed_status( $topicid, 'post_deleted' );
6571
6572 // 4. Update indexed hash (invalidates summarization cache)
6573 $this->update_topic_indexed_hash( $topicid );
6574
6575 // 5. Clear topic summary cache
6576 $this->clear_topic_summary_cache( $topicid );
6577
6578 // 6. Clear indexed counts transient
6579 delete_transient( 'wpforo_ai_indexed_counts' );
6580 }
6581 }
6582
6583 /**
6584 * Callback for post approve action
6585 *
6586 * When an unapproved post is approved, we need to:
6587 * 1. Update the indexed hash (content versioning)
6588 * 2. Set local = 0 and cloud = 0 to trigger re-indexing
6589 *
6590 * This ensures the approved post content gets indexed properly.
6591 *
6592 * @param array $post Post data
6593 */
6594 public function on_post_approve( $post ) {
6595 $topicid = wpfval( $post, 'topicid' );
6596 if ( $topicid ) {
6597 // Update indexed hash for cache invalidation (new content version)
6598 $this->update_topic_indexed_hash( $topicid );
6599 // Set local = 0 and cloud = 0 to trigger force re-indexing
6600 $this->invalidate_topic_indexed_status( $topicid, 'post_approved' );
6601 }
6602 }
6603
6604 /**
6605 * Callback for topic add action
6606 *
6607 * When a new approved topic is created, queue it for auto-indexing.
6608 * This ensures new content gets indexed without manual intervention.
6609 *
6610 * @param array $topic Topic data
6611 * @param array $forum Forum data
6612 */
6613 public function on_topic_add( $topic, $forum ) {
6614 $topicid = wpfval( $topic, 'topicid' );
6615 $status = intval( wpfval( $topic, 'status' ) );
6616
6617 // Only auto-index approved topics (status = 0)
6618 if ( $topicid && $status === 0 ) {
6619 // Queue for auto-indexing (background processing)
6620 WPF()->vector_storage->queue_topic_for_auto_indexing( $topicid );
6621
6622 $this->log_info( 'topic_queued_on_create', [
6623 'topicid' => $topicid,
6624 'status' => $status,
6625 ] );
6626 }
6627 }
6628
6629 /**
6630 * Callback for topic approve action
6631 *
6632 * When an unapproved topic is approved by admin, queue it for auto-indexing.
6633 * Also updates the indexed hash and invalidates any existing indexed status.
6634 *
6635 * @param array $topic Topic data
6636 */
6637 public function on_topic_approve( $topic ) {
6638 $topicid = wpfval( $topic, 'topicid' );
6639 if ( $topicid ) {
6640 // Update indexed hash for cache invalidation
6641 $this->update_topic_indexed_hash( $topicid );
6642
6643 // Set local = 0 and cloud = 0 to ensure fresh indexing
6644 $this->invalidate_topic_indexed_status( $topicid, 'topic_approved' );
6645
6646 // Queue for auto-indexing (background processing)
6647 WPF()->vector_storage->queue_topic_for_auto_indexing( $topicid );
6648
6649 $this->log_info( 'topic_queued_on_approve', [
6650 'topicid' => $topicid,
6651 ] );
6652 }
6653 }
6654
6655 /**
6656 * Callback for topic delete action
6657 *
6658 * When a topic is deleted:
6659 * 1. Delete all local embeddings for the topic
6660 * 2. Clear topic summary cache
6661 *
6662 * Note: Translation caches for individual posts are already cleared via
6663 * on_post_delete() which fires for each post before the topic is deleted.
6664 *
6665 * Note: Cloud vectors are cleaned up automatically - either during the next
6666 * sync (comparison detects missing topic) or when tenant is disconnected.
6667 * We don't need to reset topic indexed status since the topic row is deleted.
6668 *
6669 * @param array $topic Topic data
6670 */
6671 public function on_topic_delete( $topic ) {
6672 $topicid = wpfval( $topic, 'topicid' );
6673 if ( ! $topicid ) {
6674 return;
6675 }
6676
6677 // 1. Delete all local embeddings for this topic
6678 if ( WPF()->vector_storage ) {
6679 WPF()->vector_storage->delete_topic_embeddings( $topicid );
6680 }
6681
6682 // 2. Clear topic summary cache
6683 $this->clear_topic_summary_cache( $topicid );
6684
6685 // 3. Clear indexed counts transient
6686 delete_transient( 'wpforo_ai_indexed_counts' );
6687
6688 $this->log_info( 'topic_deleted_cleanup', [
6689 'topicid' => $topicid,
6690 ] );
6691 }
6692
6693 /**
6694 * Callback for topic private status change
6695 *
6696 * When a topic becomes private:
6697 * - Delete all embeddings (local and cloud)
6698 * - Mark topic as not indexed
6699 * - Clear caches
6700 *
6701 * When a topic becomes public again:
6702 * - Queue for auto-indexing (if enabled)
6703 *
6704 * @param int $topicid Topic ID
6705 * @param int $private New private status (1 = private, 0 = public)
6706 */
6707 public function on_topic_private_update( $topicid, $private ) {
6708 if ( ! $topicid ) {
6709 return;
6710 }
6711
6712 if ( $private ) {
6713 $topic = wpforo_topic( $topicid );
6714 if ( empty( $topic ) ) {
6715 return;
6716 }
6717
6718 $is_indexed = ! empty( $topic['local'] ) || ! empty( $topic['cloud'] );
6719 if ( ! $is_indexed ) {
6720 $this->log_info( 'topic_private_not_indexed', [ 'topicid' => $topicid ] );
6721 return;
6722 }
6723
6724 if ( WPF()->vector_storage ) {
6725 WPF()->vector_storage->delete_topic_embeddings( $topicid );
6726 WPF()->vector_storage->mark_topic_not_indexed( $topicid );
6727 }
6728 $this->clear_topic_summary_cache( $topicid );
6729 delete_transient( 'wpforo_ai_indexed_counts' );
6730
6731 $this->log_info( 'topic_removed_on_private', [ 'topicid' => $topicid ] );
6732 } else {
6733 if ( WPF()->vector_storage ) {
6734 WPF()->vector_storage->queue_topic_for_auto_indexing( $topicid );
6735 }
6736 $this->log_info( 'topic_queued_on_unprivate', [ 'topicid' => $topicid ] );
6737 }
6738 }
6739
6740 /**
6741 * Daily cron handler for pending topics indexing
6742 *
6743 * Runs once a day to find topics with local=0 or cloud=0
6744 * (based on current storage mode) and queue them for indexing.
6745 *
6746 * This catches:
6747 * - Topics that failed to index previously
6748 * - Topics added when cron was missed
6749 * - Topics that need re-indexing after content changes
6750 */
6751 public function cron_pending_topics_indexing() {
6752 if ( ! $this->is_service_available() ) {
6753 return;
6754 }
6755
6756 $result = WPF()->vector_storage->cron_process_pending_topics();
6757
6758 $this->log_info( 'daily_pending_topics_processed', $result );
6759 }
6760
6761 /**
6762 * Invalidate cloud indexed status for a topic
6763 *
6764 * Sets cloud = 0 for a topic that was previously indexed (cloud = 1).
6765 * This triggers force re-indexing when the topic is next indexed,
6766 * ensuring old vectors are deleted before re-embedding.
6767 *
6768 * Called when a new reply is added to the topic (structural change).
6769 * NOT called for edits/deletes - those use per-reply deduplication.
6770 *
6771 * @param int $topicid Topic ID
6772 */
6773 private function invalidate_topic_cloud_status( $topicid ) {
6774 global $wpdb;
6775
6776 // Only update if topic is currently indexed in cloud (cloud = 1)
6777 $updated = $wpdb->query(
6778 $wpdb->prepare(
6779 "UPDATE `" . WPF()->tables->topics . "`
6780 SET `cloud` = 0
6781 WHERE `topicid` = %d AND `cloud` = 1",
6782 $topicid
6783 )
6784 );
6785
6786 if ( $updated > 0 ) {
6787 $this->log_info( 'topic_cloud_status_invalidated', [
6788 'topicid' => $topicid,
6789 'reason' => 'post_added'
6790 ] );
6791 }
6792 }
6793
6794 /**
6795 * Invalidate both local and cloud indexed status for a topic
6796 *
6797 * Sets local = 0 and cloud = 0 for a topic.
6798 * This triggers force re-indexing for both local and cloud storage
6799 * when the topic is next indexed.
6800 *
6801 * Called when:
6802 * - A new approved post is added to the topic
6803 * - An unapproved post is approved
6804 *
6805 * @param int $topicid Topic ID
6806 * @param string $reason Reason for invalidation (for logging)
6807 */
6808 private function invalidate_topic_indexed_status( $topicid, $reason = 'unknown' ) {
6809 global $wpdb;
6810
6811 // Set both local and cloud to 0 to trigger re-indexing
6812 $updated = $wpdb->query(
6813 $wpdb->prepare(
6814 "UPDATE `" . WPF()->tables->topics . "`
6815 SET `local` = 0, `cloud` = 0
6816 WHERE `topicid` = %d AND (`local` = 1 OR `cloud` = 1)",
6817 $topicid
6818 )
6819 );
6820
6821 if ( $updated > 0 ) {
6822 $this->log_info( 'topic_indexed_status_invalidated', [
6823 'topicid' => $topicid,
6824 'reason' => $reason
6825 ] );
6826 }
6827 }
6828
6829 /**
6830 * Update indexed hash for a topic
6831 *
6832 * Recalculates the indexed hash (MD5 of topicid_postcount).
6833 * This invalidates the summarization cache when posts change.
6834 *
6835 * Called when:
6836 * - A new reply is added to the topic
6837 * - An existing post is edited
6838 * - A post is deleted
6839 *
6840 * @param int $topicid Topic ID
6841 */
6842 private function update_topic_indexed_hash( $topicid ) {
6843 global $wpdb;
6844
6845 // Update indexed hash based on current post count
6846 // Hash formula: MD5(topicid + '_' + posts)
6847 $updated = $wpdb->query(
6848 $wpdb->prepare(
6849 "UPDATE `" . WPF()->tables->topics . "`
6850 SET `indexed` = MD5(CONCAT(topicid, '_', posts))
6851 WHERE `topicid` = %d",
6852 $topicid
6853 )
6854 );
6855
6856 if ( $updated > 0 ) {
6857 $this->log_info( 'topic_indexed_hash_updated', [
6858 'topicid' => $topicid,
6859 ] );
6860 // Clear topic cache to reflect updated hash
6861 wpforo_clean_cache( 'topic', $topicid );
6862 }
6863 }
6864
6865 // =========================================================================
6866 // AI TRANSLATION METHODS
6867 // =========================================================================
6868
6869 /**
6870 * Render translation button in post content
6871 *
6872 * Displays a "Translate" dropdown button that allows users to translate
6873 * post content to their preferred language.
6874 *
6875 * @param array $post Post data
6876 * @return void
6877 */
6878 public function render_translation_button( $post ) {
6879 // Check if AI service is available and translation is available
6880 if ( ! $this->is_service_available() ) {
6881 return;
6882 }
6883
6884 // Check if translation is enabled in settings
6885 if ( ! wpfval( WPF()->settings->ai, 'translation' ) ) {
6886 return;
6887 }
6888
6889 // Check usergroup permission
6890 if ( ! WPF()->usergroup->can( 'ai_translation' ) ) {
6891 return;
6892 }
6893
6894 // Check if we have the post ID
6895 $post_id = wpfval( $post, 'postid' );
6896 if ( empty( $post_id ) ) {
6897 return;
6898 }
6899
6900 // Get available translation languages
6901 $languages = $this->get_available_translation_languages();
6902
6903 // Render the translation dropdown button
6904 ?>
6905 <div class="wpf-ai-translate-wrapper" data-postid="<?php echo esc_attr( $post_id ); ?>">
6906 <div class="wpf-ai-translate-btn" title="<?php echo esc_attr( wpforo_phrase( 'Translate this post', false ) ); ?>">
6907 <span class="wpf-ai-translate-label"><?php echo esc_html( wpforo_phrase( 'Translate', false ) ); ?></span>
6908 <span class="wpf-ai-translate-arrow">â–¼</span>
6909 </div>
6910 <div class="wpf-ai-translate-dropdown">
6911 <?php foreach ( $languages as $code => $name ) : ?>
6912 <div class="wpf-ai-translate-option" data-lang="<?php echo esc_attr( $code ); ?>">
6913 <?php echo esc_html( $name ); ?>
6914 </div>
6915 <?php endforeach; ?>
6916 </div>
6917 <div class="wpf-ai-translate-original" style="display:none;">
6918 <span class="wpf-ai-translate-label"><?php echo esc_html( wpforo_phrase( 'Show original', false ) ); ?></span>
6919 </div>
6920 <div class="wpf-ai-translate-loading" style="display:none;">
6921 <span class="wpf-ai-translate-spinner"></span>
6922 <span><?php echo esc_html( wpforo_phrase( 'Translating...', false ) ); ?></span>
6923 </div>
6924 </div>
6925 <?php
6926 }
6927
6928 /**
6929 * Get available translation languages
6930 *
6931 * @return array Associative array of language code => language name
6932 */
6933 public function get_available_translation_languages() {
6934 $languages = [
6935 'en' => wpforo_phrase( 'English', false ),
6936 'es' => wpforo_phrase( 'Spanish', false ),
6937 'fr' => wpforo_phrase( 'French', false ),
6938 'de' => wpforo_phrase( 'German', false ),
6939 'it' => wpforo_phrase( 'Italian', false ),
6940 'pt' => wpforo_phrase( 'Portuguese', false ),
6941 'ru' => wpforo_phrase( 'Russian', false ),
6942 'zh' => wpforo_phrase( 'Chinese', false ),
6943 'ja' => wpforo_phrase( 'Japanese', false ),
6944 'ko' => wpforo_phrase( 'Korean', false ),
6945 'ar' => wpforo_phrase( 'Arabic', false ),
6946 'hi' => wpforo_phrase( 'Hindi', false ),
6947 'nl' => wpforo_phrase( 'Dutch', false ),
6948 'pl' => wpforo_phrase( 'Polish', false ),
6949 'tr' => wpforo_phrase( 'Turkish', false ),
6950 'vi' => wpforo_phrase( 'Vietnamese', false ),
6951 'th' => wpforo_phrase( 'Thai', false ),
6952 'sv' => wpforo_phrase( 'Swedish', false ),
6953 'da' => wpforo_phrase( 'Danish', false ),
6954 'fi' => wpforo_phrase( 'Finnish', false ),
6955 'no' => wpforo_phrase( 'Norwegian', false ),
6956 'cs' => wpforo_phrase( 'Czech', false ),
6957 'hu' => wpforo_phrase( 'Hungarian', false ),
6958 'ro' => wpforo_phrase( 'Romanian', false ),
6959 'el' => wpforo_phrase( 'Greek', false ),
6960 'he' => wpforo_phrase( 'Hebrew', false ),
6961 'id' => wpforo_phrase( 'Indonesian', false ),
6962 'ms' => wpforo_phrase( 'Malay', false ),
6963 'uk' => wpforo_phrase( 'Ukrainian', false ),
6964 'bg' => wpforo_phrase( 'Bulgarian', false ),
6965 'hr' => wpforo_phrase( 'Croatian', false ),
6966 'sk' => wpforo_phrase( 'Slovak', false ),
6967 'sl' => wpforo_phrase( 'Slovenian', false ),
6968 'sr' => wpforo_phrase( 'Serbian', false ),
6969 'lt' => wpforo_phrase( 'Lithuanian', false ),
6970 'lv' => wpforo_phrase( 'Latvian', false ),
6971 'et' => wpforo_phrase( 'Estonian', false ),
6972 ];
6973
6974 return apply_filters( 'wpforo_ai_translation_languages', $languages );
6975 }
6976
6977 /**
6978 * Translate content via AI API
6979 *
6980 * @param string $content HTML content to translate
6981 * @param string $target_language Target language code (e.g., 'es', 'fr', 'de')
6982 * @return array|WP_Error Translated content or error
6983 */
6984 public function translate_content( $content, $target_language ) {
6985 if ( empty( $content ) ) {
6986 return new \WP_Error( 'empty_content', wpforo_phrase( 'Content cannot be empty', false ) );
6987 }
6988
6989 if ( empty( $target_language ) ) {
6990 return new \WP_Error( 'empty_language', wpforo_phrase( 'Target language is required', false ) );
6991 }
6992
6993 // Get language name for API
6994 $languages = $this->get_available_translation_languages();
6995 $language_name = isset( $languages[ $target_language ] ) ? $languages[ $target_language ] : $target_language;
6996
6997 $data = [
6998 'content' => $content,
6999 'target_language' => $language_name,
7000 ];
7001
7002 // Add quality parameter from settings (for translation model selection)
7003 $translation_quality = wpfval( WPF()->settings->ai, 'translation_quality' );
7004 if ( ! empty( $translation_quality ) ) {
7005 $data['quality'] = sanitize_text_field( $translation_quality );
7006 }
7007
7008 $response = $this->post( '/translate', $data );
7009
7010 if ( is_wp_error( $response ) ) {
7011 $this->log_error( 'translation_failed', $response->get_error_message() );
7012 return $response;
7013 }
7014
7015 $this->log_info( 'translation_completed', [
7016 'target_language' => $target_language,
7017 'content_length' => strlen( $content ),
7018 ] );
7019
7020 return $response;
7021 }
7022
7023 /**
7024 * AJAX handler for content translation
7025 *
7026 * @return void
7027 */
7028 public function ajax_translate_content() {
7029 // Track start time for logging
7030 $_log_start_time = microtime( true );
7031
7032 // Verify nonce
7033 if ( ! wp_verify_nonce( wpfval( $_POST, 'nonce' ), 'wpforo_ai_translate' ) ) {
7034 wp_send_json_error( [
7035 'message' => wpforo_phrase( 'Security check failed', false )
7036 ], 403 );
7037 }
7038
7039 // Note: Rate limit check moved after cache check
7040 // Cached content should bypass rate limits since it doesn't use API resources
7041
7042 // Check if AI service is available
7043 if ( ! $this->is_service_available() ) {
7044 wp_send_json_error( [
7045 'message' => wpforo_phrase( 'AI service is not available', false )
7046 ], 403 );
7047 }
7048
7049 // Check if translation is enabled in settings
7050 if ( ! wpfval( WPF()->settings->ai, 'translation' ) ) {
7051 wp_send_json_error( [
7052 'message' => wpforo_phrase( 'Translation feature is disabled', false )
7053 ], 403 );
7054 }
7055
7056 // Check usergroup permission
7057 if ( ! WPF()->usergroup->can( 'ai_translation' ) ) {
7058 wp_send_json_error( [
7059 'message' => wpforo_phrase( 'You do not have permission to use this feature', false )
7060 ], 403 );
7061 }
7062
7063 // Get post ID and validate
7064 $post_id = isset( $_POST['post_id'] ) ? (int) $_POST['post_id'] : 0;
7065 if ( ! $post_id ) {
7066 wp_send_json_error( [
7067 'message' => wpforo_phrase( 'Invalid post ID', false )
7068 ], 400 );
7069 }
7070
7071 // Get target language
7072 $target_language = sanitize_text_field( wpfval( $_POST, 'language' ) );
7073 if ( empty( $target_language ) ) {
7074 wp_send_json_error( [
7075 'message' => wpforo_phrase( 'Target language is required', false )
7076 ], 400 );
7077 }
7078
7079 // Get post content from database
7080 $post = wpforo_post( $post_id );
7081 if ( empty( $post ) || empty( $post['body'] ) ) {
7082 wp_send_json_error( [
7083 'message' => wpforo_phrase( 'Post not found', false )
7084 ], 404 );
7085 }
7086
7087 // Get the rendered HTML content using output buffering
7088 // (wpforo_content echoes instead of returning)
7089 ob_start();
7090 wpforo_content( $post );
7091 $content = ob_get_clean();
7092
7093 if ( empty( trim( $content ) ) ) {
7094 // Fallback to raw body content
7095 $content = $post['body'];
7096 }
7097
7098 // Build cache key from postid + language
7099 $cache_key = $this->build_translate_cache_key( $post_id, $target_language );
7100
7101 // Check cache first
7102 $cached_result = $this->get_ai_cache( self::CACHE_TYPE_TRANSLATE, $cache_key );
7103 if ( $cached_result ) {
7104 // Log cached translation
7105 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
7106 WPF()->ai_logs->log( [
7107 'action_type' => AILogs::ACTION_TRANSLATION,
7108 'credits_used' => 0,
7109 'status' => AILogs::STATUS_CACHED,
7110 'content_type' => 'post',
7111 'content_id' => $post_id,
7112 'request_summary' => sprintf( 'Translate post #%d to %s', $post_id, $target_language ),
7113 'response_summary' => 'Cached translation',
7114 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
7115 ] );
7116 }
7117 // Return cached translation (no credits used)
7118 wp_send_json_success( [
7119 'translated_content' => wpfval( $cached_result, 'translated_content' ) ?: '',
7120 'source_language' => wpfval( $cached_result, 'source_language' ) ?: 'auto',
7121 'target_language' => $target_language,
7122 'credits_used' => 0,
7123 'cached' => true,
7124 ] );
7125 }
7126
7127 // Cache miss - check rate limit before making API call
7128 // Rate limit is only checked for non-cached requests that consume API resources
7129 $this->check_rate_limit( 'translation' );
7130
7131 // Translate the content (cache miss)
7132 $result = $this->translate_content( $content, $target_language );
7133
7134 if ( is_wp_error( $result ) ) {
7135 // Log error
7136 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
7137 WPF()->ai_logs->log( [
7138 'action_type' => AILogs::ACTION_TRANSLATION,
7139 'credits_used' => 0,
7140 'status' => AILogs::STATUS_ERROR,
7141 'content_type' => 'post',
7142 'content_id' => $post_id,
7143 'request_summary' => sprintf( 'Translate post #%d to %s', $post_id, $target_language ),
7144 'error_message' => $result->get_error_message(),
7145 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
7146 ] );
7147 }
7148 wp_send_json_error( [
7149 'message' => $result->get_error_message()
7150 ], 500 );
7151 }
7152
7153 $credits_used = wpfval( $result, 'credits_used' ) ?: 1;
7154
7155 // Log successful translation
7156 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
7157 WPF()->ai_logs->log( [
7158 'action_type' => AILogs::ACTION_TRANSLATION,
7159 'credits_used' => $credits_used,
7160 'status' => AILogs::STATUS_SUCCESS,
7161 'content_type' => 'post',
7162 'content_id' => $post_id,
7163 'request_summary' => sprintf( 'Translate post #%d to %s', $post_id, $target_language ),
7164 'response_summary' => sprintf( 'Translated from %s', wpfval( $result, 'source_language' ) ?: 'auto' ),
7165 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
7166 ] );
7167 }
7168
7169 // Cache only essential fields (exclude model_used, credits_remaining, credits_used)
7170 $cache_data = [
7171 'translated_content' => wpfval( $result, 'translated_content' ) ?: '',
7172 'source_language' => wpfval( $result, 'source_language' ) ?: 'auto',
7173 ];
7174 $this->set_ai_cache( self::CACHE_TYPE_TRANSLATE, $cache_key, $cache_data, 0, $post_id );
7175
7176 // Return translated content
7177 wp_send_json_success( [
7178 'translated_content' => wpfval( $result, 'translated_content' ) ?: '',
7179 'source_language' => wpfval( $result, 'source_language' ) ?: 'auto',
7180 'target_language' => $target_language,
7181 'credits_used' => $credits_used,
7182 'cached' => false,
7183 ] );
7184 }
7185
7186 // =========================================================================
7187 // TOPIC SUMMARIZATION
7188 // =========================================================================
7189
7190 /**
7191 * Summarize a forum topic using AI
7192 *
7193 * Sends topic content and replies to the AI summarization API.
7194 * If indexed_hash is provided, backend loads posts from cloud storage.
7195 * If posts are provided, they are sent directly to the API.
7196 *
7197 * @param int $topicid Topic ID to summarize
7198 * @param array|null $posts Array of post data (post_id, author, content, is_first_post) or null if using indexed_hash
7199 * @param string $style Summary style (compact, structured, conversational, detailed, minimal)
7200 * @param string $last_modified Topic last modified timestamp for cache key
7201 * @param string $indexed_hash MD5 hash of indexed topic (if posts are stored in cloud)
7202 * @return array|WP_Error Summary result or error
7203 */
7204 public function summarize_topic( $topicid, $posts = null, $style = 'detailed', $last_modified = '', $indexed_hash = '', $language = '' ) {
7205 if ( empty( $topicid ) ) {
7206 return new \WP_Error( 'empty_topicid', wpforo_phrase( 'Topic ID is required', false ) );
7207 }
7208
7209 // Must have either posts or indexed_hash
7210 if ( ( empty( $posts ) || ! is_array( $posts ) ) && empty( $indexed_hash ) ) {
7211 return new \WP_Error( 'empty_posts', wpforo_phrase( 'Topic posts or indexed_hash are required', false ) );
7212 }
7213
7214 // Get topic title
7215 $topic = wpforo_topic( $topicid );
7216 if ( empty( $topic ) || empty( $topic['title'] ) ) {
7217 return new \WP_Error( 'topic_not_found', wpforo_phrase( 'Topic not found', false ) );
7218 }
7219
7220 $data = [
7221 'topic_id' => (int) $topicid,
7222 'topic_title' => sanitize_text_field( $topic['title'] ),
7223 'style' => sanitize_text_field( $style ),
7224 ];
7225
7226 // If indexed_hash is provided, backend will load posts from cloud storage
7227 // Otherwise, send posts directly in the request
7228 if ( ! empty( $indexed_hash ) ) {
7229 $data['indexed_hash'] = sanitize_text_field( $indexed_hash );
7230 }
7231
7232 if ( ! empty( $posts ) && is_array( $posts ) ) {
7233 $data['posts'] = $posts;
7234 }
7235
7236 // Add quality parameter from settings
7237 $summary_quality = wpfval( WPF()->settings->ai, 'topic_summary_quality' );
7238 if ( ! empty( $summary_quality ) ) {
7239 $data['quality'] = sanitize_text_field( $summary_quality );
7240 }
7241
7242 // Add last_modified for cache key generation on server side
7243 if ( ! empty( $last_modified ) ) {
7244 $data['last_modified'] = sanitize_text_field( $last_modified );
7245 }
7246
7247 // Add language for summary generation
7248 if ( ! empty( $language ) ) {
7249 $data['language'] = sanitize_text_field( $language );
7250 }
7251
7252 $response = $this->post( '/summarize', $data );
7253
7254 if ( is_wp_error( $response ) ) {
7255 $this->log_error( 'topic_summary_failed', $response->get_error_message() );
7256 return $response;
7257 }
7258
7259 $this->log_info( 'topic_summary_completed', [
7260 'topic_id' => $topicid,
7261 'reply_count' => is_array( $posts ) ? count( $posts ) : 0,
7262 'style' => $style,
7263 'indexed_hash' => ! empty( $indexed_hash ),
7264 ] );
7265
7266 return $response;
7267 }
7268
7269 /**
7270 * Get available summary styles
7271 *
7272 * @return array Style ID => Style name mapping
7273 */
7274 public function get_available_summary_styles() {
7275 return [
7276 'compact' => wpforo_phrase( 'Compact with Key Points', false ),
7277 'structured' => wpforo_phrase( 'Structured with Sections', false ),
7278 'conversational' => wpforo_phrase( 'Conversational Flow', false ),
7279 'detailed' => wpforo_phrase( 'Short Summary + Details', false ),
7280 'minimal' => wpforo_phrase( 'Minimal and Clean', false ),
7281 ];
7282 }
7283
7284 /**
7285 * AJAX handler for topic summarization
7286 *
7287 * @return void
7288 */
7289 public function ajax_summarize_topic() {
7290 // Track start time for logging
7291 $_log_start_time = microtime( true );
7292
7293 // Verify nonce
7294 if ( ! wp_verify_nonce( wpfval( $_POST, 'nonce' ), 'wpforo_ai_summarize_topic' ) ) {
7295 wp_send_json_error( [
7296 'message' => wpforo_phrase( 'Security check failed', false )
7297 ], 403 );
7298 }
7299
7300 // Note: Rate limit check moved after cache check
7301 // Cached summaries should bypass rate limits since they don't use API resources
7302
7303 // Check if AI service is available
7304 if ( ! $this->is_service_available() ) {
7305 wp_send_json_error( [
7306 'message' => wpforo_phrase( 'AI service is not available', false )
7307 ], 403 );
7308 }
7309
7310 // Check if topic summarization is enabled in settings
7311 if ( ! wpfval( WPF()->settings->ai, 'topic_summary' ) ) {
7312 wp_send_json_error( [
7313 'message' => wpforo_phrase( 'Topic summarization feature is disabled', false )
7314 ], 403 );
7315 }
7316
7317 // Check usergroup permission
7318 if ( ! WPF()->usergroup->can( 'ai_summary' ) ) {
7319 wp_send_json_error( [
7320 'message' => wpforo_phrase( 'You do not have permission to use this feature', false )
7321 ], 403 );
7322 }
7323
7324 // Get topic ID and validate
7325 $topicid = isset( $_POST['topicid'] ) ? (int) $_POST['topicid'] : 0;
7326 if ( ! $topicid ) {
7327 wp_send_json_error( [
7328 'message' => wpforo_phrase( 'Invalid topic ID', false )
7329 ], 400 );
7330 }
7331
7332 // Get topic data
7333 $topic = wpforo_topic( $topicid );
7334 if ( empty( $topic ) ) {
7335 wp_send_json_error( [
7336 'message' => wpforo_phrase( 'Topic not found', false )
7337 ], 404 );
7338 }
7339
7340 // Get summary style from settings or request
7341 $style = sanitize_text_field( wpfval( $_POST, 'style' ) );
7342 if ( empty( $style ) ) {
7343 $style = wpfval( WPF()->settings->ai, 'topic_summary_style' ) ?: 'detailed';
7344 }
7345
7346 // Check if topic is indexed in CLOUD storage (posts stored in cloud)
7347 // Only use cloud loading when:
7348 // 1. We're in cloud storage mode
7349 // 2. The topic's `cloud` column is 1 (indexed in cloud)
7350 // 3. The indexed hash exists (for backend to identify the topic)
7351 $indexed_hash = $topic['indexed'] ?? '';
7352 $is_cloud_indexed = WPF()->vector_storage && WPF()->vector_storage->is_cloud_mode()
7353 && ! empty( $topic['cloud'] ) && (int) $topic['cloud'] === 1
7354 && ! empty( $indexed_hash );
7355
7356 $posts = null;
7357 $total_posts_count = 0;
7358 $posts_limited = false;
7359 $max_posts = 220; // first 20 + last 200
7360
7361 if ( $is_cloud_indexed ) {
7362 // Topic is indexed in cloud - backend will load posts from cloud storage
7363 // Use topic's posts count for display purposes
7364 $total_posts_count = (int) ( $topic['posts'] ?? 0 );
7365 $reply_count = $total_posts_count; // All posts available in cloud
7366 } else {
7367 // Clear indexed_hash if not using cloud - don't send to API
7368 $indexed_hash = '';
7369 // Topic not indexed - load posts from WordPress and send to API
7370 $items_count = 0;
7371 $posts_data = WPF()->post->get_posts( [ 'topicid' => $topicid ], $items_count );
7372 if ( empty( $posts_data ) ) {
7373 wp_send_json_error( [
7374 'message' => wpforo_phrase( 'No posts found in this topic', false )
7375 ], 404 );
7376 }
7377
7378 // Prepare posts array for API
7379 $posts = [];
7380 $first_post_id = $topic['first_postid'] ?? 0;
7381
7382 foreach ( $posts_data as $post ) {
7383 // Get rendered HTML content using output buffering
7384 ob_start();
7385 wpforo_content( $post );
7386 $content = ob_get_clean();
7387
7388 if ( empty( trim( $content ) ) ) {
7389 $content = $post['body'] ?? '';
7390 }
7391
7392 $posts[] = [
7393 'post_id' => (int) $post['postid'],
7394 'author' => $this->get_user_display_name( $post['userid'] ?? 0 ),
7395 'content' => $content,
7396 'created_at' => $post['created'] ?? '',
7397 'is_first_post' => ( (int) $post['postid'] === (int) $first_post_id ),
7398 ];
7399 }
7400
7401 // Store total count before limiting
7402 $total_posts_count = count( $posts );
7403
7404 // Limit posts for large topics: first 20 + last 200
7405 // This captures the original context and most recent discussion
7406 $first_posts_limit = 20;
7407 $last_posts_limit = 200;
7408
7409 if ( $total_posts_count > $max_posts ) {
7410 $first_posts = array_slice( $posts, 0, $first_posts_limit );
7411 $last_posts = array_slice( $posts, -$last_posts_limit );
7412 $posts = array_merge( $first_posts, $last_posts );
7413 $posts_limited = true;
7414 }
7415
7416 $reply_count = count( $posts );
7417 }
7418
7419 $last_modified = $topic['modified'] ?? $topic['created'] ?? '';
7420
7421 // Get quality and language for cache key
7422 $quality = wpfval( WPF()->settings->ai, 'topic_summary_quality' ) ?: 'advanced';
7423 $language_code = sanitize_text_field( wpfval( $_POST, 'language' ) ) ?: '';
7424 $language = $this->get_user_language( $language_code, 'topic_summary_language' );
7425
7426 // Build cache key from topicid + reply_count + last_modified + quality + style + language
7427 $cache_key = $this->build_topic_summary_cache_key( $topicid, $reply_count, $last_modified, $quality, $style, $language );
7428
7429 // Check cache first
7430 $cached_result = $this->get_ai_cache( self::CACHE_TYPE_TOPIC_SUMMARY, $cache_key );
7431 if ( $cached_result ) {
7432 // Log cached summary
7433 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
7434 WPF()->ai_logs->log( [
7435 'action_type' => AILogs::ACTION_TOPIC_SUMMARY,
7436 'credits_used' => 0,
7437 'status' => AILogs::STATUS_CACHED,
7438 'content_type' => 'topic',
7439 'content_id' => $topicid,
7440 'topicid' => $topicid,
7441 'forumid' => $topic['forumid'] ?? null,
7442 'request_summary' => sprintf( 'Summarize topic: %s', wp_trim_words( $topic['title'], 10 ) ),
7443 'response_summary' => 'Cached summary',
7444 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
7445 ] );
7446 }
7447
7448 // Return cached summary (no credits used)
7449 // Process link markers to convert [[#POST_ID]] to clickable links
7450 $cached_summary = wpfval( $cached_result, 'summary' ) ?: '';
7451 $cached_summary = $this->replace_summary_link_markers( $cached_summary, $topicid );
7452
7453 wp_send_json_success( [
7454 'summary' => $cached_summary,
7455 'style' => wpfval( $cached_result, 'style' ) ?: $style,
7456 'topic_id' => $topicid,
7457 'reply_count' => $reply_count,
7458 'total_posts_count' => $total_posts_count,
7459 'posts_limited' => $posts_limited,
7460 'credits_used' => 0,
7461 'cached' => true,
7462 'from_s3' => $is_cloud_indexed,
7463 ] );
7464 }
7465
7466 // Cache miss - check rate limit before making API call
7467 // Rate limit is only checked for non-cached requests that consume API resources
7468 $this->check_rate_limit( 'summarization' );
7469
7470 // Summarize the topic (cache miss) - pass indexed_hash if available
7471 $result = $this->summarize_topic( $topicid, $posts, $style, $last_modified, $indexed_hash, $language );
7472
7473 if ( is_wp_error( $result ) ) {
7474 // Log error
7475 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
7476 WPF()->ai_logs->log( [
7477 'action_type' => AILogs::ACTION_TOPIC_SUMMARY,
7478 'credits_used' => 0,
7479 'status' => AILogs::STATUS_ERROR,
7480 'content_type' => 'topic',
7481 'content_id' => $topicid,
7482 'topicid' => $topicid,
7483 'forumid' => $topic['forumid'] ?? null,
7484 'request_summary' => sprintf( 'Summarize topic: %s', wp_trim_words( $topic['title'], 10 ) ),
7485 'error_message' => $result->get_error_message(),
7486 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
7487 ] );
7488 }
7489 wp_send_json_error( [
7490 'message' => $result->get_error_message()
7491 ], 500 );
7492 }
7493
7494 // Get raw summary and store in cache (keep raw with link markers for re-processing)
7495 $raw_summary = wpfval( $result, 'summary' ) ?: '';
7496 // Strip markdown code fence wrappers (```html ... ```) that LLMs sometimes add around HTML output
7497 $raw_summary = preg_replace( '/^\s*```\w*\s*\n([\s\S]*?)\n\s*```\s*$/s', '$1', $raw_summary );
7498 $credits_used = wpfval( $result, 'credits_used' ) ?: 1;
7499
7500 // Log success
7501 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
7502 WPF()->ai_logs->log( [
7503 'action_type' => AILogs::ACTION_TOPIC_SUMMARY,
7504 'credits_used' => $credits_used,
7505 'status' => AILogs::STATUS_SUCCESS,
7506 'content_type' => 'topic',
7507 'content_id' => $topicid,
7508 'topicid' => $topicid,
7509 'forumid' => $topic['forumid'] ?? null,
7510 'request_summary' => sprintf( 'Summarize topic: %s (%d posts)', wp_trim_words( $topic['title'], 10 ), $reply_count ),
7511 'response_summary' => sprintf( 'Generated %s-style summary', $style ),
7512 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
7513 ] );
7514 }
7515
7516 // Cache only essential fields (store raw with link markers)
7517 // Note: Using postid parameter to store topicid for topic summary cache
7518 $cache_data = [
7519 'summary' => $raw_summary,
7520 'style' => wpfval( $result, 'style' ) ?: $style,
7521 ];
7522 $this->set_ai_cache( self::CACHE_TYPE_TOPIC_SUMMARY, $cache_key, $cache_data, self::CACHE_TTL, $topicid );
7523
7524 // Process link markers to convert [[#POST_ID]] to clickable links
7525 $processed_summary = $this->replace_summary_link_markers( $raw_summary, $topicid );
7526
7527 // Return summary with clickable links
7528 wp_send_json_success( [
7529 'summary' => $processed_summary,
7530 'style' => wpfval( $result, 'style' ) ?: $style,
7531 'topic_id' => $topicid,
7532 'reply_count' => $reply_count,
7533 'total_posts_count' => $total_posts_count,
7534 'posts_limited' => $posts_limited,
7535 'credits_used' => $credits_used,
7536 'cached' => false,
7537 'from_s3' => $is_cloud_indexed,
7538 ] );
7539 }
7540
7541 /**
7542 * Render topic summary button in the head-bar (next to Subscribe button)
7543 *
7544 * @param array $forum Forum data
7545 * @param array $topic Topic data
7546 * @param array $posts Posts data
7547 * @return void
7548 */
7549 public function render_topic_summary_button( $forum, $topic, $posts ) {
7550 // Check if topic summarization is enabled
7551 if ( ! wpfval( WPF()->settings->ai, 'topic_summary' ) ) {
7552 return;
7553 }
7554
7555 // Check if AI service is available (connected + active/trial subscription)
7556 if ( ! $this->is_service_available() ) {
7557 return;
7558 }
7559
7560 // Check usergroup permission
7561 if ( ! WPF()->usergroup->can( 'ai_summary' ) ) {
7562 return;
7563 }
7564
7565 $topicid = $topic['topicid'] ?? 0;
7566 if ( ! $topicid ) {
7567 return;
7568 }
7569
7570 // Get the number of replies (posts_count includes the original post, so replies = posts_count - 1)
7571 $posts_count = $topic['posts'] ?? count( $posts );
7572 $replies_count = max( 0, $posts_count - 1 );
7573
7574 // Check minimum replies setting (default 1 = at least one reply required)
7575 $min_replies = intval( wpfval( WPF()->settings->ai, 'topic_summary_min_replies' ) ?? 1 );
7576 if ( $replies_count < $min_replies ) {
7577 return;
7578 }
7579
7580 $nonce = wp_create_nonce( 'wpforo_ai_summarize_topic' );
7581 $button_text = wpforo_phrase( 'Summarize Topic', false );
7582
7583 ?>
7584 <span class="wpf-ai-summarize-btn wpf-button-outlined"
7585 data-topicid="<?php echo esc_attr( $topicid ); ?>"
7586 data-nonce="<?php echo esc_attr( $nonce ); ?>"
7587 title="<?php echo esc_attr( $button_text ); ?>">
7588 <i class="fa-solid fa-wand-magic-sparkles"></i>&nbsp; <?php echo esc_html( $button_text ); ?>
7589 </span>
7590 <?php
7591 }
7592
7593 /**
7594 * Standalone wrapper for rendering topic summary container
7595 * Called via wpforo_template_post_head_bar action hook
7596 *
7597 * @param array $forum Forum data
7598 * @param array $topic Topic data
7599 * @param array $posts Posts data
7600 * @return void
7601 */
7602 public function render_topic_summary_container_standalone( $forum, $topic, $posts ) {
7603 $topicid = $topic['topicid'] ?? 0;
7604 $this->render_topic_summary_container( $topicid );
7605 }
7606
7607 /**
7608 * Render the topic summary container (slide-down area under head-bar)
7609 *
7610 * @param int $topicid Topic ID
7611 * @return void
7612 */
7613 public function render_topic_summary_container( $topicid = 0 ) {
7614 if ( ! $topicid ) {
7615 $topic = WPF()->current_object['topic'] ?? [];
7616 $topicid = $topic['topicid'] ?? 0;
7617 }
7618
7619 if ( ! $topicid ) {
7620 return;
7621 }
7622
7623 // Check if topic summarization is enabled
7624 if ( ! wpfval( WPF()->settings->ai, 'topic_summary' ) ) {
7625 return;
7626 }
7627
7628 // Check if AI service is available (connected + active/trial subscription)
7629 if ( ! $this->is_service_available() ) {
7630 return;
7631 }
7632
7633 // Check usergroup permission
7634 if ( ! WPF()->usergroup->can( 'ai_summary' ) ) {
7635 return;
7636 }
7637
7638 ?>
7639 <div class="wpf-ai-summary-container" id="wpf-ai-summary-<?php echo esc_attr( $topicid ); ?>" data-topicid="<?php echo esc_attr( $topicid ); ?>" style="display: none;">
7640 <div class="wpf-ai-summary-loading" style="display: none;">
7641 <span class="wpf-ai-loading-stars">
7642 <span class="wpf-ai-star">&#10022;</span>
7643 <span class="wpf-ai-star">&#10022;</span>
7644 <span class="wpf-ai-star">&#10022;</span>
7645 </span>
7646 <span class="wpf-ai-loading-text"><?php wpforo_phrase( 'AI is analyzing the discussion...' ); ?></span>
7647 </div>
7648 <div class="wpf-ai-summary-content"></div>
7649 <div class="wpf-ai-summary-footer" style="display: none;">
7650 <span class="wpf-ai-summary-info">
7651 <span class="wpf-ai-summary-credits"></span>
7652 </span>
7653 <button type="button" class="wpf-ai-summary-close" title="<?php echo esc_attr( wpforo_phrase( 'Close', false ) ); ?>">
7654 <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
7655 </button>
7656 </div>
7657 </div>
7658 <?php
7659 }
7660
7661 /**
7662 * AJAX handler for topic suggestions
7663 *
7664 * Returns similar topics, related topics, and quick AI answers based on topic title.
7665 * Used during topic creation to help users find existing discussions.
7666 *
7667 * @since 3.0.0
7668 * @return void
7669 */
7670 public function ajax_get_topic_suggestions() {
7671 // Verify nonce (action name matches the AJAX action)
7672 if ( ! wp_verify_nonce( wpfval( $_POST, 'nonce' ), 'wpforo_ai_get_topic_suggestions' ) ) {
7673 wp_send_json_error( [
7674 'message' => wpforo_phrase( 'Security check failed', false )
7675 ], 403 );
7676 }
7677
7678 // Check rate limit (guests and users have daily limits, moderators exempt)
7679 $this->check_rate_limit( 'suggestions' );
7680
7681 // Check if AI service is available
7682 if ( ! $this->is_service_available() ) {
7683 wp_send_json_error( [
7684 'message' => wpforo_phrase( 'AI service is not available', false )
7685 ], 403 );
7686 }
7687
7688 // Check if topic suggestions feature is enabled
7689 if ( ! wpfval( WPF()->settings->ai, 'topic_suggestions' ) ) {
7690 wp_send_json_error( [
7691 'message' => wpforo_phrase( 'Topic suggestions feature is disabled', false )
7692 ], 403 );
7693 }
7694
7695 // Check usergroup permission
7696 if ( ! WPF()->usergroup->can( 'ai_suggestion' ) ) {
7697 wp_send_json_error( [
7698 'message' => wpforo_phrase( 'You do not have permission to use this feature', false )
7699 ], 403 );
7700 }
7701
7702 // Check if user has API key configured
7703 $api_key = $this->get_api_key();
7704 if ( ! $api_key ) {
7705 wp_send_json_error( [
7706 'message' => wpforo_phrase( 'AI service not configured', false )
7707 ], 400 );
7708 }
7709
7710 // Get and validate title
7711 $title = sanitize_text_field( wpfval( $_POST, 'title' ) );
7712 if ( empty( $title ) ) {
7713 wp_send_json_error( [
7714 'message' => wpforo_phrase( 'Topic title is required', false )
7715 ], 400 );
7716 }
7717
7718 // Get forum ID (optional, for context)
7719 $forumid = isset( $_POST['forumid'] ) ? (int) $_POST['forumid'] : 0;
7720
7721 // Get settings for API request
7722 $quality = wpfval( WPF()->settings->ai, 'topic_suggestions_quality' ) ?: 'balanced';
7723 $show_related = wpfval( WPF()->settings->ai, 'topic_suggestions_show_related' );
7724 $show_answer = wpfval( WPF()->settings->ai, 'topic_suggestions_show_answer' );
7725 $max_similar = (int) ( wpfval( WPF()->settings->ai, 'topic_suggestions_max_similar' ) ?: 3 );
7726 $max_related = (int) ( wpfval( WPF()->settings->ai, 'topic_suggestions_max_related' ) ?: 3 );
7727 $similarity_threshold = (int) ( wpfval( WPF()->settings->ai, 'topic_suggestions_similarity' ) ?: 55 );
7728 $language = $this->get_user_language( null, 'topic_suggestions_language' );
7729
7730 // Check if using local storage mode
7731 $is_local_mode = WPF()->vector_storage && WPF()->vector_storage->is_local_mode();
7732
7733 // For local mode, use hybrid approach:
7734 // 1. Search local embeddings for similar topics
7735 // 2. Send results to cloud API for AI-generated related topics and quick answer
7736 if ( $is_local_mode ) {
7737 $result = $this->get_local_topic_suggestions(
7738 $title,
7739 $forumid,
7740 $max_similar,
7741 $similarity_threshold,
7742 $quality,
7743 (bool) $show_related,
7744 (bool) $show_answer,
7745 $max_related,
7746 $language
7747 );
7748 wp_send_json_success( $result );
7749 return;
7750 }
7751
7752 // Cloud mode: Use the suggestions API endpoint
7753 // Build API request payload
7754 // Note: include_similar is always true - similar topics are required for the feature to work
7755 $payload = [
7756 'title' => $title,
7757 'quality' => $quality,
7758 'include_similar' => true,
7759 'include_related' => (bool) $show_related,
7760 'include_answer' => (bool) $show_answer,
7761 'max_similar' => max( 1, $max_similar ), // Ensure at least 1 similar topic
7762 'max_related' => $max_related,
7763 'similarity_threshold' => $similarity_threshold / 100, // Convert percentage to decimal
7764 'language' => $language,
7765 ];
7766
7767 // Add forum context if available
7768 if ( $forumid ) {
7769 $forum = wpforo_forum( $forumid );
7770 if ( ! empty( $forum['title'] ) ) {
7771 $payload['forum_context'] = $forum['title'];
7772 }
7773 }
7774
7775 // Add forum access filtering (only show suggestions from forums user can access)
7776 // Returns null for admins or users with full access (no filtering needed)
7777 $accessible_forumids = $this->get_accessible_forumids();
7778 if ( $accessible_forumids !== null ) {
7779 $payload['accessible_forumids'] = $accessible_forumids;
7780 }
7781
7782 // Add custom knowledge parameters (Business+ plans)
7783 if ( $this->is_custom_knowledge_enabled() ) {
7784 $payload['include_custom_knowledge'] = true;
7785 $payload['knowledge_priority'] = [
7786 'search_priority' => $this->get_knowledge_priorities( 'search' ),
7787 'bot_reply_priority' => $this->get_knowledge_priorities( 'bot_reply' ),
7788 ];
7789 }
7790
7791 // Make API request to suggestions endpoint
7792 $response = $this->post( '/suggestions/suggest', $payload );
7793
7794 if ( is_wp_error( $response ) ) {
7795 wp_send_json_error( [
7796 'message' => $response->get_error_message()
7797 ], 500 );
7798 }
7799
7800 // Process and enrich results with topic URLs
7801 $result = [
7802 'similar_topics' => [],
7803 'related_topics' => [],
7804 'quick_answer' => null,
7805 'credits_used' => $response['credits_used'] ?? 0,
7806 ];
7807
7808 // Process similar topics
7809 if ( ! empty( $response['similar_topics'] ) && is_array( $response['similar_topics'] ) ) {
7810 foreach ( $response['similar_topics'] as $item ) {
7811 // Extract topic ID from content_id (format: topic_XX_post_YY or topic_XX_reply_YY)
7812 $content_id = $item['content_id'] ?? '';
7813 $topicid = 0;
7814 if ( preg_match( '/^topic_(\d+)_/', $content_id, $matches ) ) {
7815 $topicid = (int) $matches[1];
7816 }
7817
7818 if ( $topicid ) {
7819 $topic = wpforo_topic( $topicid );
7820 if ( ! empty( $topic ) ) {
7821 $result['similar_topics'][] = [
7822 'topicid' => $topicid,
7823 'title' => $topic['title'] ?? '',
7824 'url' => wpforo_topic( $topicid, 'url' ),
7825 'score' => round( ( $item['score'] ?? 0 ) * 100 ),
7826 'replies' => (int) ( $topic['posts'] ?? 0 ) - 1,
7827 'views' => (int) ( $topic['views'] ?? 0 ),
7828 'snippet' => $this->truncate_text( wp_strip_all_tags( $item['content'] ?? '' ), 150 ),
7829 'created' => $topic['created'] ?? '',
7830 ];
7831 }
7832 }
7833 }
7834 }
7835
7836 // Process related topics - these are selected from existing indexed content
7837 // They contain 'title', 'reason', and 'content_id' (format: topic_XX_post_YY or topic_XX_reply_YY)
7838 if ( ! empty( $response['related_topics'] ) && is_array( $response['related_topics'] ) ) {
7839 foreach ( $response['related_topics'] as $item ) {
7840 // Extract topic ID from content_id (same pattern as similar_topics)
7841 $content_id = $item['content_id'] ?? '';
7842 $topicid = 0;
7843 if ( preg_match( '/^topic_(\d+)_/', $content_id, $matches ) ) {
7844 $topicid = (int) $matches[1];
7845 }
7846
7847 $related_item = [
7848 'title' => $item['title'] ?? '',
7849 'reason' => $item['reason'] ?? '',
7850 ];
7851
7852 // Add URL if we have a valid topic ID
7853 if ( $topicid ) {
7854 $related_item['topicid'] = $topicid;
7855 $related_item['url'] = wpforo_topic( $topicid, 'url' );
7856 }
7857
7858 $result['related_topics'][] = $related_item;
7859 }
7860 }
7861
7862 // Process quick answer
7863 // Note: API returns 'ai_insight' as truncated string and 'ai_insight_full' as full text
7864 if ( ! empty( $response['ai_insight'] ) || ! empty( $response['ai_insight_full'] ) ) {
7865 // Use ai_insight_full if available, otherwise use ai_insight
7866 $answer_text = ! empty( $response['ai_insight_full'] )
7867 ? $response['ai_insight_full']
7868 : $response['ai_insight'];
7869
7870 $result['quick_answer'] = [
7871 'text' => wp_kses_post( $answer_text ),
7872 'sources' => [],
7873 'caveat' => wpforo_phrase( 'This is an AI-generated suggestion based on existing forum content. Please verify the information.', false ),
7874 ];
7875 }
7876
7877 // Deduplicate: Remove related topics that are already in similar topics
7878 if ( ! empty( $result['related_topics'] ) && ! empty( $result['similar_topics'] ) ) {
7879 $similar_topic_ids = array_column( $result['similar_topics'], 'topicid' );
7880 $result['related_topics'] = array_values( array_filter(
7881 $result['related_topics'],
7882 function( $related ) use ( $similar_topic_ids ) {
7883 return empty( $related['topicid'] ) || ! in_array( $related['topicid'], $similar_topic_ids, true );
7884 }
7885 ) );
7886 }
7887
7888 // Set has_suggestions flag - JS checks this to decide whether to show results or "no results" message
7889 $result['has_suggestions'] = ! empty( $result['similar_topics'] ) || ! empty( $result['related_topics'] ) || ! empty( $result['quick_answer'] );
7890
7891 // Return success response
7892 wp_send_json_success( $result );
7893 }
7894
7895 /**
7896 * Get topic suggestions using hybrid approach (local search + cloud AI)
7897 *
7898 * For local storage mode, we use hybrid approach:
7899 * 1. Search local embeddings for similar topics
7900 * 2. Send results to cloud API for AI-generated related topics and quick answer
7901 *
7902 * @param string $title Topic title to search for
7903 * @param int $forumid Optional forum ID filter
7904 * @param int $max_similar Maximum similar topics to return
7905 * @param int $similarity_threshold Minimum similarity percentage (0-100)
7906 * @param string $quality AI quality tier (fast, balanced, advanced, premium)
7907 * @param bool $show_related Include related topics from AI
7908 * @param bool $show_answer Include quick AI answer
7909 * @param int $max_related Maximum related topics to return
7910 * @return array Result with similar_topics, related_topics, quick_answer, credits_used
7911 */
7912 private function get_local_topic_suggestions(
7913 $title,
7914 $forumid = 0,
7915 $max_similar = 3,
7916 $similarity_threshold = 70,
7917 $quality = 'balanced',
7918 $show_related = true,
7919 $show_answer = true,
7920 $max_related = 3,
7921 $language = ''
7922 ) {
7923 $result = [
7924 'similar_topics' => [],
7925 'related_topics' => [],
7926 'quick_answer' => null,
7927 'credits_used' => 0,
7928 'storage_mode' => 'hybrid',
7929 ];
7930
7931 // Build filters
7932 $filters = [];
7933 if ( $forumid > 0 ) {
7934 $filters['forumid'] = $forumid;
7935 }
7936
7937 // Step 1: Search local embeddings
7938 // Fetch more results to have candidates for both similar and related topics
7939 $fetch_count = ( $max_similar + $max_related ) * 2;
7940 $search_results = WPF()->vector_storage->semantic_search( $title, $fetch_count, $filters );
7941
7942 if ( is_wp_error( $search_results ) ) {
7943 \wpforo_ai_log( 'error', 'Local topic suggestions error: ' . $search_results->get_error_message(), 'Client' );
7944 $result['has_suggestions'] = false;
7945 return $result;
7946 }
7947
7948 // Convert similarity threshold from percentage to decimal
7949 // Local cosine similarities are on a different scale (5-25%) than cloud scores (30-90%),
7950 // so apply 1/3 of the configured threshold for local mode with minimum of 15%
7951 $threshold_decimal = max( 0.15, ( $similarity_threshold / 100 ) / 3 );
7952 $related_threshold = max( 0.10, $threshold_decimal - 0.05 ); // 5% lower for related topics
7953
7954 // Group by topic (take best match per topic)
7955 $by_topic = [];
7956 $results_array = $search_results['results'] ?? [];
7957
7958 foreach ( $results_array as $item ) {
7959 $topicid = (int) ( $item['topic_id'] ?? 0 );
7960 if ( $topicid <= 0 ) {
7961 continue;
7962 }
7963
7964 $similarity = (float) ( $item['similarity'] ?? $item['score'] ?? 0 );
7965
7966 // Skip if below the lower threshold (for related topics)
7967 if ( $similarity < $related_threshold ) {
7968 continue;
7969 }
7970
7971 // Keep only best match per topic
7972 if ( ! isset( $by_topic[ $topicid ] ) || $similarity > $by_topic[ $topicid ]['similarity'] ) {
7973 $by_topic[ $topicid ] = [
7974 'topicid' => $topicid,
7975 'similarity' => $similarity,
7976 'snippet' => $item['snippet'] ?? $item['content_preview'] ?? $item['content'] ?? '',
7977 ];
7978 }
7979 }
7980
7981 // Sort by similarity
7982 $all_topics = array_values( $by_topic );
7983 usort( $all_topics, function( $a, $b ) {
7984 return $b['similarity'] <=> $a['similarity'];
7985 } );
7986
7987 // Enrich with full topic data and prepare for API
7988 $similar_topics_for_api = [];
7989 $similar_count = 0;
7990
7991 foreach ( $all_topics as $item ) {
7992 $topic = wpforo_topic( $item['topicid'] );
7993 if ( empty( $topic ) ) {
7994 continue;
7995 }
7996
7997 $topic_url = wpforo_topic( $item['topicid'], 'url' );
7998 $snippet = $this->truncate_text( wp_strip_all_tags( $item['snippet'] ), 500 );
7999
8000 // Build topic data for API (format matches SimilarTopicInput model)
8001 $topic_data = [
8002 'content_id' => 'topic_' . $item['topicid'],
8003 'title' => $topic['title'] ?? '',
8004 'url' => $topic_url,
8005 'content' => $snippet,
8006 'score' => $item['similarity'], // Keep as decimal for API
8007 ];
8008
8009 // Add first_post and replies for AI to generate meaningful insights
8010 // Only include this data for topics that will be used for AI features
8011 if ( $show_related || $show_answer ) {
8012 $topic_author_id = (int) ( $topic['userid'] ?? 0 );
8013
8014 // Get first post content
8015 $first_postid = $topic['first_postid'] ?? 0;
8016 if ( $first_postid ) {
8017 $first_post = wpforo_post( $first_postid );
8018 if ( $first_post ) {
8019 $first_post_content = $this->truncate_text( wp_strip_all_tags( $first_post['body'] ?? '' ), 500 );
8020 $topic_data['first_post'] = [
8021 'content' => $first_post_content,
8022 'author' => $this->get_user_display_name( $first_post['userid'] ?? 0 ),
8023 ];
8024 }
8025 }
8026
8027 // Get replies (exclude topic author, sort by is_answer + likes)
8028 $replies_args = [
8029 'topicid' => $item['topicid'],
8030 'is_first_post' => 0,
8031 'status' => 0, // Only approved
8032 'orderby' => 'is_answer DESC, likes DESC, created DESC',
8033 'row_count' => 15, // Fetch more to filter
8034 ];
8035 $topic_replies = WPF()->post->get_posts( $replies_args );
8036
8037 if ( ! empty( $topic_replies ) && is_array( $topic_replies ) ) {
8038 $formatted_replies = [];
8039 foreach ( $topic_replies as $reply ) {
8040 // Skip topic author's replies
8041 if ( (int) ( $reply['userid'] ?? 0 ) === $topic_author_id ) {
8042 continue;
8043 }
8044
8045 // Skip very short replies (less than 50 chars)
8046 $reply_content = wp_strip_all_tags( $reply['body'] ?? '' );
8047 if ( mb_strlen( $reply_content ) < 50 ) {
8048 continue;
8049 }
8050
8051 $formatted_replies[] = [
8052 'content' => $this->truncate_text( $reply_content, 500 ),
8053 'author' => $this->get_user_display_name( $reply['userid'] ?? 0 ),
8054 'is_answer' => ! empty( $reply['is_answer'] ),
8055 'likes' => (int) ( $reply['likes'] ?? 0 ),
8056 'votes' => 0, // wpForo doesn't have votes, only likes
8057 ];
8058
8059 // Limit to 10 best replies
8060 if ( count( $formatted_replies ) >= 10 ) {
8061 break;
8062 }
8063 }
8064
8065 if ( ! empty( $formatted_replies ) ) {
8066 $topic_data['replies'] = $formatted_replies;
8067 }
8068 }
8069 }
8070
8071 $similar_topics_for_api[] = $topic_data;
8072
8073 // Also build result format for similar topics (above threshold)
8074 if ( $item['similarity'] >= $threshold_decimal && $similar_count < $max_similar ) {
8075 $result['similar_topics'][] = [
8076 'topicid' => $item['topicid'],
8077 'title' => $topic['title'] ?? '',
8078 'url' => $topic_url,
8079 'score' => round( $item['similarity'] * 100 ),
8080 'replies' => (int) ( $topic['posts'] ?? 0 ) - 1,
8081 'views' => (int) ( $topic['views'] ?? 0 ),
8082 'snippet' => $this->truncate_text( $snippet, 150 ),
8083 'created' => $topic['created'] ?? '',
8084 ];
8085 $similar_count++;
8086 }
8087 }
8088
8089 // Step 2: If we have topics and need AI features, call cloud API
8090 if ( ! empty( $similar_topics_for_api ) && ( $show_related || $show_answer ) ) {
8091 // Collect similar topic IDs to exclude from related topics (prevents duplicates)
8092 $exclude_topic_ids = array_filter( array_column( $result['similar_topics'], 'topicid' ) );
8093
8094 // Build API request payload for hybrid mode
8095 $payload = [
8096 'title' => $title,
8097 'quality' => $quality,
8098 'include_similar' => false, // We already have similar topics from local search
8099 'include_related' => (bool) $show_related,
8100 'include_answer' => (bool) $show_answer,
8101 'max_similar' => $max_similar,
8102 'max_related' => $max_related,
8103 'similarity_threshold' => $similarity_threshold / 100, // Convert to decimal
8104 'similar_topics_input' => array_slice( $similar_topics_for_api, 0, 20 ), // Max 20 topics
8105 'exclude_topic_ids' => array_values( $exclude_topic_ids ), // Exclude similar topics from related
8106 'language' => $language,
8107 ];
8108
8109 // Make API request to suggestions endpoint
8110 $response = $this->post( '/suggestions/suggest', $payload );
8111
8112 if ( ! is_wp_error( $response ) ) {
8113 // Get related topics from cloud AI response
8114 if ( $show_related && ! empty( $response['related_topics'] ) ) {
8115 foreach ( $response['related_topics'] as $related ) {
8116 $result['related_topics'][] = [
8117 'title' => $related['title'] ?? '',
8118 'url' => $related['url'] ?? '',
8119 'reason' => $related['reason'] ?? '',
8120 ];
8121 }
8122 }
8123
8124 // Get quick answer from cloud AI response
8125 // Note: API returns 'ai_insight' as truncated string and 'ai_insight_full' as full text
8126 if ( $show_answer && ( ! empty( $response['ai_insight'] ) || ! empty( $response['ai_insight_full'] ) ) ) {
8127 // Use ai_insight_full if available, otherwise use ai_insight
8128 $answer_text = ! empty( $response['ai_insight_full'] )
8129 ? $response['ai_insight_full']
8130 : $response['ai_insight'];
8131
8132 $result['quick_answer'] = [
8133 'text' => wp_kses_post( $answer_text ),
8134 'sources' => [],
8135 'caveat' => wpforo_phrase( 'This is an AI-generated suggestion based on existing forum content. Please verify the information.', false ),
8136 ];
8137 }
8138
8139 // Add credits used by cloud AI
8140 $result['credits_used'] = $response['credits_used'] ?? 0;
8141 } else {
8142 // Log error but don't fail - we still have similar topics
8143 \wpforo_ai_log( 'error', 'Hybrid mode cloud API error: ' . $response->get_error_message(), 'Client' );
8144 }
8145 }
8146
8147 // Deduplicate: Remove related topics that are already in similar topics
8148 // In hybrid mode, related_topics only have title/url/reason, so we match by URL
8149 if ( ! empty( $result['related_topics'] ) && ! empty( $result['similar_topics'] ) ) {
8150 $similar_topic_urls = array_filter( array_column( $result['similar_topics'], 'url' ) );
8151
8152 $result['related_topics'] = array_values( array_filter(
8153 $result['related_topics'],
8154 function( $related ) use ( $similar_topic_urls ) {
8155 // Check by URL (the only common identifier in hybrid mode)
8156 return empty( $related['url'] ) || ! in_array( $related['url'], $similar_topic_urls, true );
8157 }
8158 ) );
8159 }
8160
8161 $result['has_suggestions'] = ! empty( $result['similar_topics'] ) ||
8162 ! empty( $result['related_topics'] ) ||
8163 ! empty( $result['quick_answer'] );
8164
8165 return $result;
8166 }
8167
8168 /**
8169 * Truncate text to specified length
8170 *
8171 * @param string $text Text to truncate
8172 * @param int $length Maximum length
8173 * @param string $suffix Suffix to append if truncated
8174 * @return string Truncated text
8175 */
8176 private function truncate_text( $text, $length = 100, $suffix = '...' ) {
8177 $text = trim( $text );
8178 if ( mb_strlen( $text ) <= $length ) {
8179 return $text;
8180 }
8181 return mb_substr( $text, 0, $length ) . $suffix;
8182 }
8183
8184 /**
8185 * Filter to disable built-in wpForo topic suggestions when AI Topic Suggestions is enabled
8186 *
8187 * When AI Topic Suggestions is enabled in settings, the built-in wpForo suggested topics
8188 * feature (which uses basic keyword matching) should be disabled to avoid duplication.
8189 *
8190 * @param bool $enabled Whether built-in suggestions are enabled
8191 * @return bool False if AI Topic Suggestions is enabled, otherwise unchanged
8192 */
8193 public function filter_built_in_suggestions( $enabled ) {
8194 // If AI Topic Suggestions is enabled, we have an API key, and user has permission, disable built-in suggestions
8195 if ( wpfval( WPF()->settings->ai, 'topic_suggestions' ) && $this->get_api_key() && WPF()->usergroup->can( 'ai_suggestion' ) ) {
8196 return false;
8197 }
8198 return $enabled;
8199 }
8200
8201 /**
8202 * Add AI suggestions panel as an HTML field right after the title field
8203 *
8204 * Uses the wpforo_form_fields filter to insert an HTML-type field containing
8205 * the AI suggestions panel directly after the topic title field.
8206 *
8207 * @param array $fields 3D array of form fields [row][col][field]
8208 * @return array Modified fields array
8209 */
8210 public function add_ai_suggestions_after_title( $fields ) {
8211 // Check if topic suggestions feature is enabled
8212 if ( ! wpfval( WPF()->settings->ai, 'topic_suggestions' ) ) {
8213 return $fields;
8214 }
8215
8216 // Check if AI service is available (connected + active/trial subscription)
8217 if ( ! $this->is_service_available() ) {
8218 return $fields;
8219 }
8220
8221 // Check usergroup permission
8222 if ( ! WPF()->usergroup->can( 'ai_suggestion' ) ) {
8223 return $fields;
8224 }
8225
8226 // Skip if editing existing topic (only show on new topic creation)
8227 // When editing, the title field has a pre-filled value from the existing topic
8228 foreach ( $fields as $row ) {
8229 foreach ( $row as $cols ) {
8230 foreach ( $cols as $field_key => $field ) {
8231 if ( $field_key === 'title' || ( is_array( $field ) && wpfval( $field, 'fieldKey' ) === 'title' ) ) {
8232 // If title field has a value, we're in edit mode
8233 if ( is_array( $field ) && ! empty( $field['value'] ) ) {
8234 return $fields;
8235 }
8236 }
8237 }
8238 }
8239 }
8240
8241 // Get the suggestions panel HTML
8242 $panel_html = $this->get_ai_suggestions_panel_html();
8243 if ( empty( $panel_html ) ) {
8244 return $fields;
8245 }
8246
8247 // Create the HTML field for the suggestions panel
8248 $suggestions_field = [
8249 'fieldKey' => 'ai_suggestions',
8250 'name' => 'ai_suggestions',
8251 'type' => 'html',
8252 'isDefault' => 1,
8253 'isRemovable' => 0,
8254 'isRequired' => 0,
8255 'label' => '',
8256 'html' => $panel_html,
8257 ];
8258
8259 // Find title field and insert suggestions field after it
8260 $new_fields = [];
8261 foreach ( $fields as $row_key => $row ) {
8262 $new_row = [];
8263 foreach ( $row as $col_key => $cols ) {
8264 $new_col = [];
8265 foreach ( $cols as $field_key => $field ) {
8266 $new_col[ $field_key ] = $field;
8267 // Insert suggestions field right after title field
8268 if ( $field_key === 'title' || ( is_array( $field ) && wpfval( $field, 'fieldKey' ) === 'title' ) ) {
8269 $new_col['ai_suggestions'] = $suggestions_field;
8270 }
8271 }
8272 $new_row[ $col_key ] = $new_col;
8273 }
8274 $new_fields[ $row_key ] = $new_row;
8275 }
8276
8277 return $new_fields;
8278 }
8279
8280 /**
8281 * Get the AI suggestions panel HTML
8282 *
8283 * Returns the HTML for the collapsible AI suggestions panel that displays
8284 * similar topics, related topics, and quick AI answers.
8285 *
8286 * @return string Panel HTML
8287 */
8288 private function get_ai_suggestions_panel_html() {
8289 // Get settings for suggestion config
8290 // Note: show_similar is not included - similar topics are always required for the feature
8291 $config = [
8292 'enabled' => true,
8293 'quality' => wpfval( WPF()->settings->ai, 'topic_suggestions_quality' ) ?: 'balanced',
8294 'min_words' => (int) ( wpfval( WPF()->settings->ai, 'topic_suggestions_min_words' ) ?: 3 ),
8295 'max_calls' => (int) ( wpfval( WPF()->settings->ai, 'topic_suggestions_max_calls' ) ?: 2 ),
8296 'show_related' => (bool) wpfval( WPF()->settings->ai, 'topic_suggestions_show_related' ),
8297 'show_answer' => (bool) wpfval( WPF()->settings->ai, 'topic_suggestions_show_answer' ),
8298 ];
8299
8300 ob_start();
8301 ?>
8302 <div class="wpf-ai-suggestions-panel" data-suggestion-config="<?php echo esc_attr( wp_json_encode( $config ) ); ?>">
8303 <div class="wpf-ai-suggestions-header">
8304 <div class="wpf-ai-suggestions-header-left">
8305 <span class="wpf-ai-suggestions-title"><?php wpforo_phrase( 'AI Topic Suggestions' ); ?></span>
8306 </div>
8307 <span class="wpf-ai-suggestions-close" title="<?php echo esc_attr( wpforo_phrase( 'Close', false ) ); ?>">&times;</span>
8308 </div>
8309 <div class="wpf-ai-suggestions-content">
8310 <!-- Content will be populated by JavaScript -->
8311 </div>
8312 </div>
8313 <?php
8314 return ob_get_clean();
8315 }
8316
8317 // =========================================================================
8318 // CONTENT CLEANING METHODS FOR INDEXING
8319 // =========================================================================
8320
8321 /**
8322 * Strip quoted content from post body before indexing
8323 *
8324 * Forum posts often contain quoted replies. When indexing, we should remove
8325 * quoted content because:
8326 * 1. The original content is already indexed from the original post
8327 * 2. Including quotes would create duplicate/redundant content in the index
8328 * 3. Search results would be skewed by over-representing quoted content
8329 *
8330 * Patterns removed:
8331 * - [quote data-userid="1" data-postid="488"]...content...[/quote]
8332 * - [quote ...]...content...[/quote] (any attributes)
8333 * - <blockquote class="..." data-...>...content...</blockquote> (with attributes)
8334 *
8335 * Patterns kept (user-written content, not quotes of other posts):
8336 * - <blockquote>...content...</blockquote> (clean tag without attributes)
8337 *
8338 * @param string $content Post content (HTML)
8339 * @return string Content with quoted sections removed
8340 */
8341 public function strip_quoted_content( $content ) {
8342 if ( empty( $content ) ) {
8343 return $content;
8344 }
8345
8346 // Pattern 1: Remove [quote ...attributes...] shortcodes
8347 // Matches [quote data-userid="1" data-postid="488"]...[/quote]
8348 // and [quote anything...]...[/quote]
8349 // Uses DOTALL flag (s) to match across newlines
8350 $content = preg_replace(
8351 '/\[quote\s+[^\]]+\].*?\[\/quote\]/is',
8352 '',
8353 $content
8354 );
8355
8356 // Pattern 2: Remove <blockquote> tags that have ANY attributes
8357 // This indicates a quoted post (wpForo adds data-* attributes)
8358 // Matches <blockquote class="..." data-userid="...">...</blockquote>
8359 // But NOT <blockquote>...</blockquote> (clean tag = user content)
8360 $content = preg_replace(
8361 '/<blockquote\s+[^>]+>.*?<\/blockquote>/is',
8362 '',
8363 $content
8364 );
8365
8366 // Clean up any resulting multiple blank lines
8367 $content = preg_replace( '/(\r?\n){3,}/', "\n\n", $content );
8368
8369 return trim( $content );
8370 }
8371
8372 /**
8373 * Clean post content for indexing
8374 *
8375 * Applies all content cleaning transformations:
8376 * - Strip quoted content (duplicates from other posts)
8377 * - Future: other content cleaning rules
8378 *
8379 * @param string $content Post content (HTML)
8380 * @return string Cleaned content ready for indexing
8381 */
8382 public function clean_content_for_indexing( $content ) {
8383 // Strip quoted content first
8384 $content = $this->strip_quoted_content( $content );
8385
8386 // Future: Add other cleaning rules here
8387
8388 return $content;
8389 }
8390
8391 /**
8392 * Clean content for search result display
8393 *
8394 * Strips HTML tags, shortcodes, and Lambda processing markers
8395 * (image descriptions, document content blocks) from search result excerpts.
8396 * These markers are useful for embeddings but should not be shown to users.
8397 *
8398 * @param string $content Raw content from search result (cloud excerpt or local preview)
8399 * @return string Cleaned content for display
8400 */
8401 public function clean_content_for_search_display( $content ) {
8402 // Strip HTML tags
8403 $content = wp_strip_all_tags( $content );
8404
8405 // Remove [TOPIC] or [TOPIC: title] prefix (cloud format)
8406 $content = preg_replace( '/^\[TOPIC[^\]]*\]\s*/i', '', $content );
8407 // Remove "Topic: Title\n\n" prefix (local format)
8408 $content = preg_replace( '/^Topic:\s*[^\n]*\n+/i', '', $content );
8409
8410 // Count image and document markers BEFORE stripping (for attachment summary)
8411 $image_count = preg_match_all( '/\[IMAGE:\s*[^\]]*\]/', $content );
8412 $doc_matches = [];
8413 preg_match_all( '/\[DOCUMENT:\s*([^\]]*)\]/', $content, $doc_matches );
8414 $doc_count = count( $doc_matches[0] );
8415 // Extract page counts from document markers like [DOCUMENT: filename.pdf (5 pages)]
8416 $total_pages = 0;
8417 if ( $doc_count > 0 ) {
8418 foreach ( $doc_matches[1] as $doc_info ) {
8419 if ( preg_match( '/\((\d+)\s+pages?\)/', $doc_info, $page_match ) ) {
8420 $total_pages += (int) $page_match[1];
8421 }
8422 }
8423 }
8424
8425 // Strip enrichment tags added for embedding quality: [FORUM: name], [SOLVED], [BEST ANSWER]
8426 $content = preg_replace( '/\[(?:FORUM|SOLVED|BEST ANSWER)[^\]]*\]/', '', $content );
8427 // Strip wpForo shortcodes: [attach]N[/attach], [attach]N,M[/attach]
8428 $content = preg_replace( '/\[attach\]\d+(?:,\d+)?\[\/attach\]/', '', $content );
8429 // Strip any remaining shortcode-like patterns: [something]...[/something] or [something]
8430 $content = preg_replace( '/\[(?:\/)?[a-zA-Z0-9_-]+(?:\s[^\]]*?)?\]/', '', $content );
8431
8432 // Strip Lambda image processing markers
8433 $content = preg_replace( '/---\s*Image\s+Content\s*---/', '', $content );
8434 $content = preg_replace( '/\[IMAGE:\s*[^\]]*\]/', '', $content );
8435
8436 // Strip Lambda document processing markers
8437 $content = preg_replace( '/---\s*Document\s+Content\s*---/', '', $content );
8438 $content = preg_replace( '/\[DOCUMENT:\s*[^\]]*\]/', '', $content );
8439 $content = preg_replace( '/\[\/DOCUMENT\]/', '', $content );
8440 $content = preg_replace( '/\[DOC_IMAGE:\s*[^\]]*\]/', '', $content );
8441
8442 // Normalize whitespace
8443 $content = preg_replace( '/\s+/', ' ', $content );
8444 $content = trim( $content );
8445
8446 // Append attachment summary (same format as local mode build_content_preview)
8447 $attachments = [];
8448 if ( $doc_count > 0 ) {
8449 if ( $total_pages > 0 ) {
8450 $attachments[] = sprintf( '%d %s, %d %s',
8451 $doc_count,
8452 $doc_count === 1 ? 'document' : 'documents',
8453 $total_pages,
8454 $total_pages === 1 ? 'page' : 'pages'
8455 );
8456 } else {
8457 $attachments[] = sprintf( '%d %s',
8458 $doc_count,
8459 $doc_count === 1 ? 'document' : 'documents'
8460 );
8461 }
8462 }
8463 if ( $image_count > 0 ) {
8464 $attachments[] = sprintf( '%d %s',
8465 $image_count,
8466 $image_count === 1 ? 'image' : 'images'
8467 );
8468 }
8469 if ( ! empty( $attachments ) ) {
8470 $content .= ' [+ ' . implode( ', ', $attachments ) . ']';
8471 }
8472
8473 return $content;
8474 }
8475
8476 // =========================================================================
8477 // AI BOT REPLY METHODS
8478 // =========================================================================
8479
8480 /**
8481 * Render Bot Reply button in post action buttons
8482 *
8483 * Displays an AI bot icon button before the quote button that allows
8484 * moderators to generate AI-powered replies to posts.
8485 *
8486 * @param array|string $button_html Current button HTML (may be array or string)
8487 * @param string $button Button type
8488 * @param array $forum Forum data
8489 * @param array $topic Topic data
8490 * @param array $post Post data
8491 * @return array Modified button HTML array
8492 */
8493 public function render_bot_reply_button( $button_html, $button, $forum, $topic, $post ) {
8494 // Ensure $button_html is an array
8495 if ( ! is_array( $button_html ) ) {
8496 $button_html = $button_html ? [ $button_html ] : [];
8497 }
8498
8499 // Check if Bot Reply feature is enabled in settings
8500 if ( ! wpfval( WPF()->settings->ai, 'bot_reply' ) ) {
8501 return $button_html;
8502 }
8503
8504 // Check if feature is available for this plan (wpforo AI specific)
8505 if ( ! $this->is_feature_available( 'ai_bot_reply' ) ) {
8506 return $button_html;
8507 }
8508
8509 // Get IDs
8510 $forumid = (int) ( wpfval( $forum, 'forumid' ) ?: wpfval( $topic, 'forumid' ) );
8511 $topicid = (int) wpfval( $topic, 'topicid' );
8512 $postid = (int) wpfval( $post, 'postid' );
8513 $is_closed = (int) wpfval( $topic, 'closed' );
8514 $is_approve = (int) wpfval( $post, 'status' );
8515
8516 // Skip if topic closed, post unapproved, or missing IDs (same as wpforo-aibot)
8517 if ( $is_closed || $is_approve || ! $postid ) {
8518 return $button_html;
8519 }
8520
8521 // Permission check: Can reply OR (is owner AND can reply to own)
8522 // Plus: Must have 'au' (approve/unapprove) permission (moderator/admin only)
8523 $can_reply = WPF()->perm->forum_can( 'cr', $forumid );
8524 $is_owner = wpforo_is_owner( wpforo_bigintval( wpfval( $topic, 'userid' ) ), (string) wpfval( $topic, 'email' ) );
8525 $can_own_reply = $is_owner && WPF()->perm->forum_can( 'ocr', $forumid );
8526
8527 if ( $can_reply || $can_own_reply ) {
8528 if ( WPF()->perm->forum_can( 'au', $forumid ) ) {
8529 $layout = WPF()->forum->get_layout( $forumid );
8530 $layout_class = 'wpforo_layout_' . $layout;
8531
8532 // Build the Bot Reply button HTML (matching wpforo-aibot structure exactly)
8533 $button_html[] = '<span id="parentpostid' . wpforo_bigintval( $postid ) . '" class="wpf-bot-reply wpf-ai-bot-reply wpf-action ' . $layout_class . '" title="' . esc_attr( wpforo_phrase( 'Ask AI Bot to reply', false ) ) . '" data-postid="' . wpforo_bigintval( $postid ) . '" data-topicid="' . wpforo_bigintval( $topicid ) . '"><i class="fas fa-robot"></i><span class="wpf-button-text">' . wpforo_phrase( 'Bot Reply', false ) . '</span></span>';
8534 }
8535 }
8536
8537 return $button_html;
8538 }
8539
8540 /**
8541 * Render Suggest Reply button in reply form
8542 *
8543 * Displays a "Suggest Reply" button before the "Add Reply" submit button
8544 * that loads AI-generated content into the TinyMCE editor.
8545 *
8546 * @param array $topic Topic data
8547 * @param array $values Form values (empty for new reply, populated for edit)
8548 * @param array $forum Forum data
8549 * @return void
8550 */
8551 public function render_suggest_reply_button( $topic, $values, $forum ) {
8552 // Skip if editing (values is not empty means edit mode)
8553 if ( ! empty( $values ) && wpfval( $values, 'postid' ) ) {
8554 return;
8555 }
8556
8557 // Check if Bot Reply feature is enabled
8558 if ( ! wpfval( WPF()->settings->ai, 'bot_reply' ) ) {
8559 return;
8560 }
8561
8562 // Check if feature is available for this plan
8563 if ( ! $this->is_feature_available( 'ai_bot_reply' ) ) {
8564 return;
8565 }
8566
8567 // Check if topic is closed
8568 if ( ! empty( $topic['closed'] ) ) {
8569 return;
8570 }
8571
8572 // Check if user has 'au' permission for this forum
8573 $forumid = wpfval( $forum, 'forumid' ) ?: wpfval( $topic, 'forumid' );
8574 if ( ! $forumid || ! WPF()->perm->forum_can( 'au', $forumid ) ) {
8575 return;
8576 }
8577
8578 $topic_id = (int) wpfval( $topic, 'topicid' );
8579 if ( ! $topic_id ) {
8580 return;
8581 }
8582
8583 // Render the Suggest Reply button
8584 ?>
8585 <button type="button"
8586 class="wpf-button wpf-button-secondary wpf-ai-suggest-reply"
8587 data-topicid="<?php echo esc_attr( $topic_id ); ?>"
8588 title="<?php echo esc_attr( wpforo_phrase( 'Generate AI reply suggestion', false ) ); ?>">
8589 <i class="fas fa-circle-notch fa-spin wpf-ai-spinner"></i>
8590 <svg class="wpf-ai-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.912 5.813a2 2 0 0 0 1.275 1.275L21 12l-5.813 1.912a2 2 0 0 0-1.275 1.275L12 21l-1.912-5.813a2 2 0 0 0-1.275-1.275L3 12l5.813-1.912a2 2 0 0 0 1.275-1.275L12 3z"/></svg>
8591 <span><?php echo esc_html( wpforo_phrase( 'Suggest Reply', false ) ); ?></span>
8592 </button>
8593 <?php
8594 }
8595
8596 /**
8597 * AJAX handler for Bot Reply
8598 *
8599 * Generates an AI reply and creates a new post from the bot user.
8600 *
8601 * @return void
8602 */
8603 public function ajax_bot_reply() {
8604 // Track start time for logging
8605 $_log_start_time = microtime( true );
8606
8607 // Verify nonce
8608 if ( ! wp_verify_nonce( sanitize_text_field( wpfval( $_POST, '_wpnonce' ) ), 'wpforo_ai_bot_reply' ) ) {
8609 wp_send_json_error( [ 'message' => wpforo_phrase( 'Security check failed', false ) ], 403 );
8610 }
8611
8612 // Check if feature is enabled and available
8613 if ( ! wpfval( WPF()->settings->ai, 'bot_reply' ) || ! $this->is_feature_available( 'ai_bot_reply' ) ) {
8614 wp_send_json_error( [ 'message' => wpforo_phrase( 'AI Bot Reply feature is not available', false ) ], 403 );
8615 }
8616
8617 // Get parameters
8618 $post_id = (int) wpfval( $_POST, 'post_id' );
8619 $topic_id = (int) wpfval( $_POST, 'topic_id' );
8620
8621 if ( ! $post_id || ! $topic_id ) {
8622 wp_send_json_error( [ 'message' => wpforo_phrase( 'Invalid request parameters', false ) ], 400 );
8623 }
8624
8625 // Get topic and post data
8626 $topic = WPF()->topic->get_topic( $topic_id );
8627 if ( ! $topic ) {
8628 wp_send_json_error( [ 'message' => wpforo_phrase( 'Topic not found', false ) ], 404 );
8629 }
8630
8631 // Check permission
8632 $forumid = (int) $topic['forumid'];
8633 if ( ! WPF()->perm->forum_can( 'au', $forumid ) ) {
8634 wp_send_json_error( [ 'message' => wpforo_phrase( 'Permission denied', false ) ], 403 );
8635 }
8636
8637 // Check if topic is closed
8638 if ( ! empty( $topic['closed'] ) ) {
8639 wp_send_json_error( [ 'message' => wpforo_phrase( 'Topic is closed', false ) ], 403 );
8640 }
8641
8642 // Check rate limits
8643 $limit_error = $this->check_bot_reply_limits( $topic_id );
8644 if ( $limit_error ) {
8645 wp_send_json_error( [ 'message' => $limit_error ], 429 );
8646 }
8647
8648 // Get the post being replied to
8649 $parent_post = WPF()->post->get_post( $post_id );
8650 if ( ! $parent_post ) {
8651 wp_send_json_error( [ 'message' => wpforo_phrase( 'Post not found', false ) ], 404 );
8652 }
8653
8654 // Get first post for topic context (if replying to a reply, not the first post)
8655 $first_post = null;
8656 if ( (int) $parent_post['is_first_post'] !== 1 ) {
8657 $first_post = WPF()->post->get_post( $topic['first_postid'] );
8658 }
8659
8660 // Generate AI reply
8661 $result = $this->generate_bot_reply( $topic, $parent_post, $first_post );
8662 if ( is_wp_error( $result ) ) {
8663 // Log error
8664 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
8665 WPF()->ai_logs->log( [
8666 'action_type' => AILogs::ACTION_BOT_REPLY,
8667 'credits_used' => 0,
8668 'status' => AILogs::STATUS_ERROR,
8669 'content_type' => 'topic',
8670 'content_id' => $topic_id,
8671 'topicid' => $topic_id,
8672 'forumid' => $forumid,
8673 'request_summary' => sprintf( 'Bot reply to topic: %s', wp_trim_words( $topic['title'], 10 ) ),
8674 'error_message' => $result->get_error_message(),
8675 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
8676 ] );
8677 }
8678 wp_send_json_error( [ 'message' => $result->get_error_message() ], 500 );
8679 }
8680
8681 // Create the bot reply post
8682 $new_post_id = $this->create_bot_reply_post( $topic, $parent_post, $result['reply'] );
8683 if ( is_wp_error( $new_post_id ) ) {
8684 // Log error
8685 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
8686 WPF()->ai_logs->log( [
8687 'action_type' => AILogs::ACTION_BOT_REPLY,
8688 'credits_used' => $result['credits_used'] ?? 0,
8689 'status' => AILogs::STATUS_ERROR,
8690 'content_type' => 'topic',
8691 'content_id' => $topic_id,
8692 'topicid' => $topic_id,
8693 'forumid' => $forumid,
8694 'request_summary' => sprintf( 'Bot reply to topic: %s', wp_trim_words( $topic['title'], 10 ) ),
8695 'error_message' => $new_post_id->get_error_message(),
8696 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
8697 ] );
8698 }
8699 wp_send_json_error( [ 'message' => $new_post_id->get_error_message() ], 500 );
8700 }
8701
8702 $credits_used = $result['credits_used'] ?? 0;
8703
8704 // Log success
8705 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
8706 WPF()->ai_logs->log( [
8707 'action_type' => AILogs::ACTION_BOT_REPLY,
8708 'credits_used' => $credits_used,
8709 'status' => AILogs::STATUS_SUCCESS,
8710 'content_type' => 'topic',
8711 'content_id' => $topic_id,
8712 'topicid' => $topic_id,
8713 'forumid' => $forumid,
8714 'request_summary' => sprintf( 'Bot reply to topic: %s', wp_trim_words( $topic['title'], 10 ) ),
8715 'response_summary' => sprintf( 'Created post #%d', $new_post_id ),
8716 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
8717 ] );
8718 }
8719
8720 wp_send_json_success( [
8721 'post_id' => $new_post_id,
8722 'credits_used' => $credits_used,
8723 'message' => wpforo_phrase( 'Bot reply created successfully', false ),
8724 ] );
8725 }
8726
8727 /**
8728 * AJAX handler for Suggest Reply
8729 *
8730 * Generates an AI reply suggestion and returns it for insertion into the editor.
8731 *
8732 * @return void
8733 */
8734 public function ajax_suggest_reply() {
8735 // Track start time for logging
8736 $_log_start_time = microtime( true );
8737
8738 // Verify nonce
8739 if ( ! wp_verify_nonce( sanitize_text_field( wpfval( $_POST, '_wpnonce' ) ), 'wpforo_ai_suggest_reply' ) ) {
8740 wp_send_json_error( [ 'message' => wpforo_phrase( 'Security check failed', false ) ], 403 );
8741 }
8742
8743 // Check if feature is enabled and available
8744 if ( ! wpfval( WPF()->settings->ai, 'bot_reply' ) || ! $this->is_feature_available( 'ai_bot_reply' ) ) {
8745 wp_send_json_error( [ 'message' => wpforo_phrase( 'AI Bot Reply feature is not available', false ) ], 403 );
8746 }
8747
8748 // Get parameters
8749 $topic_id = (int) wpfval( $_POST, 'topic_id' );
8750 $parent_id = (int) wpfval( $_POST, 'parent_id' ); // Optional: if replying to specific post
8751
8752 if ( ! $topic_id ) {
8753 wp_send_json_error( [ 'message' => wpforo_phrase( 'Invalid request parameters', false ) ], 400 );
8754 }
8755
8756 // Get topic data
8757 $topic = WPF()->topic->get_topic( $topic_id );
8758 if ( ! $topic ) {
8759 wp_send_json_error( [ 'message' => wpforo_phrase( 'Topic not found', false ) ], 404 );
8760 }
8761
8762 // Check permission
8763 $forumid = (int) $topic['forumid'];
8764 if ( ! WPF()->perm->forum_can( 'au', $forumid ) ) {
8765 wp_send_json_error( [ 'message' => wpforo_phrase( 'Permission denied', false ) ], 403 );
8766 }
8767
8768 // Get the post being replied to (default to first post if no parent specified)
8769 $parent_post = null;
8770 if ( $parent_id ) {
8771 $parent_post = WPF()->post->get_post( $parent_id );
8772 }
8773 if ( ! $parent_post ) {
8774 $parent_post = WPF()->post->get_post( $topic['first_postid'] );
8775 }
8776
8777 if ( ! $parent_post ) {
8778 wp_send_json_error( [ 'message' => wpforo_phrase( 'Post not found', false ) ], 404 );
8779 }
8780
8781 // Get first post for topic context (if replying to a reply)
8782 $first_post = null;
8783 if ( (int) $parent_post['is_first_post'] !== 1 ) {
8784 $first_post = WPF()->post->get_post( $topic['first_postid'] );
8785 }
8786
8787 // Generate AI reply
8788 $result = $this->generate_bot_reply( $topic, $parent_post, $first_post );
8789 if ( is_wp_error( $result ) ) {
8790 // Log error
8791 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
8792 WPF()->ai_logs->log( [
8793 'action_type' => AILogs::ACTION_SUGGEST_REPLY,
8794 'credits_used' => 0,
8795 'status' => AILogs::STATUS_ERROR,
8796 'content_type' => 'topic',
8797 'content_id' => $topic_id,
8798 'topicid' => $topic_id,
8799 'forumid' => $forumid,
8800 'request_summary' => sprintf( 'Suggest reply for topic: %s', wp_trim_words( $topic['title'], 10 ) ),
8801 'error_message' => $result->get_error_message(),
8802 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
8803 ] );
8804 }
8805 wp_send_json_error( [ 'message' => $result->get_error_message() ], 500 );
8806 }
8807
8808 $credits_used = $result['credits_used'] ?? 0;
8809
8810 // Log success
8811 if ( isset( WPF()->ai_logs ) && WPF()->ai_logs ) {
8812 WPF()->ai_logs->log( [
8813 'action_type' => AILogs::ACTION_SUGGEST_REPLY,
8814 'credits_used' => $credits_used,
8815 'status' => AILogs::STATUS_SUCCESS,
8816 'content_type' => 'topic',
8817 'content_id' => $topic_id,
8818 'topicid' => $topic_id,
8819 'forumid' => $forumid,
8820 'request_summary' => sprintf( 'Suggest reply for topic: %s', wp_trim_words( $topic['title'], 10 ) ),
8821 'response_summary' => 'Generated reply suggestion',
8822 'duration_ms' => (int) ( ( microtime( true ) - $_log_start_time ) * 1000 ),
8823 ] );
8824 }
8825
8826 wp_send_json_success( [
8827 'content' => $result['reply'],
8828 'credits_used' => $credits_used,
8829 ] );
8830 }
8831
8832 /**
8833 * Generate bot reply using Tasks API
8834 *
8835 * @param array $topic Topic data
8836 * @param array $parent_post Post being replied to
8837 * @param array|null $first_post First post of topic (if replying to reply)
8838 * @return array|WP_Error Result with 'reply' and 'credits_used' or WP_Error
8839 */
8840 private function generate_bot_reply( $topic, $parent_post, $first_post = null ) {
8841 $api_key = $this->get_stored_api_key();
8842 if ( ! $api_key ) {
8843 return new \WP_Error( 'no_api_key', wpforo_phrase( 'API key not configured', false ) );
8844 }
8845
8846 // Get settings
8847 $settings = WPF()->settings->ai;
8848 $quality = wpfval( $settings, 'bot_reply_quality' ) ?: 'premium';
8849 $style = wpfval( $settings, 'bot_reply_style' ) ?: 'helpful_answer';
8850 $tone = wpfval( $settings, 'bot_reply_tone' ) ?: 'neutral';
8851 $length = wpfval( $settings, 'bot_reply_length' ) ?: 'medium';
8852 $knowledge_source = wpfval( $settings, 'bot_reply_knowledge_source' ) ?: 'forum_and_ai';
8853 $response_language = $this->get_user_language( null, 'bot_reply_language' );
8854
8855 // Build include options from checkbox array
8856 $include = [];
8857 $includes_setting = wpfval( $settings, 'bot_reply_includes' );
8858 if ( is_array( $includes_setting ) ) {
8859 // Map setting values to backend expected keys
8860 $include_map = [
8861 'code' => 'code_examples',
8862 'docs' => 'documentation_links',
8863 'steps' => 'step_by_step',
8864 'questions' => 'follow_up_questions',
8865 'youtube' => 'youtube_videos',
8866 'greeting' => 'personalized_greeting',
8867 ];
8868 foreach ( $includes_setting as $key ) {
8869 if ( isset( $include_map[ $key ] ) ) {
8870 $include[] = $include_map[ $key ];
8871 }
8872 }
8873 }
8874
8875 // Get forum info
8876 $forum = WPF()->forum->get_forum( (int) $topic['forumid'] );
8877
8878 // Get parent post author name for greeting (use @nicename format)
8879 $parent_author_name = '';
8880 if ( $parent_post['userid'] ) {
8881 $parent_author = WPF()->member->get_member( $parent_post['userid'] );
8882 $nicename = wpfval( $parent_author, 'user_nicename' ) ?: wpfval( $parent_author, 'display_name' );
8883 $parent_author_name = $nicename ? '@' . $nicename : '';
8884 } else {
8885 $parent_author_name = wpfval( $parent_post, 'name' ) ?: '';
8886 }
8887
8888 // Build posts array for the topic context
8889 $posts = [];
8890
8891 // Add first post if available
8892 if ( $first_post ) {
8893 $first_post_author = '';
8894 if ( $first_post['userid'] ) {
8895 $first_author = WPF()->member->get_member( $first_post['userid'] );
8896 $first_post_author = wpfval( $first_author, 'display_name' ) ?: '';
8897 } else {
8898 $first_post_author = wpfval( $first_post, 'name' ) ?: '';
8899 }
8900 $posts[] = [
8901 'postid' => (int) $first_post['postid'],
8902 'author' => $first_post_author,
8903 'content' => wp_strip_all_tags( $first_post['body'] ),
8904 ];
8905 }
8906
8907 // Add parent post (the one being replied to)
8908 $posts[] = [
8909 'postid' => (int) $parent_post['postid'],
8910 'author' => $parent_author_name,
8911 'content' => wp_strip_all_tags( $parent_post['body'] ),
8912 ];
8913
8914 // Build the request payload matching backend ReplyGeneratorRequest schema
8915 $request_body = [
8916 'task_type' => 'reply_generator',
8917 'topics' => [
8918 [
8919 'topic_id' => (int) $topic['topicid'],
8920 'title' => $topic['title'],
8921 'forum_id' => (int) $topic['forumid'],
8922 'posts' => $posts,
8923 'reply_strategy' => 'last_post',
8924 ],
8925 ],
8926 'replies_count' => 1,
8927 'quality' => $quality,
8928 'reply_style' => $style,
8929 'reply_tone' => $tone,
8930 'reply_strategy' => 'last_post',
8931 'reply_length' => $length,
8932 'include' => $include,
8933 'knowledge_source' => $knowledge_source,
8934 'response_language' => $response_language,
8935 ];
8936
8937 // Add custom knowledge params if enabled (Business+ only, cloud storage only)
8938 if ( $this->is_custom_knowledge_enabled() ) {
8939 $request_body['include_custom_knowledge'] = true;
8940 $request_body['knowledge_priority'] = [
8941 'bot_reply_priority' => $this->get_knowledge_priorities( 'bot_reply' ),
8942 ];
8943 }
8944
8945 // Make API request to /tasks/generate endpoint
8946 $response = wp_remote_post( $this->api_base_url . '/tasks/generate', [
8947 'timeout' => 60,
8948 'headers' => [
8949 'Authorization' => 'Bearer ' . $api_key,
8950 'Content-Type' => 'application/json',
8951 ],
8952 'body' => wp_json_encode( $request_body ),
8953 ] );
8954
8955 if ( is_wp_error( $response ) ) {
8956 return new \WP_Error( 'api_error', $response->get_error_message() );
8957 }
8958
8959 $status_code = wp_remote_retrieve_response_code( $response );
8960 $body = json_decode( wp_remote_retrieve_body( $response ), true );
8961
8962 if ( $status_code >= 400 ) {
8963 $error_message = wpfval( $body, 'error' ) ?: wpfval( $body, 'detail' ) ?: 'API request failed';
8964 return new \WP_Error( 'api_error', $error_message );
8965 }
8966
8967 // Backend returns { success: bool, replies: [{topic_id, content}], credits_used }
8968 $replies = wpfval( $body, 'replies' );
8969 $reply_content = '';
8970 if ( is_array( $replies ) && ! empty( $replies ) ) {
8971 $reply_content = wpfval( $replies[0], 'content' ) ?: '';
8972 }
8973
8974 // Fallback to legacy response fields
8975 if ( empty( $reply_content ) ) {
8976 $reply_content = wpfval( $body, 'reply' ) ?: wpfval( $body, 'content' ) ?: '';
8977 }
8978
8979 if ( empty( $reply_content ) ) {
8980 return new \WP_Error( 'empty_reply', wpforo_phrase( 'AI generated an empty reply', false ) );
8981 }
8982
8983 return [
8984 'reply' => $reply_content,
8985 'credits_used' => wpfval( $body, 'credits_used' ) ?: 0,
8986 ];
8987 }
8988
8989 /**
8990 * Create bot reply post
8991 *
8992 * @param array $topic Topic data
8993 * @param array $parent_post Parent post data
8994 * @param string $content Reply content
8995 * @return int|WP_Error New post ID or WP_Error
8996 */
8997 private function create_bot_reply_post( $topic, $parent_post, $content ) {
8998 $settings = WPF()->settings->ai;
8999
9000 // Get bot user ID
9001 $bot_user_id = (int) wpfval( $settings, 'bot_reply_user_id' );
9002 if ( ! $bot_user_id ) {
9003 return new \WP_Error( 'no_bot_user', wpforo_phrase( 'Bot user not configured', false ) );
9004 }
9005
9006 // Check if bot user exists
9007 $bot_user = get_user_by( 'ID', $bot_user_id );
9008 if ( ! $bot_user ) {
9009 return new \WP_Error( 'invalid_bot_user', wpforo_phrase( 'Bot user does not exist', false ) );
9010 }
9011
9012 // Determine status (1 = unapproved, 0 = approved)
9013 $status = wpfval( $settings, 'bot_reply_unapproved' ) ? 1 : 0;
9014
9015 // Build post data
9016 $post_data = [
9017 'forumid' => (int) $topic['forumid'],
9018 'topicid' => (int) $topic['topicid'],
9019 'parentid' => (int) $parent_post['postid'],
9020 'userid' => $bot_user_id,
9021 'title' => wpforo_phrase( 'RE', false ) . ': ' . $topic['title'],
9022 'body' => $content,
9023 'status' => $status,
9024 'is_bot_reply' => true,
9025 ];
9026
9027 // Store current user to restore later
9028 $current_user_id = get_current_user_id();
9029
9030 // Temporarily switch to bot user
9031 wp_set_current_user( $bot_user_id );
9032 WPF()->current_userid = $bot_user_id;
9033
9034 // Create the post using wpForo's add method
9035 $new_post_id = WPF()->post->add( $post_data );
9036
9037 // Restore original user
9038 wp_set_current_user( $current_user_id );
9039 WPF()->current_userid = $current_user_id;
9040
9041 if ( ! $new_post_id ) {
9042 return new \WP_Error( 'post_creation_failed', wpforo_phrase( 'Failed to create bot reply', false ) );
9043 }
9044
9045 return $new_post_id;
9046 }
9047
9048 /**
9049 * Check bot reply rate limits
9050 *
9051 * @param int $topic_id Topic ID
9052 * @return string|null Error message if limit exceeded, null if OK
9053 */
9054 private function check_bot_reply_limits( $topic_id ) {
9055 global $wpdb;
9056 $settings = WPF()->settings->ai;
9057
9058 $bot_user_id = (int) wpfval( $settings, 'bot_reply_user_id' );
9059 if ( ! $bot_user_id ) {
9060 return wpforo_phrase( 'Bot user not configured', false );
9061 }
9062
9063 // Check max per topic
9064 $max_per_topic = (int) wpfval( $settings, 'bot_reply_max_per_topic' );
9065 if ( $max_per_topic > 0 ) {
9066 $topic_count = $wpdb->get_var(
9067 $wpdb->prepare(
9068 "SELECT COUNT(*) FROM `" . WPF()->tables->posts . "`
9069 WHERE `topicid` = %d AND `userid` = %d",
9070 $topic_id,
9071 $bot_user_id
9072 )
9073 );
9074
9075 if ( (int) $topic_count >= $max_per_topic ) {
9076 return sprintf(
9077 wpforo_phrase( 'Maximum bot replies per topic reached (%d)', false ),
9078 $max_per_topic
9079 );
9080 }
9081 }
9082
9083 // Check max per day
9084 $max_per_day = (int) wpfval( $settings, 'bot_reply_max_per_day' );
9085 if ( $max_per_day > 0 ) {
9086 $today_start = current_time( 'Y-m-d' ) . ' 00:00:00';
9087 $day_count = $wpdb->get_var(
9088 $wpdb->prepare(
9089 "SELECT COUNT(*) FROM `" . WPF()->tables->posts . "`
9090 WHERE `userid` = %d AND `created` >= %s",
9091 $bot_user_id,
9092 $today_start
9093 )
9094 );
9095
9096 if ( (int) $day_count >= $max_per_day ) {
9097 return sprintf(
9098 wpforo_phrase( 'Maximum bot replies per day reached (%d)', false ),
9099 $max_per_day
9100 );
9101 }
9102 }
9103
9104 return null;
9105 }
9106
9107 // =========================================================================
9108 // MULTIMODAL IMAGE EXTRACTION METHODS
9109 // =========================================================================
9110
9111 /**
9112 * Check if multimodal image indexing is enabled for the current board
9113 *
9114 * Image indexing requires:
9115 * 1. Professional, Business or Enterprise plan
9116 * 2. Board-specific setting enabled (ai_image_indexing_enabled)
9117 *
9118 * Credit Impact:
9119 * - When enabled, posts with images consume +1 additional credit
9120 * - Maximum 10 images per post (enforced by API)
9121 *
9122 * @return bool True if image indexing is enabled
9123 */
9124 public function is_image_indexing_enabled() {
9125 // Check board-specific setting first (fast check)
9126 $board_setting = (bool) wpforo_get_option( 'ai_image_indexing_enabled', 0 );
9127 if ( ! $board_setting ) {
9128 return false;
9129 }
9130
9131 // Check plan eligibility using cached plan (no API calls)
9132 $plan = strtolower( $this->get_subscription_plan() );
9133
9134 // Professional, Business and Enterprise plans have image indexing
9135 return in_array( $plan, [ 'professional', 'business', 'enterprise' ], true );
9136 }
9137
9138 /**
9139 * Check if URL points to an image file
9140 *
9141 * @param string $url URL to check
9142 * @return bool True if URL has an image extension
9143 */
9144 private function is_image_url( $url ) {
9145 $path = wp_parse_url( $url, PHP_URL_PATH );
9146 if ( ! $path ) {
9147 return false;
9148 }
9149 $ext = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
9150 return in_array( $ext, self::$image_extensions, true );
9151 }
9152
9153 /**
9154 * Check if document indexing is enabled for the current board
9155 *
9156 * Requires both:
9157 * 1. Professional+ subscription plan
9158 * 2. Board-specific setting enabled (ai_document_indexing_enabled)
9159 *
9160 * @return bool True if document indexing is enabled and eligible
9161 */
9162 public function is_document_indexing_enabled() {
9163 $board_setting = (bool) wpforo_get_option( 'ai_document_indexing_enabled', 0 );
9164 if ( ! $board_setting ) {
9165 return false;
9166 }
9167
9168 $plan = strtolower( $this->get_subscription_plan() );
9169
9170 return in_array( $plan, [ 'professional', 'business', 'enterprise' ], true );
9171 }
9172
9173 /**
9174 * Check if URL points to a document file
9175 *
9176 * @param string $url URL to check
9177 * @return bool True if URL has a document extension
9178 */
9179 private function is_document_url( $url ) {
9180 $path = wp_parse_url( $url, PHP_URL_PATH );
9181 if ( ! $path ) {
9182 return false;
9183 }
9184 $ext = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
9185 return in_array( $ext, self::$document_extensions, true );
9186 }
9187
9188 /**
9189 * Check if URL belongs to local site (not external domain)
9190 *
9191 * @param string $url URL to check
9192 * @param string $site_url Site URL for comparison (optional)
9193 * @return bool True if URL is local
9194 */
9195 private function is_local_url( $url, $site_url = null ) {
9196 if ( ! $site_url ) {
9197 $site_url = get_site_url();
9198 }
9199
9200 $site_host = wp_parse_url( $site_url, PHP_URL_HOST );
9201 $url_host = wp_parse_url( $url, PHP_URL_HOST );
9202
9203 // Relative URLs are local
9204 if ( ! $url_host ) {
9205 return true;
9206 }
9207
9208 // Exact match
9209 if ( $url_host === $site_host ) {
9210 return true;
9211 }
9212
9213 // Normalize both hosts (strip www prefix for comparison)
9214 $site_host_normalized = preg_replace( '/^www\./', '', $site_host );
9215 $url_host_normalized = preg_replace( '/^www\./', '', $url_host );
9216
9217 // Match after www normalization (example.com == www.example.com)
9218 if ( $url_host_normalized === $site_host_normalized ) {
9219 return true;
9220 }
9221
9222 // Check if URL host is a subdomain of site host (e.g., cdn.example.com for example.com)
9223 // Must end with .site_host to be a subdomain
9224 if ( substr( $url_host_normalized, -strlen( '.' . $site_host_normalized ) ) === '.' . $site_host_normalized ) {
9225 return true;
9226 }
9227
9228 return false;
9229 }
9230
9231 /**
9232 * Normalize URL to canonical form for deduplication
9233 *
9234 * Handles protocol-relative URLs, relative URLs, http→https normalization,
9235 * and query string/fragment removal.
9236 *
9237 * @param string $url URL to normalize
9238 * @param string $site_url Site URL for relative URL expansion (optional)
9239 * @return string Normalized URL, or empty string if invalid
9240 */
9241 private function normalize_url( $url, $site_url = null ) {
9242 if ( ! $site_url ) {
9243 $site_url = get_site_url();
9244 }
9245
9246 $url = trim( $url );
9247
9248 // Skip data URIs
9249 if ( strpos( $url, 'data:' ) === 0 ) {
9250 return '';
9251 }
9252
9253 // Expand relative URLs
9254 if ( strpos( $url, '/' ) === 0 && strpos( $url, '//' ) !== 0 ) {
9255 $url = rtrim( $site_url, '/' ) . $url;
9256 } elseif ( strpos( $url, '//' ) === 0 ) {
9257 // Protocol-relative URL
9258 $url = 'https:' . $url;
9259 }
9260
9261 // Normalize protocol to https
9262 $url = preg_replace( '#^http://#i', 'https://', $url );
9263
9264 // Remove query string and fragment for deduplication
9265 $url = strtok( $url, '?#' );
9266
9267 return $url;
9268 }
9269
9270 /**
9271 * Normalize image URL (delegates to normalize_url)
9272 *
9273 * @param string $url URL to normalize
9274 * @param string $site_url Site URL for relative URL expansion (optional)
9275 * @return string Normalized URL, or empty string if invalid
9276 */
9277 private function normalize_image_url( $url, $site_url = null ) {
9278 return $this->normalize_url( $url, $site_url );
9279 }
9280
9281 /**
9282 * Extract images from <img> tags in content
9283 *
9284 * @param string $content Post HTML content
9285 * @param string $site_url Site URL for validation
9286 * @return array Array of normalized image URLs
9287 */
9288 private function extract_img_tags( $content, $site_url = null ) {
9289 $images = [];
9290
9291 if ( preg_match_all( '/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $content, $matches ) ) {
9292 foreach ( $matches[1] as $src ) {
9293 $normalized = $this->normalize_image_url( $src, $site_url );
9294 if ( $normalized && $this->is_image_url( $normalized ) && $this->is_local_url( $normalized, $site_url ) ) {
9295 $images[] = $normalized;
9296 }
9297 }
9298 }
9299
9300 return $images;
9301 }
9302
9303 /**
9304 * Extract images from <a> tags in content (wpForo default attachments)
9305 *
9306 * @param string $content Post HTML content
9307 * @param string $site_url Site URL for validation
9308 * @return array Array of normalized image URLs
9309 */
9310 private function extract_anchor_images( $content, $site_url = null ) {
9311 $images = [];
9312
9313 if ( preg_match_all( '/<a[^>]+href=["\']([^"\']+)["\'][^>]*>/i', $content, $matches ) ) {
9314 foreach ( $matches[1] as $href ) {
9315 $normalized = $this->normalize_image_url( $href, $site_url );
9316 if ( $normalized && $this->is_image_url( $normalized ) && $this->is_local_url( $normalized, $site_url ) ) {
9317 $images[] = $normalized;
9318 }
9319 }
9320 }
9321
9322 return $images;
9323 }
9324
9325 /**
9326 * Extract plain text image URLs from content
9327 *
9328 * @param string $content Post content
9329 * @param string $site_url Site URL for validation
9330 * @return array Array of normalized image URLs
9331 */
9332 private function extract_plain_urls( $content, $site_url = null ) {
9333 $images = [];
9334
9335 // Strip HTML tags first to find plain text URLs
9336 $text = wp_strip_all_tags( $content );
9337
9338 // Match URLs ending with image extensions
9339 $pattern = '#https?://[^\s<>"\']+\.(?:' . implode( '|', self::$image_extensions ) . ')#i';
9340
9341 if ( preg_match_all( $pattern, $text, $matches ) ) {
9342 foreach ( $matches[0] as $url ) {
9343 $normalized = $this->normalize_image_url( $url, $site_url );
9344 if ( $normalized && $this->is_local_url( $normalized, $site_url ) ) {
9345 $images[] = $normalized;
9346 }
9347 }
9348 }
9349
9350 return $images;
9351 }
9352
9353 /**
9354 * Extract attachment IDs from [attach] shortcodes
9355 *
9356 * @param string $content Post content with shortcodes
9357 * @return array Array of attachment IDs (integers)
9358 */
9359 private function extract_attach_ids( $content ) {
9360 $attach_ids = [];
9361
9362 // Match [attach...]ID[/attach] patterns
9363 if ( preg_match_all( '/\[attach[^\]]*\](\d+(?:,\s*\d+)*)\[\/attach\]/i', $content, $matches ) ) {
9364 foreach ( $matches[1] as $ids_string ) {
9365 $ids = array_map( 'intval', explode( ',', $ids_string ) );
9366 $attach_ids = array_merge( $attach_ids, $ids );
9367 }
9368 }
9369
9370 return array_unique( array_filter( $attach_ids ) );
9371 }
9372
9373 /**
9374 * Get image URLs from attachment IDs
9375 *
9376 * Handles missing Advanced Attachments addon gracefully.
9377 *
9378 * @param array $attach_ids Array of attachment IDs
9379 * @return array Array of image data with url and attach_id
9380 */
9381 private function get_attachment_urls( $attach_ids ) {
9382 if ( empty( $attach_ids ) ) {
9383 return [];
9384 }
9385
9386 // Check if wpForo is available
9387 if ( ! function_exists( 'WPF' ) ) {
9388 return [];
9389 }
9390
9391 // Check if Advanced Attachments addon exists
9392 if ( ! isset( WPF()->tables->attachments ) ) {
9393 return [];
9394 }
9395
9396 global $wpdb;
9397 $table = WPF()->tables->attachments;
9398
9399 // Check if table exists
9400 $table_exists = $wpdb->get_var( $wpdb->prepare(
9401 "SHOW TABLES LIKE %s",
9402 $table
9403 ) );
9404
9405 if ( ! $table_exists ) {
9406 return [];
9407 }
9408
9409 $placeholders = implode( ',', array_fill( 0, count( $attach_ids ), '%d' ) );
9410 $query = $wpdb->prepare(
9411 "SELECT attachid, fileurl, mime FROM {$table} WHERE attachid IN ({$placeholders})",
9412 $attach_ids
9413 );
9414
9415 $attachments = $wpdb->get_results( $query, ARRAY_A );
9416
9417 if ( ! $attachments ) {
9418 return [];
9419 }
9420
9421 $image_urls = [];
9422 foreach ( $attachments as $attach ) {
9423 // Only include image MIME types
9424 if ( isset( $attach['mime'] ) && strpos( $attach['mime'], 'image/' ) === 0 ) {
9425 $normalized = $this->normalize_image_url( $attach['fileurl'] );
9426 if ( $normalized ) {
9427 $image_urls[] = [
9428 'attach_id' => (int) $attach['attachid'],
9429 'url' => $normalized,
9430 ];
9431 }
9432 }
9433 }
9434
9435 return $image_urls;
9436 }
9437
9438 /**
9439 * Get document URLs from attachment IDs
9440 *
9441 * Filters attachments by document MIME types (application/*, text/*).
9442 * Validates that the file extension matches supported document formats.
9443 *
9444 * @param array $attach_ids Array of attachment IDs
9445 * @return array Array of document data with url and attach_id
9446 */
9447 private function get_attachment_document_urls( $attach_ids ) {
9448 if ( empty( $attach_ids ) ) {
9449 return [];
9450 }
9451
9452 if ( ! function_exists( 'WPF' ) ) {
9453 return [];
9454 }
9455
9456 if ( ! isset( WPF()->tables->attachments ) ) {
9457 return [];
9458 }
9459
9460 global $wpdb;
9461 $table = WPF()->tables->attachments;
9462
9463 $table_exists = $wpdb->get_var( $wpdb->prepare(
9464 "SHOW TABLES LIKE %s",
9465 $table
9466 ) );
9467
9468 if ( ! $table_exists ) {
9469 return [];
9470 }
9471
9472 $placeholders = implode( ',', array_fill( 0, count( $attach_ids ), '%d' ) );
9473 $query = $wpdb->prepare(
9474 "SELECT attachid, fileurl, mime FROM {$table} WHERE attachid IN ({$placeholders})",
9475 $attach_ids
9476 );
9477
9478 $attachments = $wpdb->get_results( $query, ARRAY_A );
9479
9480 if ( ! $attachments ) {
9481 return [];
9482 }
9483
9484 $doc_urls = [];
9485 foreach ( $attachments as $attach ) {
9486 $mime = $attach['mime'] ?? '';
9487 // Include application/* and text/* MIME types (PDFs, DOCX, TXT, etc.)
9488 if ( strpos( $mime, 'application/' ) === 0 || strpos( $mime, 'text/' ) === 0 ) {
9489 $normalized = $this->normalize_url( $attach['fileurl'] );
9490 if ( $normalized && $this->is_document_url( $normalized ) ) {
9491 $doc_urls[] = [
9492 'attach_id' => (int) $attach['attachid'],
9493 'url' => $normalized,
9494 ];
9495 }
9496 }
9497 }
9498
9499 return $doc_urls;
9500 }
9501
9502 /**
9503 * Extract ALL images from post content with deduplication
9504 *
9505 * Handles all 4 image source types:
9506 * 1. <img> tags
9507 * 2. <a> tags (wpForo default attachments)
9508 * 3. Plain text URLs
9509 * 4. [attach] shortcodes (Advanced Attachments addon)
9510 *
9511 * @param string $content Post body content
9512 * @return array Array of unique image data
9513 */
9514 public function extract_post_images( $content ) {
9515 if ( empty( $content ) ) {
9516 return [];
9517 }
9518
9519 $site_url = get_site_url();
9520
9521 // Track URLs for deduplication (normalized URL => image data)
9522 $url_map = [];
9523
9524 // 1. Extract from <img> tags
9525 foreach ( $this->extract_img_tags( $content, $site_url ) as $url ) {
9526 if ( ! isset( $url_map[ $url ] ) ) {
9527 $url_map[ $url ] = [
9528 'type' => 'img_tag',
9529 'url' => $url,
9530 'attach_id' => null,
9531 ];
9532 }
9533 }
9534
9535 // 2. Extract from <a> tags (default wpForo attachments)
9536 foreach ( $this->extract_anchor_images( $content, $site_url ) as $url ) {
9537 if ( ! isset( $url_map[ $url ] ) ) {
9538 $url_map[ $url ] = [
9539 'type' => 'anchor_link',
9540 'url' => $url,
9541 'attach_id' => null,
9542 ];
9543 }
9544 }
9545
9546 // 3. Extract plain text URLs
9547 foreach ( $this->extract_plain_urls( $content, $site_url ) as $url ) {
9548 if ( ! isset( $url_map[ $url ] ) ) {
9549 $url_map[ $url ] = [
9550 'type' => 'plain_url',
9551 'url' => $url,
9552 'attach_id' => null,
9553 ];
9554 }
9555 }
9556
9557 // 4. Extract [attach] shortcode images (if addon exists)
9558 $attach_ids = $this->extract_attach_ids( $content );
9559 if ( ! empty( $attach_ids ) ) {
9560 $attach_images = $this->get_attachment_urls( $attach_ids );
9561 foreach ( $attach_images as $attach ) {
9562 $url = $attach['url'];
9563 if ( ! isset( $url_map[ $url ] ) ) {
9564 $url_map[ $url ] = [
9565 'type' => 'shortcode',
9566 'url' => $url,
9567 'attach_id' => $attach['attach_id'],
9568 ];
9569 } else {
9570 // URL already exists from another source, add attach_id
9571 $url_map[ $url ]['attach_id'] = $attach['attach_id'];
9572 }
9573 }
9574 }
9575
9576 // Return deduplicated images as array
9577 return array_values( $url_map );
9578 }
9579
9580 /**
9581 * Extract ALL documents from post content with deduplication
9582 *
9583 * Handles 3 document source types:
9584 * 1. <a> tags with href pointing to document files (linked PDFs, DOCX, etc.)
9585 * 2. Plain text URLs ending in document extensions
9586 * 3. [attach] shortcodes resolving to document attachments
9587 *
9588 * @param string $content Post body content
9589 * @return array Array of unique document data: [['type' => '...', 'url' => '...', 'attach_id' => ...], ...]
9590 */
9591 public function extract_post_documents( $content ) {
9592 if ( empty( $content ) ) {
9593 return [];
9594 }
9595
9596 $site_url = get_site_url();
9597 $url_map = [];
9598
9599 // 1. Extract from <a> tags (most common - linked PDFs)
9600 if ( preg_match_all( '/<a[^>]+href=["\']([^"\']+)["\'][^>]*>/i', $content, $matches ) ) {
9601 foreach ( $matches[1] as $href ) {
9602 $normalized = $this->normalize_url( $href, $site_url );
9603 if ( $normalized && $this->is_document_url( $normalized ) && $this->is_local_url( $normalized, $site_url ) ) {
9604 $url_map[ $normalized ] = [
9605 'type' => 'anchor_link',
9606 'url' => $normalized,
9607 'attach_id' => null,
9608 ];
9609 }
9610 }
9611 }
9612
9613 // 2. Extract plain text document URLs
9614 $doc_ext_pattern = implode( '|', self::$document_extensions );
9615 if ( preg_match_all( '#https?://[^\s<>"\']+\.(?:' . $doc_ext_pattern . ')#i', $content, $matches ) ) {
9616 foreach ( $matches[0] as $url ) {
9617 $normalized = $this->normalize_url( $url, $site_url );
9618 if ( $normalized && $this->is_local_url( $normalized, $site_url ) && ! isset( $url_map[ $normalized ] ) ) {
9619 $url_map[ $normalized ] = [
9620 'type' => 'plain_url',
9621 'url' => $normalized,
9622 'attach_id' => null,
9623 ];
9624 }
9625 }
9626 }
9627
9628 // 3. Extract [attach] shortcode documents (if addon exists)
9629 $attach_ids = $this->extract_attach_ids( $content );
9630 if ( ! empty( $attach_ids ) ) {
9631 $attach_docs = $this->get_attachment_document_urls( $attach_ids );
9632 foreach ( $attach_docs as $doc ) {
9633 $url = $doc['url'];
9634 if ( ! isset( $url_map[ $url ] ) ) {
9635 $url_map[ $url ] = [
9636 'type' => 'shortcode',
9637 'url' => $url,
9638 'attach_id' => $doc['attach_id'],
9639 ];
9640 } else {
9641 $url_map[ $url ]['attach_id'] = $doc['attach_id'];
9642 }
9643 }
9644 }
9645
9646 return array_values( $url_map );
9647 }
9648
9649 // =========================================================================
9650 // CUSTOM KNOWLEDGE AJAX HANDLERS
9651 // =========================================================================
9652
9653 /**
9654 * AJAX handler for adding custom knowledge
9655 *
9656 * Sends file URL to backend for processing and indexing.
9657 * Endpoint: POST /v1/knowledge/ingest
9658 */
9659 public function ajax_add_knowledge() {
9660 check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
9661
9662 if ( ! current_user_can( 'manage_options' ) ) {
9663 wp_send_json_error( [
9664 'message' => wpforo_phrase( 'Insufficient permissions', false )
9665 ], 403 );
9666 }
9667
9668 if ( ! $this->is_feature_available( 'custom_knowledge' ) ) {
9669 wp_send_json_error( [
9670 'message' => wpforo_phrase( 'Custom knowledge requires Business plan or higher', false )
9671 ], 403 );
9672 }
9673
9674 $file_url = isset( $_POST['file_url'] ) ? esc_url_raw( trim( $_POST['file_url'] ) ) : '';
9675 $file_type = isset( $_POST['file_type'] ) ? sanitize_key( $_POST['file_type'] ) : 'text';
9676 $file_name = isset( $_POST['file_name'] ) ? sanitize_text_field( trim( $_POST['file_name'] ) ) : '';
9677
9678 if ( empty( $file_url ) ) {
9679 wp_send_json_error( [
9680 'message' => wpforo_phrase( 'File URL is required', false )
9681 ], 400 );
9682 }
9683
9684 $valid_types = [ 'json', 'markdown', 'text', 'pdf' ];
9685 if ( ! in_array( $file_type, $valid_types, true ) ) {
9686 $file_type = 'text';
9687 }
9688
9689 // Build request body - backend expects 'name' not 'file_name'
9690 $request_body = [
9691 'file_url' => $file_url,
9692 'file_type' => $file_type,
9693 ];
9694 if ( ! empty( $file_name ) ) {
9695 $request_body['name'] = $file_name;
9696 }
9697
9698 $response = $this->post( '/knowledge/ingest', $request_body );
9699
9700 if ( is_wp_error( $response ) ) {
9701 wp_send_json_error( [
9702 'message' => $response->get_error_message()
9703 ], 400 );
9704 }
9705
9706 $this->log_info( 'knowledge_added', [
9707 'file_url' => $file_url,
9708 'file_type' => $file_type,
9709 'name' => $file_name
9710 ] );
9711
9712 // Merge backend response fields into success response
9713 // JS polling expects: async, file_id, name at top level
9714 $success_data = [
9715 'message' => wpforo_phrase( 'Knowledge file added. Processing will begin shortly.', false ),
9716 ];
9717
9718 // Pass through key fields from backend response
9719 if ( is_array( $response ) ) {
9720 if ( ! empty( $response['file_id'] ) ) {
9721 $success_data['file_id'] = $response['file_id'];
9722 }
9723 if ( ! empty( $response['async'] ) ) {
9724 $success_data['async'] = true;
9725 }
9726 if ( ! empty( $response['name'] ) ) {
9727 $success_data['name'] = $response['name'];
9728 }
9729 if ( isset( $response['credits_remaining'] ) ) {
9730 $success_data['credits_remaining'] = $response['credits_remaining'];
9731 }
9732 }
9733
9734 wp_send_json_success( $success_data );
9735 }
9736
9737 /**
9738 * AJAX handler for deleting custom knowledge
9739 *
9740 * Endpoint: DELETE /v1/knowledge/files/{file_id}
9741 */
9742 public function ajax_delete_knowledge() {
9743 check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
9744
9745 if ( ! current_user_can( 'manage_options' ) ) {
9746 wp_send_json_error( [
9747 'message' => wpforo_phrase( 'Insufficient permissions', false )
9748 ], 403 );
9749 }
9750
9751 $file_id = isset( $_POST['file_id'] ) ? sanitize_text_field( $_POST['file_id'] ) : '';
9752
9753 if ( empty( $file_id ) ) {
9754 wp_send_json_error( [
9755 'message' => wpforo_phrase( 'File ID is required', false )
9756 ], 400 );
9757 }
9758
9759 $response = $this->delete( '/knowledge/files/' . urlencode( $file_id ), [], 60 );
9760
9761 if ( is_wp_error( $response ) ) {
9762 wp_send_json_error( [
9763 'message' => $response->get_error_message()
9764 ], 400 );
9765 }
9766
9767 $this->log_info( 'knowledge_deleted', [
9768 'file_id' => $file_id
9769 ] );
9770
9771 wp_send_json_success( [
9772 'message' => wpforo_phrase( 'Knowledge file deleted successfully.', false )
9773 ] );
9774 }
9775
9776 /**
9777 * AJAX handler for checking async job status
9778 *
9779 * Endpoint: GET /v1/knowledge/jobs/{file_id}
9780 * Used by polling to check if async indexing is complete
9781 */
9782 public function ajax_get_job_status() {
9783 check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
9784
9785 if ( ! current_user_can( 'manage_options' ) ) {
9786 wp_send_json_error( [
9787 'message' => wpforo_phrase( 'Insufficient permissions', false )
9788 ], 403 );
9789 }
9790
9791 $file_id = isset( $_POST['file_id'] ) ? sanitize_text_field( $_POST['file_id'] ) : '';
9792
9793 if ( empty( $file_id ) ) {
9794 wp_send_json_error( [
9795 'message' => wpforo_phrase( 'File ID is required', false )
9796 ], 400 );
9797 }
9798
9799 $response = $this->get( '/knowledge/jobs/' . urlencode( $file_id ) );
9800
9801 if ( is_wp_error( $response ) ) {
9802 wp_send_json_error( [
9803 'message' => $response->get_error_message()
9804 ], 400 );
9805 }
9806
9807 // Log completion or failure (only once per file)
9808 $status = isset( $response['status'] ) ? $response['status'] : '';
9809 $logged_key = 'wpforo_knowledge_logged_' . $file_id;
9810
9811 if ( in_array( $status, [ 'enabled', 'failed' ], true ) && ! get_transient( $logged_key ) ) {
9812 $file_name = isset( $response['name'] ) ? $response['name'] : $file_id;
9813 $credits_used = isset( $response['credits_used'] ) ? (int) $response['credits_used'] : 0;
9814 $chunk_count = isset( $response['chunk_count'] ) ? (int) $response['chunk_count'] : 0;
9815 $error_msg = isset( $response['error_message'] ) ? $response['error_message'] : '';
9816
9817 if ( $status === 'enabled' ) {
9818 WPF()->ai_logs->log( [
9819 'action_type' => AILogs::ACTION_KNOWLEDGE_INDEXING,
9820 'credits_used' => $credits_used,
9821 'status' => AILogs::STATUS_SUCCESS,
9822 'request_summary' => 'File: ' . $file_name,
9823 'response_summary' => sprintf( 'Indexed %d chunks, used %d credits', $chunk_count, $credits_used ),
9824 'user_type' => 'admin',
9825 ] );
9826 } else {
9827 WPF()->ai_logs->log( [
9828 'action_type' => AILogs::ACTION_KNOWLEDGE_INDEXING,
9829 'credits_used' => 0,
9830 'status' => AILogs::STATUS_ERROR,
9831 'request_summary' => 'File: ' . $file_name,
9832 'error_message' => $error_msg ?: 'Indexing failed',
9833 'user_type' => 'admin',
9834 ] );
9835 }
9836
9837 // Mark as logged (expires in 1 hour - enough to prevent duplicate logs)
9838 set_transient( $logged_key, true, HOUR_IN_SECONDS );
9839 }
9840
9841 wp_send_json_success( $response );
9842 }
9843
9844 /**
9845 * AJAX handler for saving knowledge settings (priorities + enabled state)
9846 *
9847 * Settings are stored per-board in WordPress options:
9848 * - ai_knowledge_enabled (0/1)
9849 * - ai_knowledge_priorities (array of priorities per feature)
9850 *
9851 * Priorities are arrays of content sources in order:
9852 * - Position 0 = First priority (1.3x boost)
9853 * - Position 1 = Second priority (1.15x boost)
9854 * - Position 2 = Third priority (1.0x - no boost)
9855 */
9856 public function ajax_save_knowledge_priorities() {
9857 check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
9858
9859 if ( ! current_user_can( 'manage_options' ) ) {
9860 wp_send_json_error( [
9861 'message' => wpforo_phrase( 'Insufficient permissions', false )
9862 ], 403 );
9863 }
9864
9865 // Get board ID
9866 $board_id = isset( $_POST['board_id'] ) ? intval( $_POST['board_id'] ) : 0;
9867
9868 // Switch to correct board context
9869 if ( $board_id > 0 ) {
9870 WPF()->change_board( $board_id );
9871 }
9872
9873 // Get enabled state
9874 $enabled = isset( $_POST['enabled'] ) ? (int) (bool) $_POST['enabled'] : 0;
9875
9876 // Valid content sources
9877 $valid_sources = [ 'custom_knowledge', 'forum', 'wordpress' ];
9878
9879 // Sanitize priority arrays from POST data
9880 $priorities = [
9881 'search' => $this->sanitize_priority_array(
9882 isset( $_POST['search_priority'] ) ? $_POST['search_priority'] : [],
9883 $valid_sources,
9884 [ 'forum', 'wordpress', 'custom_knowledge' ]
9885 ),
9886 'chat' => $this->sanitize_priority_array(
9887 isset( $_POST['chat_priority'] ) ? $_POST['chat_priority'] : [],
9888 $valid_sources,
9889 [ 'custom_knowledge', 'forum', 'wordpress' ]
9890 ),
9891 'bot_reply' => $this->sanitize_priority_array(
9892 isset( $_POST['bot_reply_priority'] ) ? $_POST['bot_reply_priority'] : [],
9893 $valid_sources,
9894 [ 'forum', 'custom_knowledge', 'wordpress' ]
9895 ),
9896 ];
9897
9898 // Save to WordPress options (board-specific via wpforo_update_option)
9899 wpforo_update_option( 'ai_knowledge_enabled', $enabled );
9900 wpforo_update_option( 'ai_knowledge_priorities', $priorities );
9901
9902 $this->log_info( 'knowledge_settings_saved', [
9903 'board_id' => $board_id,
9904 'enabled' => $enabled,
9905 'priorities' => $priorities
9906 ] );
9907
9908 wp_send_json_success( [
9909 'message' => wpforo_phrase( 'Settings saved successfully.', false )
9910 ] );
9911 }
9912
9913 /**
9914 * Sanitize and validate priority array
9915 *
9916 * @param mixed $input Raw input (may be array or string)
9917 * @param array $valid_sources Valid content source values
9918 * @param array $default Default priority order
9919 * @return array Sanitized array with exactly 3 valid sources
9920 */
9921 private function sanitize_priority_array( $input, $valid_sources, $default = null ) {
9922 if ( $default === null ) {
9923 $default = [ 'forum', 'wordpress', 'custom_knowledge' ];
9924 }
9925
9926 if ( ! is_array( $input ) ) {
9927 return $default;
9928 }
9929
9930 $sanitized = [];
9931 foreach ( $input as $source ) {
9932 $source = sanitize_key( $source );
9933 if ( in_array( $source, $valid_sources, true ) && ! in_array( $source, $sanitized, true ) ) {
9934 $sanitized[] = $source;
9935 }
9936 }
9937
9938 // Ensure we have exactly 3 unique sources
9939 if ( count( $sanitized ) !== 3 ) {
9940 return $default;
9941 }
9942
9943 return $sanitized;
9944 }
9945
9946 /**
9947 * AJAX handler for getting knowledge settings for a board
9948 */
9949 public function ajax_get_knowledge_settings() {
9950 check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
9951
9952 if ( ! current_user_can( 'manage_options' ) ) {
9953 wp_send_json_error( [
9954 'message' => wpforo_phrase( 'Insufficient permissions', false )
9955 ], 403 );
9956 }
9957
9958 // Get board ID
9959 $board_id = isset( $_POST['board_id'] ) ? intval( $_POST['board_id'] ) : 0;
9960
9961 // Switch to correct board context
9962 if ( $board_id > 0 ) {
9963 WPF()->change_board( $board_id );
9964 }
9965
9966 // Get settings from WordPress options (board-specific)
9967 $enabled = (int) wpforo_get_option( 'ai_knowledge_enabled', 0 );
9968 $priorities = wpforo_get_option( 'ai_knowledge_priorities', [] );
9969
9970 // Apply defaults if not set
9971 $default_priorities = [
9972 'search' => [ 'forum', 'wordpress', 'custom_knowledge' ],
9973 'chat' => [ 'custom_knowledge', 'forum', 'wordpress' ],
9974 'bot_reply' => [ 'forum', 'custom_knowledge', 'wordpress' ],
9975 ];
9976
9977 if ( empty( $priorities ) || ! is_array( $priorities ) ) {
9978 $priorities = $default_priorities;
9979 } else {
9980 foreach ( $default_priorities as $feature => $default ) {
9981 if ( ! isset( $priorities[ $feature ] ) || ! is_array( $priorities[ $feature ] ) ) {
9982 $priorities[ $feature ] = $default;
9983 }
9984 }
9985 }
9986
9987 wp_send_json_success( [
9988 'board_id' => $board_id,
9989 'enabled' => $enabled,
9990 'priorities' => $priorities
9991 ] );
9992 }
9993
9994 /**
9995 * AJAX handler for getting knowledge files list
9996 *
9997 * Makes one API call to backend:
9998 * - GET /v1/knowledge/files - List of indexed files
9999 *
10000 * Settings (enabled, priorities) are stored in WordPress per-board
10001 * and retrieved separately via ajax_get_knowledge_settings.
10002 *
10003 * Returns empty data gracefully if backend is not available yet.
10004 */
10005 public function ajax_get_knowledge_files() {
10006 check_ajax_referer( 'wpforo_ai_features_nonce', 'nonce' );
10007
10008 if ( ! current_user_can( 'manage_options' ) ) {
10009 wp_send_json_error( [
10010 'message' => wpforo_phrase( 'Insufficient permissions', false )
10011 ], 403 );
10012 }
10013
10014 // Get files list from backend - return empty if not ready
10015 $files_response = $this->get( '/knowledge/files' );
10016 if ( is_wp_error( $files_response ) ) {
10017 // Backend not available - return empty state
10018 wp_send_json_success( [
10019 'files' => [],
10020 'totals' => [
10021 'total_files' => 0,
10022 'total_chunks' => 0,
10023 'total_credits' => 0,
10024 ]
10025 ] );
10026 return;
10027 }
10028
10029 // Normalize files data - map backend field names to UI field names
10030 $files = [];
10031 if ( isset( $files_response['files'] ) && is_array( $files_response['files'] ) ) {
10032 foreach ( $files_response['files'] as $file ) {
10033 $files[] = [
10034 'file_id' => isset( $file['file_id'] ) ? $file['file_id'] : '',
10035 'name' => isset( $file['name'] ) ? $file['name'] : '',
10036 'url' => isset( $file['source_url'] ) ? $file['source_url'] : '',
10037 'type' => isset( $file['file_type'] ) ? $file['file_type'] : 'text',
10038 'size_bytes' => isset( $file['file_size_bytes'] ) ? (int) $file['file_size_bytes'] : 0,
10039 'chunks' => isset( $file['chunk_count'] ) ? (int) $file['chunk_count'] : 0,
10040 'credits_used' => isset( $file['credits_used'] ) ? (int) $file['credits_used'] : 0,
10041 'status' => isset( $file['status'] ) ? $file['status'] : 'unknown',
10042 'enabled' => isset( $file['status'] ) && $file['status'] === 'enabled',
10043 'created_at' => isset( $file['created_at'] ) ? $file['created_at'] : '',
10044 ];
10045 }
10046 }
10047
10048 wp_send_json_success( [
10049 'files' => $files,
10050 'totals' => [
10051 'total_files' => isset( $files_response['total'] ) ? (int) $files_response['total'] : count( $files ),
10052 'total_chunks' => isset( $files_response['total_chunks'] ) ? (int) $files_response['total_chunks'] : 0,
10053 'total_credits' => isset( $files_response['total_credits_used'] ) ? (int) $files_response['total_credits_used'] : 0,
10054 ]
10055 ] );
10056 }
10057
10058 /**
10059 * Check if custom knowledge is enabled for the current board
10060 *
10061 * @return bool True if enabled
10062 */
10063 public function is_custom_knowledge_enabled() {
10064 // Must have Business+ plan
10065 if ( ! $this->is_feature_available( 'custom_knowledge' ) ) {
10066 return false;
10067 }
10068
10069 // Custom knowledge only works in cloud storage mode
10070 // (vectors are stored in S3 Vectors, not local WordPress DB)
10071 if ( WPF()->vector_storage->is_local_mode() ) {
10072 return false;
10073 }
10074
10075 // Check board-specific setting
10076 return (bool) wpforo_get_option( 'ai_knowledge_enabled', 0 );
10077 }
10078
10079 /**
10080 * Get custom knowledge priorities for the current board
10081 *
10082 * @param string $feature Feature name: 'search', 'chat', or 'bot_reply'
10083 * @return array Priority order array
10084 */
10085 public function get_knowledge_priorities( $feature = 'search' ) {
10086 $defaults = [
10087 'search' => [ 'forum', 'wordpress', 'custom_knowledge' ],
10088 'chat' => [ 'custom_knowledge', 'forum', 'wordpress' ],
10089 'bot_reply' => [ 'forum', 'custom_knowledge', 'wordpress' ],
10090 ];
10091
10092 $priorities = wpforo_get_option( 'ai_knowledge_priorities', [] );
10093
10094 if ( isset( $priorities[ $feature ] ) && is_array( $priorities[ $feature ] ) ) {
10095 return $priorities[ $feature ];
10096 }
10097
10098 return isset( $defaults[ $feature ] ) ? $defaults[ $feature ] : $defaults['search'];
10099 }
10100 }
10101