Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/wpforo/admin/pages/tabs/ai-features-tab-rag-indexing.php
+++ b/wpforo/admin/pages/tabs/ai-features-tab-rag-indexing.php
@@ -105,8 +105,9 @@
$has_pending_cron_jobs = $pending_jobs_info['has_pending_jobs'];
$pending_topics_count_early = $pending_jobs_info['pending_topics'];
- // Consider "active" if either storage says indexing OR we have local pending cron jobs
- $is_processing = $is_indexing || $has_pending_cron_jobs;
+ // Only show the spinner when actually indexing (backend is processing or cron batch is running).
+ // Queued topics waiting for their scheduled time should NOT trigger the spinner.
+ $is_processing = $is_indexing || $pending_jobs_info['is_actively_processing'];
// Get storage recommendation
$recommendation = $storage_manager->get_storage_recommendation();
@@ -139,7 +140,7 @@
<span class="dashicons dashicons-format-chat"></span>
</div>
<div class="stat-info">
- <div class="stat-value" id="rag-total-topics"><?php echo number_format( $total_topics ); ?></div>
+ <div class="stat-value" id="rag-total-topics"><?php echo number_format( $total_topics ); ?><?php if ( $pending_topics_count_early > 0 ) : ?> <small class="rag-queued-count">| <?php echo number_format( $pending_topics_count_early ); ?> <?php _e( 'queued...', 'wpforo' ); ?></small><?php endif; ?></div>
<div class="stat-label"><?php _e( 'Total Threads Indexed', 'wpforo' ); ?></div>
</div>
</div>
@@ -151,10 +152,8 @@
<div class="stat-info">
<div class="stat-value <?php echo $is_processing ? 'status-active' : 'status-idle'; ?>" id="rag-indexing-status">
<?php
- if ( $is_indexing ) {
+ if ( $is_processing ) {
_e( 'Indexing...', 'wpforo' );
- } elseif ( $has_pending_cron_jobs ) {
- _e( 'Processing...', 'wpforo' );
} else {
_e( 'Idle', 'wpforo' );
}
--- a/wpforo/admin/settings/ai.php
+++ b/wpforo/admin/settings/ai.php
@@ -61,6 +61,7 @@
<?php
WPF()->settings->form_field( 'ai', 'search' );
WPF()->settings->form_field( 'ai', 'search_quality' );
+ WPF()->settings->form_field( 'ai', 'search_min_score' );
WPF()->settings->form_field( 'ai', 'search_enhance' );
WPF()->settings->form_field( 'ai', 'search_enhance_quality' );
WPF()->settings->form_field( 'ai', 'search_language' );
@@ -432,6 +433,7 @@
WPF()->settings->form_field( 'ai', 'chatbot_use_local_context' );
WPF()->settings->form_field( 'ai', 'chatbot_allowed_groups' );
WPF()->settings->form_field( 'ai', 'chatbot_language' );
+ WPF()->settings->form_field( 'ai', 'chatbot_min_score' );
?>
<?php else : ?>
<div class="wpf-opt-row">
--- a/wpforo/classes/AIChatbot.php
+++ b/wpforo/classes/AIChatbot.php
@@ -432,6 +432,7 @@
$use_local = (bool) wpforo_setting( 'ai', 'chatbot_use_local_context' );
$context_threshold = (int) wpforo_setting( 'ai', 'chatbot_context_update_threshold' ) ?: self::DEFAULT_CONTEXT_UPDATE_THRESHOLD;
$no_content_message = wpforo_setting( 'ai', 'chatbot_no_content_message' ) ?: '';
+ $min_score_setting = (int) wpforo_setting( 'ai', 'chatbot_min_score' );
// Get response language
$language = WPF()->ai_client->get_user_language( null, 'chatbot_language' );
@@ -449,6 +450,12 @@
],
];
+ // Add min_score to settings if set (for cloud RAG filtering)
+ // Converted from percentage (30) to decimal (0.3) for API
+ if ( $min_score_setting > 0 ) {
+ $request_data['settings']['min_score'] = $min_score_setting / 100;
+ }
+
// Add local context if enabled
if ( $use_local && ! empty( $local_context ) ) {
$request_data['local_context'] = $local_context;
@@ -474,8 +481,8 @@
$this->add_message( $conversation_id, 'user', $message );
// Store assistant response
- $credits_map = [ 'fast' => 1, 'balanced' => 2, 'advanced' => 3 ];
- $credits = $credits_map[ $quality ] ?? 1;
+ $credits_map = [ 'fast' => 1, 'balanced' => 2, 'advanced' => 3, 'premium' => 4 ];
+ $credits = $credits_map[ $quality ] ?? 2;
$this->add_message( $conversation_id, 'assistant', $response['response'], [
'tokens_used' => $response['tokens_used'] ?? 0,
@@ -686,8 +693,16 @@
* @return array|WP_Error Array of RAG results or WP_Error on failure
*/
private function perform_local_rag_search( $query, $limit = 5 ) {
- // Perform semantic search using VectorStorageManager
- $search_results = WPF()->vector_storage->semantic_search( $query, $limit );
+ // Get chatbot-specific min_score setting and apply local mode scaling
+ // Local cosine similarities are on a different scale (5-25%) than cloud scores (30-90%),
+ // so apply 1/3 of the configured threshold for local mode with 15% absolute minimum.
+ $min_score_setting = (int) wpforo_setting( 'ai', 'chatbot_min_score' );
+ $local_threshold = $min_score_setting > 0 ? ( $min_score_setting / 100 ) / 3 : 0;
+ $absolute_min = 0.15; // 15% - below this, results are definitely garbage
+ $filters = [ 'min_score' => max( $local_threshold, $absolute_min ) ];
+
+ // Perform semantic search using VectorStorageManager with chatbot's score threshold
+ $search_results = WPF()->vector_storage->semantic_search( $query, $limit, $filters );
if ( is_wp_error( $search_results ) ) {
return $search_results;
--- a/wpforo/classes/AIClient.php
+++ b/wpforo/classes/AIClient.php
@@ -2655,6 +2655,47 @@
}
/**
+ * Get forum IDs the current user can view (for search filtering)
+ *
+ * Uses cached WPF()->current_user_accesses (board-specific).
+ * Returns null if user can access all forums (no filtering needed).
+ *
+ * @return array|null Array of accessible forum IDs, or null for full access
+ */
+ public function get_accessible_forumids() {
+ // Admins see everything
+ if ( current_user_can( 'administrator' ) ) {
+ return null;
+ }
+
+ // Get all forums for current board (cached by usergroup)
+ $all_forums = WPF()->forum->get_forums( [ 'type' => 'forum' ] );
+ if ( empty( $all_forums ) ) {
+ return null;
+ }
+
+ $accessible = [];
+ $total_forums = 0;
+
+ foreach ( $all_forums as $forum ) {
+ if ( empty( $forum['is_cat'] ) ) { // Skip categories
+ $total_forums++;
+ // 'vf' = can view forum
+ if ( WPF()->perm->forum_can( 'vf', $forum['forumid'] ) ) {
+ $accessible[] = (int) $forum['forumid'];
+ }
+ }
+ }
+
+ // If user can access all forums, return null (no filtering needed)
+ if ( count( $accessible ) === $total_forums ) {
+ return null;
+ }
+
+ return $accessible;
+ }
+
+ /**
* Perform semantic search query
*
* @param string $query Search query text
@@ -2685,6 +2726,16 @@
$filters['board_id'] = $current_boardid;
}
+ // Add forum access filtering (only forums current user can view)
+ // Skip if already set by VectorStorageManager (avoids double-add)
+ // Returns null for admins or users with full access (no filtering needed)
+ if ( ! isset( $filters['accessible_forumids'] ) ) {
+ $accessible_forumids = $this->get_accessible_forumids();
+ if ( $accessible_forumids !== null ) {
+ $filters['accessible_forumids'] = $accessible_forumids;
+ }
+ }
+
$data = [
'tenant_id' => $tenant_id,
'query' => sanitize_text_field( $query ),
@@ -3366,9 +3417,29 @@
// Get minimum score threshold from settings (default 30%)
// Local cosine similarities are on a different scale (5-25%) than cloud scores (30-90%),
// so apply 1/3 of the configured threshold for local mode.
+ // Absolute minimum of 15% for local mode prevents garbage results (random vectors
+ // have ~5-15% cosine similarity with any query).
$is_local_mode = WPF()->vector_storage && WPF()->vector_storage->is_local_mode();
$min_score_setting = (int) wpfval( WPF()->settings->ai, 'search_min_score' );
- $min_score_percent = $is_local_mode ? max( 0, min( 100, round( $min_score_setting / 3 ) ) ) : max( 0, min( 100, $min_score_setting ) );
+ if ( $is_local_mode ) {
+ $local_threshold = round( $min_score_setting / 3 );
+ $min_score_percent = max( 15, $local_threshold ); // Absolute minimum 15%
+ } else {
+ $min_score_percent = max( 0, min( 100, $min_score_setting ) );
+ }
+
+ // Relevance label thresholds - different for local vs cloud modes
+ // Local cosine scores: 5-25% range (raw similarity)
+ // Cloud re-ranked scores: 30-90% range (LLM re-ranked)
+ if ( $is_local_mode ) {
+ $threshold_excellent = 25;
+ $threshold_good = 20;
+ $threshold_relevant = 15;
+ } else {
+ $threshold_excellent = 80;
+ $threshold_good = 60;
+ $threshold_relevant = 40;
+ }
// Enrich results with wpForo/WordPress data
// Cloud API returns: id, score, title, excerpt, url, content_source, metadata{...}
@@ -3402,11 +3473,11 @@
if ( $min_score_percent > 0 && $score_percent < $min_score_percent ) continue;
- if ( $score_percent >= 80 ) {
+ if ( $score_percent >= $threshold_excellent ) {
$relevance_label = wpforo_phrase( 'Excellent match', false );
- } elseif ( $score_percent >= 60 ) {
+ } elseif ( $score_percent >= $threshold_good ) {
$relevance_label = wpforo_phrase( 'Good match', false );
- } elseif ( $score_percent >= 40 ) {
+ } elseif ( $score_percent >= $threshold_relevant ) {
$relevance_label = wpforo_phrase( 'Relevant', false );
} else {
$relevance_label = wpforo_phrase( 'Possibly relevant', false );
@@ -3467,11 +3538,11 @@
continue;
}
- if ( $score_percent >= 80 ) {
+ if ( $score_percent >= $threshold_excellent ) {
$relevance_label = wpforo_phrase( 'Excellent match', false );
- } elseif ( $score_percent >= 60 ) {
+ } elseif ( $score_percent >= $threshold_good ) {
$relevance_label = wpforo_phrase( 'Good match', false );
- } elseif ( $score_percent >= 40 ) {
+ } elseif ( $score_percent >= $threshold_relevant ) {
$relevance_label = wpforo_phrase( 'Relevant', false );
} else {
$relevance_label = wpforo_phrase( 'Possibly relevant', false );
@@ -4953,7 +5024,9 @@
$crons = _get_cron_array();
$pending_jobs = 0;
$pending_topics = 0;
+ $is_actively_processing = false;
$board_id = WPF()->board->get_current( 'boardid' ) ?: 0;
+ $now = time();
// Check for queue-based processing (self-rescheduling pattern)
// Mode-specific keys (current format): wpforo_ai_indexing_queue_{mode}_{board_id}
@@ -4967,19 +5040,35 @@
}
}
- // Also check if any queue processor cron is scheduled (mode-specific or legacy)
+ // Check if any queue processor cron is scheduled (mode-specific or legacy)
+ // and whether it's due now (actively processing) or scheduled for the future (just queued)
foreach ( [ 'wpforo_ai_process_queue_local', 'wpforo_ai_process_queue_cloud', 'wpforo_ai_process_queue' ] as $cron_hook ) {
- if ( wp_next_scheduled( $cron_hook, [ $board_id ] ) ) {
+ $next = wp_next_scheduled( $cron_hook, [ $board_id ] );
+ if ( $next ) {
$pending_jobs = max( $pending_jobs, 1 );
+ // Cron is due or overdue — batch processing is imminent or in progress
+ if ( $next <= $now ) {
+ $is_actively_processing = true;
+ }
}
}
+ // Also check if a processing lock is held (batch is running right now)
+ if ( get_transient( 'wpforo_ai_indexing_lock_local_' . $board_id )
+ || get_transient( 'wpforo_ai_indexing_lock_cloud_' . $board_id )
+ || get_transient( 'wpforo_ai_indexing_lock_' . $board_id ) ) {
+ $is_actively_processing = true;
+ }
+
// Legacy: Search for wpforo_ai_process_batch scheduled events (old pattern)
if ( ! empty( $crons ) ) {
foreach ( $crons as $timestamp => $cron ) {
if ( isset( $cron['wpforo_ai_process_batch'] ) ) {
foreach ( $cron['wpforo_ai_process_batch'] as $key => $job ) {
$pending_jobs++;
+ if ( $timestamp <= $now ) {
+ $is_actively_processing = true;
+ }
// Count topics in this batch
$args = isset( $job['args'] ) ? $job['args'] : [];
if ( isset( $args[0] ) && is_array( $args[0] ) ) {
@@ -4993,9 +5082,10 @@
}
return [
- 'has_pending_jobs' => $pending_jobs > 0,
- 'pending_jobs' => $pending_jobs,
- 'pending_topics' => $pending_topics,
+ 'has_pending_jobs' => $pending_jobs > 0,
+ 'is_actively_processing' => $is_actively_processing,
+ 'pending_jobs' => $pending_jobs,
+ 'pending_topics' => $pending_topics,
];
}
@@ -7344,6 +7434,13 @@
}
}
+ // Add forum access filtering (only show suggestions from forums user can access)
+ // Returns null for admins or users with full access (no filtering needed)
+ $accessible_forumids = $this->get_accessible_forumids();
+ if ( $accessible_forumids !== null ) {
+ $payload['accessible_forumids'] = $accessible_forumids;
+ }
+
// Make API request to suggestions endpoint
$response = $this->post( '/suggestions/suggest', $payload );
@@ -7502,8 +7599,10 @@
}
// Convert similarity threshold from percentage to decimal
- $threshold_decimal = $similarity_threshold / 100;
- $related_threshold = max( 0.3, $threshold_decimal - 0.2 ); // 20% lower for related topics
+ // Local cosine similarities are on a different scale (5-25%) than cloud scores (30-90%),
+ // so apply 1/3 of the configured threshold for local mode with minimum of 15%
+ $threshold_decimal = max( 0.15, ( $similarity_threshold / 100 ) / 3 );
+ $related_threshold = max( 0.10, $threshold_decimal - 0.05 ); // 5% lower for related topics
// Group by topic (take best match per topic)
$by_topic = [];
--- a/wpforo/classes/AIContentModeration.php
+++ b/wpforo/classes/AIContentModeration.php
@@ -889,7 +889,8 @@
// Add context settings for spam
if ( $spam_enabled ) {
- $request_data['use_forum_context'] = $this->use_spam_context();
+ // Forum context only works in cloud mode (local mode has no S3 Vectors index)
+ $request_data['use_forum_context'] = $this->use_spam_context() && ! WPF()->vector_storage->is_local_mode();
$request_data['min_indexed_topics'] = $this->get_setting( 'spam', 'min_indexed', 100 );
$request_data['board_id'] = $this->board_id;
}
@@ -897,7 +898,8 @@
$endpoint = '/moderation/analyze';
} else {
// Use spam-only endpoint (more efficient)
- $request_data['use_forum_context'] = $this->use_spam_context();
+ // Forum context only works in cloud mode (local mode has no S3 Vectors index)
+ $request_data['use_forum_context'] = $this->use_spam_context() && ! WPF()->vector_storage->is_local_mode();
$request_data['min_indexed_topics'] = $this->get_setting( 'spam', 'min_indexed', 100 );
$request_data['board_id'] = $this->board_id;
--- a/wpforo/classes/API.php
+++ b/wpforo/classes/API.php
@@ -186,8 +186,10 @@
//Load reCAPTCHA API and Widget for Topic and Post Editor
if( in_array( $template, [ 'forum', 'topic', 'post', 'add-topic' ], true ) ) {
- add_action( 'wp_enqueue_scripts', [ $this, 'rc_enqueue' ] );
- add_action( 'wpforo_verify_form_end', [ $this, 'rc_verify' ] );
+ if( wpforo_setting( 'recaptcha', 'topic_editor' ) || wpforo_setting( 'recaptcha', 'post_editor' ) ) {
+ add_action( 'wp_enqueue_scripts', [ $this, 'rc_enqueue' ] );
+ add_action( 'wpforo_verify_form_end', [ $this, 'rc_verify' ] );
+ }
if( wpforo_setting( 'recaptcha', 'topic_editor' ) ) {
add_action( 'wpforo_topic_form_extra_fields_after', [ $this, 'rc_widget' ] );
}
--- a/wpforo/classes/PostMeta.php
+++ b/wpforo/classes/PostMeta.php
@@ -408,7 +408,14 @@
$fileurl = (string) wpfval( $file, 'fileurl' );
$filedir = wpforo_fix_upload_dir( $fileurl );
if( $mediaid ) wp_delete_attachment( $mediaid );
- wp_delete_file( $filedir );
+ // Security: Only delete files within wpforo upload directory
+ if( $filedir ) {
+ $realpath = realpath( $filedir );
+ $upload_base = realpath( WPF()->folders['wp_upload']['dir'] );
+ if( $realpath && $upload_base && strpos( $realpath, $upload_base . DIRECTORY_SEPARATOR . 'wpforo' ) === 0 ) {
+ wp_delete_file( $filedir );
+ }
+ }
}
$this->delete( [ 'postid' => $postid, 'metakey' => $metakey ] );
}
@@ -527,6 +534,9 @@
$fields_list = WPF()->post->get_topic_fields_list( false, $forum, ! WPF()->current_userid );
foreach( $topic['postmetas'] as $metakey => $metavalue ) {
if( in_array( $metakey, $fields_list ) ) {
+ // Security: Only accept array values for file-type fields to prevent file path injection
+ $field = WPF()->post->get_field( $metakey, 'topic', $forum );
+ if( is_array( $metavalue ) && wpfval( $field, 'type' ) !== 'file' ) continue;
$postmeta = [
'postid' => $topic['first_postid'],
'metakey' => $metakey,
@@ -550,6 +560,9 @@
$fields_list = WPF()->post->get_topic_fields_list( false, $forum, ! WPF()->current_userid );
foreach( $args['postmetas'] as $metakey => $metavalue ) {
if( in_array( $metakey, $fields_list ) ) {
+ // Security: Only accept array values for file-type fields to prevent file path injection
+ $field = WPF()->post->get_field( $metakey, 'topic', $forum );
+ if( is_array( $metavalue ) && wpfval( $field, 'type' ) !== 'file' ) continue;
$postmeta = [
'metavalue' => $metavalue,
'forumid' => $topic['forumid'],
@@ -580,6 +593,9 @@
$fields_list = WPF()->post->get_post_fields_list( false, $forum, ! WPF()->current_userid );
foreach( $post['postmetas'] as $metakey => $metavalue ) {
if( in_array( $metakey, $fields_list ) ) {
+ // Security: Only accept array values for file-type fields to prevent file path injection
+ $field = WPF()->post->get_field( $metakey, 'post', $forum );
+ if( is_array( $metavalue ) && wpfval( $field, 'type' ) !== 'file' ) continue;
$postmeta = [
'postid' => $post['postid'],
'metakey' => $metakey,
@@ -603,6 +619,9 @@
$fields_list = WPF()->post->get_post_fields_list( false, $forum, ! WPF()->current_userid );
foreach( $args['postmetas'] as $metakey => $metavalue ) {
if( in_array( $metakey, $fields_list ) ) {
+ // Security: Only accept array values for file-type fields to prevent file path injection
+ $field = WPF()->post->get_field( $metakey, 'post', $forum );
+ if( is_array( $metavalue ) && wpfval( $field, 'type' ) !== 'file' ) continue;
$postmeta = [
'metavalue' => $metavalue,
'forumid' => $post['forumid'],
--- a/wpforo/classes/Settings.php
+++ b/wpforo/classes/Settings.php
@@ -3364,11 +3364,11 @@
"label" => esc_html__( "Minimum Relevance Score (%)", "wpforo" ),
"label_original" => "Minimum Relevance Score (%)",
"description" => esc_html__(
- "Results with relevance score below this threshold will be filtered out. Set to 0 to show all results. Recommended: 30%.",
+ "Results below this threshold are filtered out. For local storage, the threshold is automatically scaled (divided by 3) with a minimum of 15% to prevent irrelevant results. Recommended: 30%.",
"wpforo"
),
- "description_original" => "Results with relevance score below this threshold will be filtered out. Set to 0 to show all results. Recommended: 30%.",
- "min" => 0,
+ "description_original" => "Results below this threshold are filtered out. For local storage, the threshold is automatically scaled (divided by 3) with a minimum of 15% to prevent irrelevant results. Recommended: 30%.",
+ "min" => 30,
"max" => 100,
"docurl" => "",
],
@@ -3628,10 +3628,12 @@
"label" => esc_html__( "Similarity Threshold (%)", "wpforo" ),
"label_original" => "Similarity Threshold (%)",
"description" => esc_html__(
- "Minimum similarity percentage for topics to be shown as similar. Lower values show more results.",
+ "Minimum similarity percentage for topics to be shown as similar. Lower values show more results. For local storage, the threshold is automatically scaled (divided by 3) with a minimum of 15%. Recommended: 55%.",
"wpforo"
),
- "description_original" => "Minimum similarity percentage for topics to be shown as similar. Lower values show more results.",
+ "description_original" => "Minimum similarity percentage for topics to be shown as similar. Lower values show more results. For local storage, the threshold is automatically scaled (divided by 3) with a minimum of 15%. Recommended: 55%.",
+ "min" => 30,
+ "max" => 100,
"docurl" => "",
],
"topic_suggestions_language" => [
@@ -3885,14 +3887,15 @@
"label" => esc_html__( "Chatbot AI Quality", "wpforo" ),
"label_original" => "Chatbot AI Quality",
"description" => esc_html__(
- "Select the default AI model quality for chatbot responses. Higher quality provides better responses but uses more credits per message. Note: Fast quality is not available for AI Chat as it does not provide adequate response quality for conversational interactions.",
+ "Select the default AI model quality for chatbot responses. Higher quality provides better responses but uses more credits per message.",
"wpforo"
),
- "description_original" => "Select the default AI model quality for chatbot responses. Higher quality provides better responses but uses more credits per message. Note: Fast quality is not available for AI Chat as it does not provide adequate response quality for conversational interactions.",
+ "description_original" => "Select the default AI model quality for chatbot responses. Higher quality provides better responses but uses more credits per message.",
"variants" => [
- [ 'value' => 'balanced', 'label' => esc_html__( 'Balanced (1 credit per message)', 'wpforo' ) ],
- [ 'value' => 'advanced', 'label' => esc_html__( 'Advanced (2 credits per message)', 'wpforo' ) ],
- [ 'value' => 'premium', 'label' => esc_html__( 'Premium (3 credits per message)', 'wpforo' ) ],
+ [ 'value' => 'fast', 'label' => esc_html__( 'Fast (1 credit per message)', 'wpforo' ) ],
+ [ 'value' => 'balanced', 'label' => esc_html__( 'Balanced (2 credits per message)', 'wpforo' ) ],
+ [ 'value' => 'advanced', 'label' => esc_html__( 'Advanced (3 credits per message)', 'wpforo' ) ],
+ [ 'value' => 'premium', 'label' => esc_html__( 'Premium (4 credits per message)', 'wpforo' ) ],
],
"docurl" => "",
],
@@ -3992,6 +3995,19 @@
"variants" => $this->get_variants_ai_languages(),
"docurl" => "",
],
+ "chatbot_min_score" => [
+ "type" => "number",
+ "label" => esc_html__( "Minimum Relevance Score (%)", "wpforo" ),
+ "label_original" => "Minimum Relevance Score (%)",
+ "description" => esc_html__(
+ "Forum content below this relevance threshold is excluded from chatbot context. For local storage, the threshold is automatically scaled (divided by 3) with a minimum of 15%. Recommended: 30%.",
+ "wpforo"
+ ),
+ "description_original" => "Forum content below this relevance threshold is excluded from chatbot context. For local storage, the threshold is automatically scaled (divided by 3) with a minimum of 15%. Recommended: 30%.",
+ "min" => 15,
+ "max" => 100,
+ "docurl" => "",
+ ],
// AI Bot Reply
"bot_reply" => [
"type" => "radio",
@@ -5492,16 +5508,16 @@
'assistant_classic_search' => false,
'assistant_preferences' => false,
'search' => true,
- 'search_quality' => 'balanced',
+ 'search_quality' => 'fast',
'search_min_score' => 30,
'search_enhance' => true,
- 'search_enhance_quality' => 'advanced',
+ 'search_enhance_quality' => 'balanced',
'search_language' => '',
'search_max_results' => 5,
'translation' => true,
- 'translation_quality' => 'advanced',
+ 'translation_quality' => 'fast',
'topic_summary' => true,
- 'topic_summary_quality' => 'advanced',
+ 'topic_summary_quality' => 'balanced',
'topic_summary_style' => 'detailed',
'topic_summary_min_replies' => 1,
'topic_summary_language' => '',
@@ -5518,11 +5534,11 @@
'topic_suggestions_language' => '',
// AI Content Moderation defaults
'moderation_spam' => true,
- 'moderation_spam_quality' => 'advanced',
+ 'moderation_spam_quality' => 'balanced',
'moderation_spam_use_context' => true,
'moderation_spam_min_indexed' => 100,
'moderation_spam_action_detected' => 'unapprove_ban',
- 'moderation_spam_action_suspected' => 'unapprove_ban',
+ 'moderation_spam_action_suspected' => 'unapprove',
'moderation_spam_action_uncertain' => 'unapprove',
'moderation_spam_action_clean' => 'none',
'moderation_spam_exempt_minposts' => 10,
@@ -5536,7 +5552,7 @@
'moderation_compliance_action' => 'unapprove',
// AI Assistant Chatbot defaults
'chatbot' => true,
- 'chatbot_quality' => 'fast',
+ 'chatbot_quality' => 'balanced',
'chatbot_welcome_message' => "Hello {user_display_name}!rnI'm your forum assistant. I can help you find information and answer questions related to this forum. What would you like to know?",
'chatbot_no_content_message' => 'I couldn't find information about that topic in the forum. Please try searching directly or create a <a href="{add_topic_url}" target="_blank">new topic</a> to start a discussion.',
'chatbot_max_conversations' => 10,
@@ -5545,6 +5561,7 @@
'chatbot_use_local_context' => false,
'chatbot_allowed_groups' => [1, 2, 3, 5],
'chatbot_language' => '',
+ 'chatbot_min_score' => 30,
// AI Bot Reply defaults
'bot_reply' => true,
'bot_reply_quality' => 'premium',
--- a/wpforo/classes/Template.php
+++ b/wpforo/classes/Template.php
@@ -2293,6 +2293,17 @@
return ( $data == 'icon' ) ? implode( ' ', $icon ) : $icon['title'];
}
}
+ if( $item['type'] == 1 ) {
+ $icon['class'] = 'fas fa-thumbtack';
+ $icon['color'] = 'wpfcl-10';
+ $icon['title'] = wpforo_phrase( 'Sticky', false );
+ if( $echo ) {
+ $status = true;
+ echo ( $data == 'icon' ) ? implode( ' ', $icon ) : $icon['title'];
+ } else {
+ return ( $data == 'icon' ) ? implode( ' ', $icon ) : $icon['title'];
+ }
+ }
if( wpforo_topic( $item['topicid'], 'solved' ) ) {
$icon['class'] = 'fas fa-check-circle';
$icon['color'] = 'wpfcl-8';
@@ -2306,33 +2317,13 @@
}
}
- if( $item['closed'] && $item['type'] == 1 ) {
- $icon['class'] = 'fas fa-lock';
- $icon['color'] = 'wpfcl-1';
- $icon['title'] = wpforo_phrase( 'Closed', false );
- if( $echo ) {
- $status = true;
- echo ( $data == 'icon' ) ? implode( ' ', $icon ) : $icon['title'];
- } else {
- return ( $data == 'icon' ) ? implode( ' ', $icon ) : $icon['title'];
- }
- } elseif( $item['closed'] && $item['type'] != 1 ) {
+ if( $item['closed'] ) {
$icon['class'] = 'fas fa-lock';
$icon['color'] = 'wpfcl-1';
$icon['title'] = wpforo_phrase( 'Closed', false );
if( $echo ) {
$status = true;
echo ( $data == 'icon' ) ? implode( ' ', $icon ) : $icon['title'];
- } else {
- return ( $data == 'icon' ) ? implode( ' ', $icon ) : $icon['title'];
- }
- } elseif( ! $item['closed'] && $item['type'] == 1 ) {
- $icon['class'] = 'fas fa-thumbtack';
- $icon['color'] = 'wpfcl-10';
- $icon['title'] = wpforo_phrase( 'Sticky', false );
- if( $echo ) {
- $status = true;
- echo ( $data == 'icon' ) ? implode( ' ', $icon ) : $icon['title'];
} else {
return ( $data == 'icon' ) ? implode( ' ', $icon ) : $icon['title'];
}
--- a/wpforo/classes/VectorStorageManager.php
+++ b/wpforo/classes/VectorStorageManager.php
@@ -246,14 +246,17 @@
$local = $this->get_local_storage();
$stats = $local->get_stats();
- // Check for pending WP Cron jobs
- $pending_jobs = $this->get_pending_cron_jobs();
+ // Only report is_indexing when a batch is actually being processed
+ // (lock transient is held), not when topics are merely queued.
+ $board_id = $this->board_id;
+ $is_actively_indexing = (bool) get_transient( 'wpforo_ai_indexing_lock_local_' . $board_id )
+ || (bool) get_transient( 'wpforo_ai_indexing_lock_' . $board_id );
return [
'total_indexed' => (int) ( $stats['total_embeddings'] ?? 0 ),
'total_topics' => (int) ( $stats['total_topics'] ?? 0 ),
- 'indexing_progress' => 0, // Local doesn't track progress the same way
- 'is_indexing' => $pending_jobs['has_pending_jobs'],
+ 'indexing_progress' => $this->calculate_local_indexing_progress( $board_id ),
+ 'is_indexing' => $is_actively_indexing,
'last_indexed_at' => $stats['last_indexed_at'] ?? null,
'storage_mode' => self::MODE_LOCAL,
'storage_size' => $stats['storage_size_mb'] ?? '0',
@@ -262,6 +265,39 @@
}
/**
+ * Calculate local indexing progress percentage.
+ *
+ * Reads the queue and settings options to calculate how many topics
+ * have been processed out of the total queued for indexing.
+ *
+ * @param int $board_id Board ID
+ * @return int Progress percentage (0-100), or 0 if no indexing in progress
+ */
+ private function calculate_local_indexing_progress( $board_id ) {
+ $settings_key = 'wpforo_ai_indexing_settings_' . $board_id;
+ $settings = get_option( $settings_key, [] );
+
+ // No settings = no indexing session started
+ $total = (int) ( $settings['total_topics'] ?? 0 );
+ if ( $total <= 0 ) {
+ return 0;
+ }
+
+ // Count remaining topics in queue
+ $queue_key = 'wpforo_ai_indexing_queue_' . $board_id;
+ $pending = get_option( $queue_key, [] );
+ $remaining = is_array( $pending ) ? count( $pending ) : 0;
+
+ // Calculate progress
+ $processed = $total - $remaining;
+ if ( $processed < 0 ) {
+ $processed = 0; // Safety: queue grew larger than original total
+ }
+
+ return (int) round( ( $processed / $total ) * 100 );
+ }
+
+ /**
* Get cloud storage statistics
*
* @return array
@@ -276,9 +312,9 @@
$rag_status = [];
}
- // Check for pending WP Cron jobs
- $pending_jobs = $this->get_pending_cron_jobs();
- $is_indexing = ( $rag_status['is_indexing'] ?? false ) || $pending_jobs['has_pending_jobs'];
+ // Only report is_indexing when the backend is actively processing.
+ // Queued WP Cron topics are reported separately via pending_cron_jobs.
+ $is_indexing = (bool) ( $rag_status['is_indexing'] ?? false );
// Use local cloud column for accurate count (reflects manual changes)
$total_indexed = (int) $wpdb->get_var(
@@ -466,17 +502,16 @@
$local = $this->get_local_storage();
$indexed_count = 0;
+ $noise_filtered_count = 0;
$errors = [];
$is_first = true;
foreach ( $posts as $post ) {
// Mark first post for special handling
$post['is_first_post'] = $is_first;
+ $current_is_first = $is_first;
$is_first = false;
- // Generate content for embedding
- $content = $this->prepare_content_for_embedding( $post, $topic );
-
// Extract images if image indexing is enabled
$images = [];
if ( $include_images && ! empty( $post['body'] ) ) {
@@ -489,6 +524,19 @@
$documents = $ai_client->extract_post_documents( $post['body'] );
}
+ // Skip noise posts, but NEVER skip:
+ // - First post (topic body is always indexed)
+ // - Posts with images/documents that WILL be indexed (attachments have value even with short text)
+ // Note: $images/$documents are only populated if indexing is enabled, so this check is correct
+ $has_indexable_attachments = ! empty( $images ) || ! empty( $documents );
+ if ( ! $current_is_first && ! $has_indexable_attachments && $this->is_noise_post( $post['body'] ?? '' ) ) {
+ $noise_filtered_count++;
+ continue;
+ }
+
+ // Generate content for embedding
+ $content = $this->prepare_content_for_embedding( $post, $topic );
+
// Use content hash for deduplication
// Include image and document counts so re-index happens when attachments change
$hash_input = $content . '|images:' . count( $images ) . '|docs:' . count( $documents );
@@ -556,10 +604,11 @@
}
$response = [
- 'success' => true,
- 'indexed_count' => $indexed_count,
- 'total_posts' => count( $posts ),
- 'errors' => $errors,
+ 'success' => true,
+ 'indexed_count' => $indexed_count,
+ 'noise_filtered_count' => $noise_filtered_count,
+ 'total_posts' => count( $posts ),
+ 'errors' => $errors,
];
// Add image processing stats if images were processed
@@ -621,11 +670,12 @@
public function index_topics_batch_local( $topic_ids, $options = [] ) {
if ( empty( $topic_ids ) ) {
return [
- 'success' => true,
- 'indexed_count' => 0,
- 'skipped_count' => 0,
- 'total_posts' => 0,
- 'errors' => [],
+ 'success' => true,
+ 'indexed_count' => 0,
+ 'skipped_count' => 0,
+ 'noise_filtered_count' => 0,
+ 'total_posts' => 0,
+ 'errors' => [],
];
}
@@ -645,6 +695,7 @@
$items_with_images = []; // Items with images or documents (single endpoint)
$post_metadata = []; // Metadata for storing after embedding
$skipped_count = 0;
+ $noise_filtered_count = 0; // Posts filtered as noise (short, gratitude, etc.)
$topics_with_embeddings = []; // Topics that have at least one indexed post
// Batch-fetch all topics in one query to avoid N+1
@@ -699,12 +750,9 @@
$is_first = true;
foreach ( $posts as $post ) {
$post['is_first_post'] = $is_first;
+ $current_is_first = $is_first;
$is_first = false;
- // Prepare content for embedding
- $content = $this->prepare_content_for_embedding( $post, $topic );
- $post_id = $post['postid'];
-
// Extract images if image indexing is enabled
$images = [];
if ( $include_images && ! empty( $post['body'] ) ) {
@@ -717,6 +765,20 @@
$documents = $ai_client->extract_post_documents( $post['body'] );
}
+ // Skip noise posts, but NEVER skip:
+ // - First post (topic body is always indexed)
+ // - Posts with images/documents that WILL be indexed (attachments have value even with short text)
+ // Note: Only exempt if indexing is enabled AND post actually has attachments
+ $has_indexable_attachments = ! empty( $images ) || ! empty( $documents );
+ if ( ! $current_is_first && ! $has_indexable_attachments && $this->is_noise_post( $post['body'] ?? '' ) ) {
+ $noise_filtered_count++;
+ continue;
+ }
+
+ // Prepare content for embedding
+ $content = $this->prepare_content_for_embedding( $post, $topic );
+ $post_id = $post['postid'];
+
// Stable hash from raw content — not from prepare_content_for_embedding() output.
// This ensures enrichment formatting changes don't invalidate all existing hashes.
$content_hash = $this->compute_content_hash( $post, $topic, count( $images ), count( $documents ) );
@@ -769,12 +831,13 @@
}
return [
- 'success' => true,
- 'indexed_count' => 0,
- 'skipped_count' => $skipped_count,
- 'total_posts' => $skipped_count,
- 'errors' => [],
- 'message' => sprintf( wpforo_phrase( 'All %d posts already indexed (unchanged).', false ), $skipped_count ),
+ 'success' => true,
+ 'indexed_count' => 0,
+ 'skipped_count' => $skipped_count,
+ 'noise_filtered_count' => $noise_filtered_count,
+ 'total_posts' => $skipped_count + $noise_filtered_count,
+ 'errors' => [],
+ 'message' => sprintf( wpforo_phrase( 'All %d posts already indexed (unchanged).', false ), $skipped_count ),
];
}
@@ -935,11 +998,12 @@
}
return [
- 'success' => false,
- 'indexed_count' => 0,
- 'skipped_count' => $skipped_count,
- 'total_posts' => $total_items + $skipped_count,
- 'errors' => $errors,
+ 'success' => false,
+ 'indexed_count' => 0,
+ 'skipped_count' => $skipped_count,
+ 'noise_filtered_count' => $noise_filtered_count,
+ 'total_posts' => $total_items + $skipped_count + $noise_filtered_count,
+ 'errors' => $errors,
];
}
@@ -991,12 +1055,13 @@
}
$response = [
- 'success' => true,
- 'indexed_count' => $indexed_count,
- 'skipped_count' => $skipped_count,
- 'total_posts' => $total_items + $skipped_count,
- 'credits_used' => $total_credits_used > 0 ? $total_credits_used : count( $topic_ids ),
- 'errors' => $errors,
+ 'success' => true,
+ 'indexed_count' => $indexed_count,
+ 'skipped_count' => $skipped_count,
+ 'noise_filtered_count' => $noise_filtered_count,
+ 'total_posts' => $total_items + $skipped_count + $noise_filtered_count,
+ 'credits_used' => $total_credits_used > 0 ? $total_credits_used : count( $topic_ids ),
+ 'errors' => $errors,
];
// Add image processing stats if images were processed
@@ -1136,6 +1201,150 @@
}
/**
+ * Noise filtering patterns - matches cloud mode (rag_ingestion/handler.py)
+ * These patterns identify non-informative content that shouldn't be indexed.
+ */
+ private static $gratitude_patterns = [
+ '/^thanks?.?!?$/i',
+ '/^thank you.?!?$/i',
+ '/^thx.?!?$/i',
+ '/^ty.?!?$/i',
+ '/^thanks?,?s*(a lot|so much|for .{0,30}).?!?$/i',
+ '/^(great|awesome|perfect|helpful|nice|good),?s*thanks?.?!?$/i',
+ '/^thanks?,?s*(it works|that worked|that fixed it|that helped).?!?$/i',
+ ];
+
+ private static $agreement_patterns = [
+ '/^i agree.?!?$/i',
+ '/^same here.?!?$/i',
+ '/^me too.?!?$/i',
+ '/^same (problem|issue).?!?$/i',
+ '/^+1.?$/i',
+ '/^this.?!?$/i',
+ '/^exactly.?!?$/i',
+ '/^(yes|no|correct|right|true|indeed|absolutely|definitely).?!?$/i',
+ ];
+
+ private static $bump_patterns = [
+ '/^bump.?!?$/i',
+ '/^following.?$/i',
+ '/^subscribing.?$/i',
+ '/^watching.?$/i',
+ '/^any ?one??$/i',
+ '/^any updates???$/i',
+ '/^hello??$/i',
+ '/^still waiting.?$/i',
+ '/^???+$/i',
+ ];
+
+ private static $noise_phrases = [ 'ok', 'okay', 'k', 'up' ];
+
+ /** Minimum character count for indexing */
+ const MIN_CHAR_COUNT = 20;
+
+ /** Minimum word count for indexing */
+ const MIN_WORD_COUNT = 5;
+
+ /** Cached locale check result for performance */
+ private static $is_english_locale = null;
+
+ /**
+ * Check if the current locale is English-like.
+ * Cached for performance since it doesn't change during a request.
+ *
+ * @return bool True if locale is English (en, en_US, en_GB, etc.)
+ */
+ private function is_english_locale() {
+ if ( self::$is_english_locale === null ) {
+ $locale = get_locale();
+ // Match en, en_US, en_GB, en_AU, etc.
+ self::$is_english_locale = ( $locale === 'en' || strpos( $locale, 'en_' ) === 0 );
+ }
+ return self::$is_english_locale;
+ }
+
+ /**
+ * Check if a post is noise content that shouldn't be indexed.
+ *
+ * Filters out non-informative content to improve search quality:
+ * - Length < 20 characters (all languages)
+ * - Word count < 5 words (all languages)
+ * - Only emojis (no alphanumeric text) (all languages)
+ * - Gratitude-only posts ("thanks", etc.) (English only)
+ * - Agreement-only posts ("+1", "I agree", etc.) (English only)
+ * - Bump/follow posts ("bump", "following", etc.) (English only)
+ * - Common noise phrases ("ok", etc.) (English only)
+ *
+ * Pattern matching is only applied for English locales to avoid
+ * wasting resources on non-English forums where patterns won't match.
+ *
+ * Matches cloud mode behavior (rag_ingestion/handler.py:is_noise_reply).
+ *
+ * @param string $body Post body content (HTML allowed, will be stripped)
+ * @return bool True if post is noise and should be skipped, false if it should be indexed
+ */
+ public function is_noise_post( $body ) {
+ // Strip HTML and normalize whitespace
+ $cleaned = wp_strip_all_tags( $body );
+ $cleaned = preg_replace( '/s+/', ' ', $cleaned );
+ $cleaned = trim( $cleaned );
+
+ // Rule 1: Minimum character count (language-agnostic)
+ if ( mb_strlen( $cleaned ) < self::MIN_CHAR_COUNT ) {
+ return true;
+ }
+
+ // Rule 2: Minimum word count (language-agnostic)
+ $words = preg_split( '/s+/', $cleaned, -1, PREG_SPLIT_NO_EMPTY );
+ if ( count( $words ) < self::MIN_WORD_COUNT ) {
+ return true;
+ }
+
+ // Rule 3: Check if only emojis/symbols (no alphanumeric chars) (language-agnostic)
+ if ( ! preg_match( '/[a-zA-Z0-9]/', $cleaned ) ) {
+ return true;
+ }
+
+ // Pattern matching only for English locales - skip for other languages
+ // to avoid wasting CPU cycles on patterns that won't match
+ if ( ! $this->is_english_locale() ) {
+ return false;
+ }
+
+ // Prepare for pattern matching (English only from here)
+ $lower_text = mb_strtolower( $cleaned );
+
+ // Rule 4: Gratitude-only detection
+ foreach ( self::$gratitude_patterns as $pattern ) {
+ if ( preg_match( $pattern, $lower_text ) ) {
+ return true;
+ }
+ }
+
+ // Rule 5: Agreement-only detection
+ foreach ( self::$agreement_patterns as $pattern ) {
+ if ( preg_match( $pattern, $lower_text ) ) {
+ return true;
+ }
+ }
+
+ // Rule 6: Bump/follow detection
+ foreach ( self::$bump_patterns as $pattern ) {
+ if ( preg_match( $pattern, $lower_text ) ) {
+ return true;
+ }
+ }
+
+ // Rule 7: Legacy noise phrases (exact match)
+ if ( in_array( $lower_text, self::$noise_phrases, true ) ) {
+ return true;
+ }
+
+ // Passed all filters - keep this post
+ return false;
+ }
+
+ /**
* Compute a stable content hash for embedding deduplication.
*
* Uses raw content inputs (body text, topic title, attachment counts) rather than
@@ -2238,6 +2447,23 @@
* @return array|WP_Error Search results or error
*/
public function semantic_search( $query, $limit = 10, $filters = [] ) {
+ // Add forum access filtering (restrict to forums user can view)
+ // This applies to BOTH local and cloud modes
+ $ai_client = $this->get_ai_client();
+ $accessible_forumids = $ai_client->get_accessible_forumids();
+
+ if ( $accessible_forumids !== null ) {
+ // User has restricted access - if empty array, they can't access any forums
+ if ( empty( $accessible_forumids ) ) {
+ return [
+ 'results' => [],
+ 'total' => 0,
+ 'message' => wpforo_phrase( 'No results found', false ),
+ ];
+ }
+ $filters['accessible_forumids'] = $accessible_forumids;
+ }
+
if ( $this->is_local_mode() ) {
return $this->semantic_search_local( $query, $limit, $filters );
} else {
@@ -2257,9 +2483,19 @@
// Inject score threshold from settings so VectorStorageLocal filters at search time.
// Local cosine similarities are on a different scale (5-25%) than cloud scores (30-90%),
// so apply 1/3 of the configured threshold for local mode.
+ // Minimum absolute threshold of 15% prevents garbage results from nonsense queries
+ // (random vectors have ~5-15% cosine similarity with any query).
$min_score_setting = (int) wpfval( WPF()->settings->ai, 'search_min_score' );
- if ( $min_score_setting > 0 && ! isset( $filters['min_score'] ) ) {
- $filters['min_score'] = ( $min_score_setting / 100 ) / 3;
+ $local_threshold = $min_score_setting > 0 ? ( $min_score_setting / 100 ) / 3 : 0;
+ $absolute_min = 0.15; // 15% - below this, results are definitely garbage
+ if ( ! isset( $filters['min_score'] ) ) {
+ $filters['min_score'] = max( $local_threshold, $absolute_min );
+ }
+
+ // Convert accessible_forumids to forumids for local storage (include filter)
+ if ( ! empty( $filters['accessible_forumids'] ) && empty( $filters['forumids'] ) ) {
+ $filters['forumids'] = $filters['accessible_forumids'];
+ unset( $filters['accessible_forumids'] );
}
// Generate embedding for query
@@ -2369,6 +2605,46 @@
'total' => count( $results ),
];
+ // Collect topic and post IDs for forum results to batch fetch (avoids N+1 queries)
+ $topic_ids = [];
+ $post_ids = [];
+ foreach ( $results as $result ) {
+ $content_type = $result['content_type'] ?? 'forum';
+ if ( $content_type === 'forum' ) {
+ $topic_ids[] = (int) $result['topicid'];
+ $post_ids[] = (int) $result['postid'];
+ }
+ }
+
+ // Batch fetch topics and posts in 2 queries instead of 2*N queries
+ $topics_map = [];
+ $posts_map = [];
+
+ if ( ! empty( $topic_ids ) ) {
+ $topic_ids = array_unique( array_filter( $topic_ids ) );
+ $_count = 0;
+ $all_topics = WPF()->topic->get_topics( [
+ 'include' => $topic_ids,
+ 'row_count' => count( $topic_ids ),
+ ], $_count, false );
+ foreach ( $all_topics as $t ) {
+ $topics_map[ (int) $t['topicid'] ] = $t;
+ }
+ }
+
+ if ( ! empty( $post_ids ) ) {
+ $post_ids = array_unique( array_filter( $post_ids ) );
+ $_count = 0;
+ $all_posts = WPF()->post->get_posts( [
+ 'include' => $post_ids,
+ 'row_count' => count( $post_ids ),
+ ], $_count, false );
+ foreach ( $all_posts as $p ) {
+ $posts_map[ (int) $p['postid'] ] = $p;
+ }
+ }
+
+ // Process results using pre-fetched maps
foreach ( $results as $result ) {
$content_type = $result['content_type'] ?? 'forum';
@@ -2402,29 +2678,56 @@
continue;
}
- // Get full topic and post data
- $topic = WPF()->topic->get_topic( $result['topicid'] );
- $post = WPF()->post->get_post( $result['postid'] );
+ // Forum result - use pre-fetched maps
+ $topic = $topics_map[ (int) $result['topicid'] ] ?? null;
+ $post = $posts_map[ (int) $result['postid'] ] ?? null;
if ( ! $topic || ! $post ) {
continue;
}
+ // Calculate quality boost for ranking (uses live data, no schema changes)
+ // Boost is applied to ranking only; original score is preserved for display
+ $base_score = (float) $result['similarity'];
+ $boost = 1.0;
+ if ( ! empty( $topic['solved'] ) ) {
+ $boost += 0.05; // +5% for solved topics
+ }
+ if ( ! empty( $post['is_answer'] ) ) {
+ $boost += 0.10; // +10% for best answer posts
+ }
+ if ( ! empty( $post['votes'] ) && (int) $post['votes'] > 5 ) {
+ $boost += 0.03; // +3% for well-voted posts (>5 votes)
+ }
+
$formatted['results'][] = [
- 'topic_id' => (int) $result['topicid'],
- 'post_id' => (int) $result['postid'],
- 'forum_id' => (int) $result['forumid'],
- 'title' => $topic['title'] ?? '',
- 'content' => preg_replace( '/[(?:FORUM|SOLVED|BEST ANSWER)[^]]*]/', '', $result['content_preview'] ?? preg_replace( '/[(?:/)?[a-zA-Z0-9_-]+(?:s[^]]*?)?]/', '', strip_tags( $post['body'] ) ) ),
- 'score' => (float) $result['similarity'],
- 'url' => WPF()->topic->get_url( $result['topicid'] ),
- 'post_url' => WPF()->post->get_url( $result['postid'] ),
- 'created' => $post['created'] ?? null,
- 'user_id' => (int) ( $result['userid'] ?? 0 ),
- 'content_type' => 'forum',
+ 'topic_id' => (int) $result['topicid'],
+ 'post_id' => (int) $result['postid'],
+ 'forum_id' => (int) $result['forumid'],
+ 'title' => $topic['title'] ?? '',
+ 'content' => preg_replace( '/[(?:FORUM|SOLVED|BEST ANSWER)[^]]*]/', '', $result['content_preview'] ?? preg_replace( '/[(?:/)?[a-zA-Z0-9_-]+(?:s[^]]*?)?]/', '', strip_tags( $post['body'] ) ) ),
+ 'score' => $base_score, // Original similarity for display
+ '_boost_score' => $base_score * $boost, // Internal: for sorting only
+ 'url' => WPF()->topic->get_url( $topic ), // Pass array to avoid extra query
+ 'post_url' => WPF()->post->get_url( $post ), // Pass array to avoid extra query
+ 'created' => $post['created'] ?? null,
+ 'user_id' => (int) ( $result['userid'] ?? 0 ),
+ 'content_type' => 'forum',
];
}
+ // Re-sort by boosted score (quality signals affect ranking)
+ if ( ! empty( $formatted['results'] ) ) {
+ usort( $formatted['results'], function( $a, $b ) {
+ return ( $b['_boost_score'] ?? $b['score'] ) <=> ( $a['_boost_score'] ?? $a['score'] );
+ } );
+ // Remove internal boost score from output
+ foreach ( $formatted['results'] as &$r ) {
+ unset( $r['_boost_score'] );
+ }
+ unset( $r );
+ }
+
return $formatted;
}
--- a/wpforo/includes/functions.php
+++ b/wpforo/includes/functions.php
@@ -2644,6 +2644,9 @@
if( $folders && preg_match( '#[/\]wpforo(?:_d+)?[/\](?:' . implode( '|', $folders ) . ')[/\].+?$#iu', (string) $upload_dir, $match ) ) {
$upload_dir = wpforo_fix_dir_sep( WPF()->folders['wp_upload']['dir'] . $match[0] );
$upload_dir = urldecode( (string) $upload_dir );
+ } else {
+ // Security: Return empty string for paths outside wpforo uploads to prevent arbitrary file operations
+ $upload_dir = '';
}
return $upload_dir;
--- a/wpforo/includes/installation.php
+++ b/wpforo/includes/installation.php
@@ -103,6 +103,10 @@
wpforo_migrate_recent_activity_menu();
wpforo_migrate_theme_styles();
}
+
+ // Migrate AI quality defaults to faster tiers (3.0.3+)
+ // Runs for all versions - has its own one-time flag check
+ wpforo_migrate_ai_quality_defaults();
}
function wpforo_create_tables() {
@@ -1800,3 +1804,70 @@
// Mark migration as complete
update_option( 'wpforo_theme_styles_migrated', 1 );
}
+
+/**
+ * Migrate AI quality settings to optimized defaults for better performance.
+ * Changes quality tiers from 'advanced' to 'fast'/'balanced' to reduce API timeouts.
+ * This only runs once during plugin update.
+ *
+ * New defaults (3.0.3+):
+ * - search_quality: 'fast' (was 'balanced')
+ * - search_enhance_quality: 'balanced' (was 'advanced')
+ * - translation_quality: 'fast' (was 'advanced')
+ * - topic_summary_quality: 'balanced' (was 'advanced')
+ * - moderation_spam_quality: 'balanced' (was 'advanced')
+ * - chatbot_quality: 'balanced' (was 'fast')
+ *
+ * @since 3.0.3
+ */
+function wpforo_migrate_ai_quality_defaults() {
+ // Only run this migration once
+ if( get_option( 'wpforo_ai_quality_defaults_migrated' ) ) {
+ return;
+ }
+
+ // Define quality settings to update (old_value => new_value)
+ $quality_changes = [
+ 'search_quality' => 'fast',
+ 'search_enhance_quality' => 'balanced',
+ 'translation_quality' => 'fast',
+ 'topic_summary_quality' => 'balanced',
+ 'moderation_spam_quality' => 'balanced',
+ 'chatbot_quality' => 'balanced',
+ ];
+
+ // Get all board IDs (including default board 0)
+ $boardids = [ 0 ];
+ if( class_exists( 'wpforo\classes\Board' ) && method_exists( WPF()->board, 'get_active_boardids' ) ) {
+ $boardids = array_unique( array_merge( $boardids, (array) WPF()->board->get_active_boardids() ) );
+ }
+
+ foreach( $boardids as $boardid ) {
+ // Build option name: wpforo_ai (board 0) or wpforo_{boardid}_ai (other boards)
+ $option_name = 'wpforo_' . ( $boardid ? $boardid . '_' : '' ) . 'ai';
+ $ai_settings = get_option( $option_name, [] );
+
+ // Skip if no settings saved (new install, will use new defaults)
+ if( empty( $ai_settings ) ) {
+ continue;
+ }
+
+ // Update only the quality settings
+ $updated = false;
+ foreach( $quality_changes as $key => $new_value ) {
+ // Only update if the key exists (user has this setting saved)
+ if( isset( $ai_settings[ $key ] ) ) {
+ $ai_settings[ $key ] = $new_value;
+ $updated = true;
+ }
+ }
+
+ // Save updated settings
+ if( $updated ) {
+ update_option( $option_name, $ai_settings );
+ }
+ }
+
+ // Mark migration as complete
+ update_option( 'wpforo_ai_quality_defaults_migrated', 1 );
+}
--- a/wpforo/themes/2026/layouts/5/forum.php
+++ b/wpforo/themes/2026/layouts/5/forum.php
@@ -80,7 +80,8 @@
?>
<div id="wpf-forum-<?php echo intval( $forum['forumid'] ) ?>" class="wpforo-forum-card <?php wpforo_unread( $forum['forumid'], 'forum' ) ?>">
- <!-- Card Cover with Stats Overlay -->
+ <!-- Card Cover with Stats Overlay (Clickable) -->
+ <a href="<?php echo esc_url( (string) $forum_url ); ?>" class="forum-card-cover-link">
<div class="forum-card-cover" <?php if( $cover_image_url ): ?>style="background-image: url('<?php echo esc_url( $cover_image_url ); ?>');"<?php else: ?>style="background: linear-gradient(135deg, <?php echo esc_attr( $forum['color'] ); ?>55 0%, <?php echo esc_attr( $forum['color'] ); ?>99 100%);"<?php endif; ?>>
<!-- Stats and Avatars Overlay -->
@@ -116,6 +117,7 @@
<?php endif; ?>
</div>
</div>
+ </a>
<!-- Card Info (Icon + Title + Description) -->
<div class="forum-card-info">
--- a/wpforo/wpforo.php
+++ b/wpforo/wpforo.php
@@ -5,7 +5,7 @@
* Description: WordPress Forum plugin. wpForo is the only AI powered forum solution for your community. Modern design and 5 forum layouts.
* Author: gVectors Team
* Author URI: https://gvectors.com/
-* Version: 3.0.2
+* Version: 3.0.3
* Requires at least: 5.2
* Requires PHP: 7.1
* Text Domain: wpforo
@@ -14,7 +14,7 @@
namespace wpforo;
-define( 'WPFORO_VERSION', '3.0.2' );
+define( 'WPFORO_VERSION', '3.0.3' );
//Exit if accessed directly
if( ! defined( 'ABSPATH' ) ) exit;