| @@ -9,27 +9,11 @@ | ||
| 9 | 9 | exit; // Exit if accessed directly |
| 10 | 10 | } |
| 11 | 11 | |
| 12 | 12 | class MxChat_Knowledge_Manager { |
| 13 | - | |
| 13 | + | |
| 14 | 14 | private $options; |
| 15 | - | |
| 16 | - // Post IDs whose vectors were already deleted by mxchat_handle_status_transition this | |
| 17 | - // request, so the transient-based branch in mxchat_handle_post_update can skip the | |
| 18 | - // redundant (idempotent but network-visible) second deletion. | |
| 19 | - private $transition_deleted_posts = array(); | |
| 20 | - | |
| 21 | - // Post IDs already INDEXED by mxchat_handle_status_transition's arrival edge this | |
| 22 | - // request. Normal editor publishes fire transition_post_status first, then | |
| 23 | - // post_updated — without this guard every editor publish would embed twice. | |
| 24 | - private $transition_indexed_posts = array(); | |
| 25 | - | |
| 26 | - // Post IDs core has announced an in-flight UPDATE for. pre_post_update fires only | |
| 27 | - // inside wp_insert_post's update branch and always before wp_transition_post_status, | |
| 28 | - // so this is an exact "a post_updated is coming later this request" signal — which is | |
| 29 | - // what makes it safe to arm transition_indexed_posts (plan a664f3). | |
| 30 | - private $pending_post_update = array(); | |
| 31 | - | |
| 15 | + | |
| 32 | 16 | /** |
| 33 | 17 | * Constructor - Register hooks for content processing |
| 34 | 18 | */ |
| 35 | 19 | public function __construct() { |
| @@ -47,9 +31,8 @@ | ||
| 47 | 31 | // Admin post handlers for form submissions |
| 48 | 32 | add_action('admin_post_mxchat_submit_content', array($this, 'mxchat_handle_content_submission')); |
| 49 | 33 | add_action('admin_post_mxchat_submit_sitemap', array($this, 'mxchat_handle_sitemap_submission')); |
| 50 | 34 | add_action('admin_post_mxchat_submit_pdf_file', array($this, 'mxchat_handle_pdf_file_submission')); |
| 51 | - add_action('admin_post_mxchat_submit_youtube', array($this, 'mxchat_handle_youtube_submission')); | |
| 52 | 35 | add_action('admin_post_mxchat_stop_processing', array($this, 'mxchat_stop_processing')); |
| 53 | 36 | |
| 54 | 37 | // AJAX handlers for real-time processing and status updates |
| 55 | 38 | add_action('wp_ajax_mxchat_get_status_updates', array($this, 'mxchat_ajax_get_status_updates')); |
| @@ -75,31 +58,22 @@ | ||
| 75 | 58 | add_action('wp_ajax_mxchat_refresh_pinecone_entries', array($this, 'ajax_mxchat_refresh_pinecone_entries')); |
| 76 | 59 | add_action('wp_ajax_mxchat_paginate_entries', array($this, 'ajax_mxchat_paginate_entries')); |
| 77 | 60 | add_action('wp_ajax_mxchat_get_entry_content', array($this, 'ajax_mxchat_get_entry_content')); |
| 78 | 61 | add_action('wp_ajax_mxchat_save_entry_content', array($this, 'ajax_mxchat_save_entry_content')); |
| 79 | - add_action('wp_ajax_mxchat_inspect_entry', array($this, 'ajax_mxchat_inspect_entry')); | |
| 80 | 62 | |
| 63 | + // Hook for content deletion | |
| 64 | + add_action('mxchat_delete_content', array($this, 'mxchat_delete_from_pinecone_by_url'), 10, 1); | |
| 65 | + | |
| 81 | 66 | // WordPress post management hooks |
| 82 | 67 | add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2); |
| 83 | 68 | add_action('post_updated', array($this, 'mxchat_handle_post_update'), 10, 3); |
| 84 | 69 | add_action('before_delete_post', array($this, 'mxchat_handle_post_delete')); |
| 85 | 70 | add_action('wp_trash_post', array($this, 'mxchat_handle_post_delete')); |
| 86 | - // Authoritative unpublish detection: core hands this hook the REAL previous status, so | |
| 87 | - // removal no longer depends on the mxchat_prev_status_* transients (evictable by persistent | |
| 88 | - // object caches, never written by paths that bypass wp_update_post, e.g. plugins flipping | |
| 89 | - // post_status directly and calling wp_transition_post_status themselves). | |
| 90 | - add_action('transition_post_status', array($this, 'mxchat_handle_status_transition'), 10, 3); | |
| 91 | 71 | |
| 92 | 72 | // ACF hook - fires AFTER ACF fields are saved, ensuring ACF data is available |
| 93 | 73 | // Priority 20 to run after ACF's own save (which runs at priority 10) |
| 94 | 74 | add_action('acf/save_post', array($this, 'mxchat_handle_acf_save'), 20); |
| 95 | 75 | |
| 96 | - // One-time cleanup for vectors orphaned by unpublishes that predate the | |
| 97 | - // transition_post_status handler (plan 816fb1): wp mxchat prune-unpublished | |
| 98 | - if (defined('WP_CLI') && WP_CLI) { | |
| 99 | - WP_CLI::add_command('mxchat prune-unpublished', array($this, 'cli_prune_unpublished')); | |
| 100 | - } | |
| 101 | - | |
| 102 | 76 | add_action('wp_ajax_mxchat_mark_queue_complete', array($this, 'ajax_mxchat_mark_queue_complete')); |
| 103 | 77 | |
| 104 | 78 | // WooCommerce product hooks (if WooCommerce is active) |
| 105 | 79 | if (class_exists('WooCommerce')) { |
| @@ -147,17 +121,26 @@ | ||
| 147 | 121 | |
| 148 | 122 | // Get bot-specific options and API key |
| 149 | 123 | $bot_options = $this->get_bot_options($bot_id); |
| 150 | 124 | $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options'); |
| 151 | - | |
| 152 | - // Custom-provider-aware decision; keyless custom sites must pass (plan cbd5fd). | |
| 153 | - $preflight = MxChat_Utils::embedding_preflight($options); | |
| 154 | - if (!$preflight['ok']) { | |
| 155 | - set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30); | |
| 125 | + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002'; | |
| 126 | + | |
| 127 | + if (strpos($selected_model, 'voyage') === 0) { | |
| 128 | + $api_key = $options['voyage_api_key'] ?? ''; | |
| 129 | + } elseif (strpos($selected_model, 'gemini-embedding') === 0) { | |
| 130 | + $api_key = $options['gemini_api_key'] ?? ''; | |
| 131 | + } else { | |
| 132 | + $api_key = $options['api_key'] ?? ''; | |
| 133 | + } | |
| 134 | + | |
| 135 | + if (empty($api_key)) { | |
| 136 | + set_transient('mxchat_admin_notice_error', | |
| 137 | + esc_html__('API key is not configured. Please add your API key in the settings before submitting content.', 'mxchat'), | |
| 138 | + 30 | |
| 139 | + ); | |
| 156 | 140 | wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts'))); |
| 157 | 141 | exit; |
| 158 | 142 | } |
| 159 | - $api_key = $preflight['api_key']; | |
| 160 | 143 | |
| 161 | 144 | // Use centralized utility function with bot_id |
| 162 | 145 | $result = MxChat_Utils::submit_content_to_db($article_content, $article_url, $api_key, null, $bot_id); |
| 163 | 146 | |
| @@ -176,247 +159,13 @@ | ||
| 176 | 159 | wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts'))); |
| 177 | 160 | exit; |
| 178 | 161 | } |
| 179 | 162 | |
| 180 | -/** | |
| 181 | - * Handle the "YouTube" KB import source (admin-post form submission). | |
| 182 | - * | |
| 183 | - * Per-video description mode: | |
| 184 | - * - auto: fetch oEmbed metadata (reliable) + best-effort captions transcript. | |
| 185 | - * If no usable transcript, index the metadata anyway, tell the admin, | |
| 186 | - * and bounce back with the manual box pre-filled (never fail silently). | |
| 187 | - * - manual: the admin's own description is what gets indexed; metadata rides along. | |
| 188 | - * | |
| 189 | - * The row is stored with content_type 'youtube' and source_url = the canonical | |
| 190 | - * watch URL, so re-importing the same video UPDATES the entry (source_url | |
| 191 | - * duplicate handling in MxChat_Utils::store_in_wordpress_db) — that is also the | |
| 192 | - * "augment a metadata-only entry" path. | |
| 193 | - */ | |
| 194 | -public function mxchat_handle_youtube_submission() { | |
| 195 | - if (!isset($_POST['submit_youtube']) || !current_user_can('manage_options')) { | |
| 196 | - wp_die(esc_html__('Unauthorized access', 'mxchat')); | |
| 197 | - } | |
| 198 | - | |
| 199 | - check_admin_referer('mxchat_submit_youtube_action', 'mxchat_submit_youtube_nonce'); | |
| 200 | - | |
| 201 | - $redirect_url = admin_url('admin.php?page=mxchat-prompts'); | |
| 202 | - | |
| 203 | - $youtube_url = isset($_POST['youtube_url']) ? esc_url_raw(wp_unslash($_POST['youtube_url'])) : ''; | |
| 204 | - $video_id = MxChat_Utils::parse_youtube_id($youtube_url); | |
| 205 | - | |
| 206 | - if (empty($video_id)) { | |
| 207 | - set_transient('mxchat_admin_notice_error', | |
| 208 | - esc_html__('That does not look like a link to a single YouTube video. Please paste a watch, youtu.be, or Shorts URL.', 'mxchat'), | |
| 209 | - 30 | |
| 210 | - ); | |
| 211 | - wp_safe_redirect(esc_url($redirect_url)); | |
| 212 | - exit; | |
| 213 | - } | |
| 214 | - | |
| 215 | - $canonical_url = 'https://www.youtube.com/watch?v=' . $video_id; | |
| 216 | - | |
| 217 | - $description_mode = (isset($_POST['youtube_description_mode']) && $_POST['youtube_description_mode'] === 'manual') ? 'manual' : 'auto'; | |
| 218 | - $manual_description = isset($_POST['youtube_description']) ? trim(wp_kses_post(wp_unslash($_POST['youtube_description']))) : ''; | |
| 219 | - | |
| 220 | - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default'; | |
| 221 | - | |
| 222 | - // Resolve the embedding decision exactly like the sibling handlers — | |
| 223 | - // custom-provider-aware (plan cbd5fd). | |
| 224 | - $bot_options = $this->get_bot_options($bot_id); | |
| 225 | - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options'); | |
| 226 | - | |
| 227 | - $preflight = MxChat_Utils::embedding_preflight($options); | |
| 228 | - if (!$preflight['ok']) { | |
| 229 | - set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30); | |
| 230 | - wp_safe_redirect(esc_url($redirect_url)); | |
| 231 | - exit; | |
| 232 | - } | |
| 233 | - $api_key = $preflight['api_key']; | |
| 234 | - | |
| 235 | - // Metadata is fetched in BOTH modes — it is the reliable half of auto, and in | |
| 236 | - // manual mode it enriches the indexed text with the real title/channel. | |
| 237 | - $meta = $this->mxchat_fetch_youtube_oembed($video_id); | |
| 238 | - $video_title = isset($meta['title']) ? sanitize_text_field($meta['title']) : ''; | |
| 239 | - $video_channel = isset($meta['author_name']) ? sanitize_text_field($meta['author_name']) : ''; | |
| 240 | - | |
| 241 | - $header_lines = 'YouTube Video: ' . ($video_title !== '' ? $video_title : $canonical_url) . "\n"; | |
| 242 | - if ($video_channel !== '') { | |
| 243 | - $header_lines .= 'Channel: ' . $video_channel . "\n"; | |
| 244 | - } | |
| 245 | - $header_lines .= 'URL: ' . $canonical_url . "\n\n"; | |
| 246 | - | |
| 247 | - $transcript_missing = false; | |
| 248 | - | |
| 249 | - if ($description_mode === 'manual') { | |
| 250 | - if ($manual_description === '') { | |
| 251 | - set_transient('mxchat_admin_notice_error', | |
| 252 | - esc_html__('Please write a description for the video, or switch to auto-fetch.', 'mxchat'), | |
| 253 | - 30 | |
| 254 | - ); | |
| 255 | - wp_safe_redirect(esc_url($redirect_url)); | |
| 256 | - exit; | |
| 257 | - } | |
| 258 | - $indexed_text = $header_lines . $manual_description; | |
| 259 | - } else { | |
| 260 | - $transcript = $this->mxchat_fetch_youtube_transcript($video_id); | |
| 261 | - | |
| 262 | - if (strlen($transcript) >= 200) { | |
| 263 | - $indexed_text = $header_lines . $transcript; | |
| 264 | - } else { | |
| 265 | - // Graceful fallback: captions disabled / blocked / no speech. Auto | |
| 266 | - // reliably gets metadata; it does NOT guarantee a transcript. | |
| 267 | - $transcript_missing = true; | |
| 268 | - | |
| 269 | - if ($video_title === '' && $video_channel === '') { | |
| 270 | - // Both halves failed — nothing meaningful to index. | |
| 271 | - set_transient('mxchat_admin_notice_error', | |
| 272 | - esc_html__('Could not retrieve any information for that video (no metadata and no captions). Please check the URL, or use the manual description option.', 'mxchat'), | |
| 273 | - 30 | |
| 274 | - ); | |
| 275 | - wp_safe_redirect(esc_url($redirect_url)); | |
| 276 | - exit; | |
| 277 | - } | |
| 278 | - | |
| 279 | - $indexed_text = $header_lines . sprintf( | |
| 280 | - /* translators: 1: video title, 2: channel name */ | |
| 281 | - __('A YouTube video titled "%1$s" from the channel %2$s.', 'mxchat'), | |
| 282 | - $video_title !== '' ? $video_title : $canonical_url, | |
| 283 | - $video_channel !== '' ? $video_channel : 'YouTube' | |
| 284 | - ); | |
| 285 | - } | |
| 286 | - } | |
| 287 | - | |
| 288 | - $result = MxChat_Utils::submit_content_to_db($indexed_text, $canonical_url, $api_key, null, $bot_id, 'youtube'); | |
| 289 | - | |
| 290 | - if (is_wp_error($result)) { | |
| 291 | - set_transient('mxchat_admin_notice_error', | |
| 292 | - esc_html__('Error storing video in the knowledge base: ', 'mxchat') . $result->get_error_message(), | |
| 293 | - 30 | |
| 294 | - ); | |
| 295 | - wp_safe_redirect(esc_url($redirect_url)); | |
| 296 | - exit; | |
| 297 | - } | |
| 298 | - | |
| 299 | - if ($transcript_missing) { | |
| 300 | - set_transient('mxchat_admin_notice_success', | |
| 301 | - esc_html__('Video indexed from its title and channel — no captions were available for a transcript. The form below is pre-filled: write your own description and import again to improve matching (it updates the same entry).', 'mxchat'), | |
| 302 | - 30 | |
| 303 | - ); | |
| 304 | - // Bounce back with prefill args so the page reopens the YouTube form in | |
| 305 | - // manual mode with the URL + fetched title ready to augment. | |
| 306 | - $redirect_url = add_query_arg(array( | |
| 307 | - 'mxchat_yt_prefill' => '1', | |
| 308 | - 'yt_url' => rawurlencode($canonical_url), | |
| 309 | - 'yt_title' => rawurlencode($video_title), | |
| 310 | - ), $redirect_url); | |
| 311 | - } else { | |
| 312 | - set_transient('mxchat_admin_notice_success', | |
| 313 | - esc_html__('YouTube video successfully added to the knowledge base!', 'mxchat'), | |
| 314 | - 30 | |
| 315 | - ); | |
| 316 | - } | |
| 317 | - | |
| 318 | - wp_safe_redirect(esc_url_raw($redirect_url)); | |
| 319 | - exit; | |
| 320 | -} | |
| 321 | - | |
| 322 | -/** | |
| 323 | - * Fetch YouTube oEmbed metadata for a video (no API key required). | |
| 324 | - * Returns the decoded array (title, author_name, thumbnail_url, ...) or array(). | |
| 325 | - */ | |
| 326 | -private function mxchat_fetch_youtube_oembed($video_id) { | |
| 327 | - $oembed_url = 'https://www.youtube.com/oembed?url=' . rawurlencode('https://www.youtube.com/watch?v=' . $video_id) . '&format=json'; | |
| 328 | - $response = wp_remote_get($oembed_url, array('timeout' => 15)); | |
| 329 | - if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) { | |
| 330 | - return array(); | |
| 331 | - } | |
| 332 | - $data = json_decode(wp_remote_retrieve_body($response), true); | |
| 333 | - return is_array($data) ? $data : array(); | |
| 334 | -} | |
| 335 | - | |
| 336 | -/** | |
| 337 | - * Best-effort captions transcript for a video. Deliberately ISOLATED: this uses | |
| 338 | - * YouTube's unofficial timedtext route (the caption track list embedded in the | |
| 339 | - * watch page), which YouTube has broken before and will break again. Every | |
| 340 | - * failure mode returns '' so a break degrades to the metadata-only import path | |
| 341 | - * instead of erroring the whole submission. Do not let anything in here throw. | |
| 342 | - */ | |
| 343 | -private function mxchat_fetch_youtube_transcript($video_id) { | |
| 344 | - $watch_url = 'https://www.youtube.com/watch?v=' . $video_id . '&hl=en'; | |
| 345 | - | |
| 346 | - // First try the honest ingest UA; some responses omit the player config for | |
| 347 | - // bot UAs, so retry once with a browser UA before giving up. | |
| 348 | - $user_agents = array( | |
| 349 | - mxchat_ingest_user_agent(), | |
| 350 | - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', | |
| 351 | - ); | |
| 352 | - | |
| 353 | - $tracks = array(); | |
| 354 | - foreach ($user_agents as $ua) { | |
| 355 | - $response = wp_remote_get($watch_url, array( | |
| 356 | - 'timeout' => 20, | |
| 357 | - 'user-agent' => $ua, | |
| 358 | - )); | |
| 359 | - if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) { | |
| 360 | - continue; | |
| 361 | - } | |
| 362 | - $body = wp_remote_retrieve_body($response); | |
| 363 | - if (!is_string($body) || $body === '' || !preg_match('/"captionTracks":(\[.*?\])(?=,")/s', $body, $m)) { | |
| 364 | - continue; | |
| 365 | - } | |
| 366 | - $decoded = json_decode($m[1], true); | |
| 367 | - if (is_array($decoded) && !empty($decoded)) { | |
| 368 | - $tracks = $decoded; | |
| 369 | - break; | |
| 370 | - } | |
| 371 | - } | |
| 372 | - | |
| 373 | - if (empty($tracks)) { | |
| 374 | - return ''; | |
| 375 | - } | |
| 376 | - | |
| 377 | - // Prefer an English track, else take the first offered. | |
| 378 | - $chosen = null; | |
| 379 | - foreach ($tracks as $track) { | |
| 380 | - if (isset($track['languageCode']) && strpos($track['languageCode'], 'en') === 0) { | |
| 381 | - $chosen = $track; | |
| 382 | - break; | |
| 383 | - } | |
| 384 | - } | |
| 385 | - if ($chosen === null) { | |
| 386 | - $chosen = $tracks[0]; | |
| 387 | - } | |
| 388 | - if (empty($chosen['baseUrl']) || !is_string($chosen['baseUrl'])) { | |
| 389 | - return ''; | |
| 390 | - } | |
| 391 | - | |
| 392 | - $timedtext = wp_remote_get($chosen['baseUrl'], array('timeout' => 20)); | |
| 393 | - if (is_wp_error($timedtext) || wp_remote_retrieve_response_code($timedtext) !== 200) { | |
| 394 | - return ''; | |
| 395 | - } | |
| 396 | - $xml = wp_remote_retrieve_body($timedtext); | |
| 397 | - if (!is_string($xml) || strpos($xml, '<text') === false) { | |
| 398 | - return ''; | |
| 399 | - } | |
| 400 | - | |
| 401 | - // <text start=".." dur="..">caption</text> — strip tags, decode the | |
| 402 | - // double-encoded entities timedtext ships, collapse whitespace. | |
| 403 | - $text = preg_replace('/<[^>]+>/', ' ', $xml); | |
| 404 | - $text = html_entity_decode(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'), ENT_QUOTES | ENT_HTML5, 'UTF-8'); | |
| 405 | - $text = trim(preg_replace('/\s+/u', ' ', $text)); | |
| 406 | - | |
| 407 | - return $text; | |
| 408 | -} | |
| 409 | - | |
| 410 | 163 | public function mxchat_is_pdf_url($url, $response) { |
| 411 | 164 | $content_type = wp_remote_retrieve_header($response, 'content-type'); |
| 412 | 165 | $file_extension = strtolower(pathinfo($url, PATHINFO_EXTENSION)); |
| 413 | 166 | |
| 414 | - // Check Content-Disposition header for .pdf filename (Google Drive sends this) | |
| 415 | - $disposition = wp_remote_retrieve_header($response, 'content-disposition'); | |
| 416 | - $has_pdf_disposition = ! empty($disposition) && stripos($disposition, '.pdf') !== false; | |
| 417 | - | |
| 418 | - return strpos($content_type, 'pdf') !== false || $file_extension === 'pdf' || $has_pdf_disposition; | |
| 167 | + return strpos($content_type, 'pdf') !== false || $file_extension === 'pdf'; | |
| 419 | 168 | } |
| 420 | 169 | |
| 421 | 170 | |
| 422 | 171 | public function mxchat_handle_pdf_for_knowledge_base($pdf_url, $response, $bot_id = 'default') { |
| @@ -1064,230 +813,8 @@ | ||
| 1064 | 813 | ); |
| 1065 | 814 | } |
| 1066 | 815 | |
| 1067 | 816 | /** |
| 1068 | - * AJAX: Inspect a knowledge entry — returns the per-chunk stored text + metadata | |
| 1069 | - * WITHOUT collapsing it, so a site owner can see exactly what was indexed for an | |
| 1070 | - * entry (plan-mxchat-20260628-d8cb4b). READ-ONLY: never re-embeds or mutates. | |
| 1071 | - */ | |
| 1072 | -public function ajax_mxchat_inspect_entry() { | |
| 1073 | - check_ajax_referer('mxchat_inspect_entry_nonce', 'nonce'); | |
| 1074 | - | |
| 1075 | - if ( ! current_user_can('manage_options') ) { | |
| 1076 | - wp_send_json_error( array( 'message' => esc_html__('Permission denied.', 'mxchat') ) ); | |
| 1077 | - } | |
| 1078 | - | |
| 1079 | - $source_url = isset($_POST['source_url']) ? sanitize_text_field( wp_unslash($_POST['source_url']) ) : ''; | |
| 1080 | - $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0; | |
| 1081 | - $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress'; | |
| 1082 | - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default'; | |
| 1083 | - | |
| 1084 | - if ( $data_source === 'pinecone' ) { | |
| 1085 | - $result = $this->inspect_pinecone_entry( $source_url, $entry_id, $bot_id ); | |
| 1086 | - } else { | |
| 1087 | - $result = $this->inspect_wordpress_entry( $source_url, $entry_id ); | |
| 1088 | - } | |
| 1089 | - | |
| 1090 | - if ( is_wp_error( $result ) ) { | |
| 1091 | - wp_send_json_error( array( 'message' => $result->get_error_message() ) ); | |
| 1092 | - } | |
| 1093 | - | |
| 1094 | - wp_send_json_success( $result ); | |
| 1095 | -} | |
| 1096 | - | |
| 1097 | -/** | |
| 1098 | - * Read-only inspector for WordPress-DB entries. Mirrors get_wordpress_entry_content() | |
| 1099 | - * but returns each STORED chunk's exact text + length (no implode), plus the assembled | |
| 1100 | - * embedded text. This shows what is actually in the index, not a re-derivation from the post. | |
| 1101 | - */ | |
| 1102 | -private function inspect_wordpress_entry( $source_url, $entry_id ) { | |
| 1103 | - global $wpdb; | |
| 1104 | - $table = $wpdb->prefix . 'mxchat_system_prompt_content'; | |
| 1105 | - | |
| 1106 | - $rows = array(); | |
| 1107 | - | |
| 1108 | - // Group by the real stored source_url — this INCLUDES "mxchat://" manual | |
| 1109 | - // Direct Content entries (the spec's manual-entry case), which share one | |
| 1110 | - // source_url across their chunk rows. Only the synthetic "_ungrouped_<id>" | |
| 1111 | - // display key (invented by the table view for rows with no source_url) is | |
| 1112 | - // excluded; those fall through to the entry_id lookup below. | |
| 1113 | - if ( ! empty( $source_url ) && strpos( $source_url, '_ungrouped_' ) !== 0 ) { | |
| 1114 | - $rows = $wpdb->get_results( $wpdb->prepare( | |
| 1115 | - "SELECT id, article_content, source_url, content_type FROM {$table} WHERE source_url = %s ORDER BY id ASC", | |
| 1116 | - $source_url | |
| 1117 | - ) ); | |
| 1118 | - } | |
| 1119 | - | |
| 1120 | - // Fallback / manual "Direct Content" entries: fetch the single row by id. | |
| 1121 | - if ( empty( $rows ) && $entry_id > 0 ) { | |
| 1122 | - $row = $wpdb->get_row( $wpdb->prepare( | |
| 1123 | - "SELECT id, article_content, source_url, content_type FROM {$table} WHERE id = %d", | |
| 1124 | - $entry_id | |
| 1125 | - ) ); | |
| 1126 | - if ( $row ) { | |
| 1127 | - $rows = array( $row ); | |
| 1128 | - } | |
| 1129 | - } | |
| 1130 | - | |
| 1131 | - if ( empty( $rows ) ) { | |
| 1132 | - return new WP_Error( 'not_found', esc_html__('Entry not found in the local knowledge database.', 'mxchat') ); | |
| 1133 | - } | |
| 1134 | - | |
| 1135 | - $chunks = array(); | |
| 1136 | - $content_type = ''; | |
| 1137 | - foreach ( $rows as $row ) { | |
| 1138 | - $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content ); | |
| 1139 | - $text = isset( $parsed['text'] ) ? $parsed['text'] : ''; | |
| 1140 | - $index = isset( $parsed['metadata']['chunk_index'] ) ? intval( $parsed['metadata']['chunk_index'] ) : count( $chunks ); | |
| 1141 | - $content_type = $row->content_type; | |
| 1142 | - $chunks[] = array( | |
| 1143 | - 'index' => $index, | |
| 1144 | - 'text' => $text, | |
| 1145 | - 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ), | |
| 1146 | - 'row_id' => intval( $row->id ), | |
| 1147 | - ); | |
| 1148 | - } | |
| 1149 | - | |
| 1150 | - usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } ); | |
| 1151 | - | |
| 1152 | - $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) ); | |
| 1153 | - | |
| 1154 | - return array( | |
| 1155 | - 'store' => 'wordpress', | |
| 1156 | - 'source_url' => $source_url, | |
| 1157 | - 'content_type' => $content_type, | |
| 1158 | - 'is_chunked' => count( $chunks ) > 1, | |
| 1159 | - 'chunk_count' => count( $chunks ), | |
| 1160 | - 'assembled' => $assembled, | |
| 1161 | - 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ), | |
| 1162 | - 'chunks' => array_values( $chunks ), | |
| 1163 | - // WP-DB storage carries no separate vector metadata; surface that fact | |
| 1164 | - // rather than letting the owner guess (the spec's taxonomy question). | |
| 1165 | - 'metadata' => array(), | |
| 1166 | - 'metadata_note' => esc_html__('Stored in the local WordPress database. Only the assembled text shown here is embedded — there are no separate vector metadata fields (e.g. taxonomy terms are not stored unless they were injected into the text itself).', 'mxchat'), | |
| 1167 | - ); | |
| 1168 | -} | |
| 1169 | - | |
| 1170 | -/** | |
| 1171 | - * Read-only inspector for Pinecone entries. Mirrors get_pinecone_entry_content() | |
| 1172 | - * but keeps each vector's text + metadata instead of imploding, so the owner can | |
| 1173 | - * confirm exactly which metadata fields (text/source_url/type/last_updated/created_at/bot_id) | |
| 1174 | - * are present per chunk. READ-ONLY. | |
| 1175 | - */ | |
| 1176 | -private function inspect_pinecone_entry( $source_url, $entry_id, $bot_id ) { | |
| 1177 | - if ( ! class_exists('MxChat_Pinecone_Manager') ) { | |
| 1178 | - return new WP_Error( 'pinecone_unavailable', esc_html__('Pinecone manager not available.', 'mxchat') ); | |
| 1179 | - } | |
| 1180 | - | |
| 1181 | - if ( $bot_id === 'default' || ! class_exists('MxChat_Multi_Bot_Manager') ) { | |
| 1182 | - $pinecone_options = get_option('mxchat_pinecone_addon_options'); | |
| 1183 | - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? ''; | |
| 1184 | - $host = $pinecone_options['mxchat_pinecone_host'] ?? ''; | |
| 1185 | - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? ''; | |
| 1186 | - } else { | |
| 1187 | - $bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id); | |
| 1188 | - $api_key = $bot_config['api_key'] ?? ''; | |
| 1189 | - $host = $bot_config['host'] ?? ''; | |
| 1190 | - $namespace = $bot_config['namespace'] ?? ''; | |
| 1191 | - } | |
| 1192 | - | |
| 1193 | - if ( empty($host) || empty($api_key) ) { | |
| 1194 | - return new WP_Error( 'pinecone_config', esc_html__('Pinecone not configured.', 'mxchat') ); | |
| 1195 | - } | |
| 1196 | - | |
| 1197 | - $base_id = md5( $source_url ); | |
| 1198 | - $vector_ids = array( $base_id ); | |
| 1199 | - | |
| 1200 | - $list_url = "https://{$host}/vectors/list"; | |
| 1201 | - $list_body = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 ); | |
| 1202 | - if ( ! empty($namespace) ) { | |
| 1203 | - $list_body['namespace'] = $namespace; | |
| 1204 | - } | |
| 1205 | - | |
| 1206 | - $list_resp = wp_remote_post( $list_url, array( | |
| 1207 | - 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ), | |
| 1208 | - 'body' => wp_json_encode( $list_body ), | |
| 1209 | - 'timeout' => 15, | |
| 1210 | - ) ); | |
| 1211 | - | |
| 1212 | - if ( ! is_wp_error($list_resp) ) { | |
| 1213 | - $list_data = json_decode( wp_remote_retrieve_body($list_resp), true ); | |
| 1214 | - if ( ! empty($list_data['vectors']) ) { | |
| 1215 | - foreach ( $list_data['vectors'] as $v ) { | |
| 1216 | - $vector_ids[] = $v['id']; | |
| 1217 | - } | |
| 1218 | - } | |
| 1219 | - } | |
| 1220 | - | |
| 1221 | - $fetch_url = "https://{$host}/vectors/fetch"; | |
| 1222 | - $fetch_body = array( 'ids' => $vector_ids ); | |
| 1223 | - if ( ! empty($namespace) ) { | |
| 1224 | - $fetch_body['namespace'] = $namespace; | |
| 1225 | - } | |
| 1226 | - | |
| 1227 | - $fetch_resp = wp_remote_post( $fetch_url, array( | |
| 1228 | - 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ), | |
| 1229 | - 'body' => wp_json_encode( $fetch_body ), | |
| 1230 | - 'timeout' => 15, | |
| 1231 | - ) ); | |
| 1232 | - | |
| 1233 | - if ( is_wp_error($fetch_resp) ) { | |
| 1234 | - return new WP_Error( 'pinecone_fetch', esc_html__('Failed to fetch from Pinecone.', 'mxchat') ); | |
| 1235 | - } | |
| 1236 | - | |
| 1237 | - $fetch_data = json_decode( wp_remote_retrieve_body($fetch_resp), true ); | |
| 1238 | - $vectors = $fetch_data['vectors'] ?? array(); | |
| 1239 | - | |
| 1240 | - if ( empty($vectors) ) { | |
| 1241 | - return new WP_Error( 'not_found', esc_html__('Entry not found in Pinecone.', 'mxchat') ); | |
| 1242 | - } | |
| 1243 | - | |
| 1244 | - // Whitelisted metadata fields the spec calls out — shown so devs can confirm | |
| 1245 | - // what is (and is NOT) stored per vector. | |
| 1246 | - $meta_fields = array( 'text', 'source_url', 'type', 'last_updated', 'created_at', 'bot_id', 'chunk_index', 'total_chunks' ); | |
| 1247 | - $chunks = array(); | |
| 1248 | - $content_type = ''; | |
| 1249 | - foreach ( $vectors as $vid => $vector ) { | |
| 1250 | - $meta = isset($vector['metadata']) && is_array($vector['metadata']) ? $vector['metadata'] : array(); | |
| 1251 | - $text = $meta['text'] ?? ''; | |
| 1252 | - $index = isset($meta['chunk_index']) ? intval($meta['chunk_index']) : count($chunks); | |
| 1253 | - $content_type = $meta['type'] ?? $content_type; | |
| 1254 | - | |
| 1255 | - $clean_meta = array(); | |
| 1256 | - foreach ( $meta_fields as $field ) { | |
| 1257 | - if ( array_key_exists( $field, $meta ) && $field !== 'text' ) { | |
| 1258 | - $clean_meta[ $field ] = is_scalar( $meta[ $field ] ) ? (string) $meta[ $field ] : wp_json_encode( $meta[ $field ] ); | |
| 1259 | - } | |
| 1260 | - } | |
| 1261 | - | |
| 1262 | - $chunks[] = array( | |
| 1263 | - 'index' => $index, | |
| 1264 | - 'text' => $text, | |
| 1265 | - 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ), | |
| 1266 | - 'vector_id' => (string) $vid, | |
| 1267 | - 'metadata' => $clean_meta, | |
| 1268 | - ); | |
| 1269 | - } | |
| 1270 | - | |
| 1271 | - usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } ); | |
| 1272 | - | |
| 1273 | - $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) ); | |
| 1274 | - | |
| 1275 | - return array( | |
| 1276 | - 'store' => 'pinecone', | |
| 1277 | - 'source_url' => $source_url, | |
| 1278 | - 'content_type' => $content_type, | |
| 1279 | - 'is_chunked' => count( $chunks ) > 1, | |
| 1280 | - 'chunk_count' => count( $chunks ), | |
| 1281 | - 'assembled' => $assembled, | |
| 1282 | - 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ), | |
| 1283 | - 'chunks' => array_values( $chunks ), | |
| 1284 | - 'metadata' => array(), | |
| 1285 | - 'metadata_note' => esc_html__('Stored in Pinecone. Each chunk above lists the vector metadata fields actually present — if a field you expect (such as taxonomy terms) is missing here, it was not stored as metadata and is only searchable if it appears in the embedded text.', 'mxchat'), | |
| 1286 | - ); | |
| 1287 | -} | |
| 1288 | - | |
| 1289 | -/** | |
| 1290 | 817 | * AJAX: Save edited content — re-chunks and re-embeds as needed. |
| 1291 | 818 | * Works for both WordPress DB and Pinecone entries. |
| 1292 | 819 | */ |
| 1293 | 820 | public function ajax_mxchat_save_entry_content() { |
| @@ -1332,17 +859,10 @@ | ||
| 1332 | 859 | } |
| 1333 | 860 | |
| 1334 | 861 | // For manual entries (no source_url or mxchat:// prefix), delete the old entry by ID first |
| 1335 | 862 | // so submit_content_to_db creates a replacement instead of a duplicate |
| 1336 | - // Also treat legacy mxchat.ai source URLs as manual — old bug assigned the site URL to manual entries | |
| 1337 | - $is_legacy_manual = !empty($source_url) && strpos($source_url, 'mxchat.ai') !== false && strpos($source_url, 'mxchat://') !== 0; | |
| 1338 | - if ( $entry_id > 0 && (empty($source_url) || strpos($source_url, 'mxchat://') === 0 || $is_legacy_manual) ) { | |
| 863 | + if ( $entry_id > 0 && (empty($source_url) || strpos($source_url, 'mxchat://') === 0) ) { | |
| 1339 | 864 | $wpdb->delete( $table, array( 'id' => $entry_id ), array( '%d' ) ); |
| 1340 | - // Clear legacy URL so submit_content_to_db generates a unique mxchat:// identifier | |
| 1341 | - // instead of reusing the shared URL (which would mass-delete other entries with the same URL) | |
| 1342 | - if ( $is_legacy_manual ) { | |
| 1343 | - $source_url = ''; | |
| 1344 | - } | |
| 1345 | 865 | } |
| 1346 | 866 | |
| 1347 | 867 | // Use the existing submit_content_to_db which handles chunking, Pinecone, and WP DB |
| 1348 | 868 | $vector_id = ! empty($source_url) ? md5($source_url) : md5('mxchat_manual_' . $entry_id); |
| @@ -1419,53 +939,40 @@ | ||
| 1419 | 939 | exit; |
| 1420 | 940 | } |
| 1421 | 941 | |
| 1422 | 942 | $submitted_url = esc_url_raw($_POST['sitemap_url']); |
| 1423 | - | |
| 1424 | - // Convert Google Drive sharing URLs to direct download URLs | |
| 1425 | - if ( strpos($submitted_url, 'drive.google.com') !== false ) { | |
| 1426 | - $file_id = ''; | |
| 1427 | - if ( preg_match('/[?&]id=([a-zA-Z0-9_-]+)/', $submitted_url, $m) ) { | |
| 1428 | - $file_id = $m[1]; | |
| 1429 | - } elseif ( preg_match('#/file/d/([a-zA-Z0-9_-]+)#', $submitted_url, $m) ) { | |
| 1430 | - $file_id = $m[1]; | |
| 1431 | - } | |
| 1432 | - if ( ! empty($file_id) ) { | |
| 1433 | - $submitted_url = 'https://drive.google.com/uc?export=download&id=' . $file_id; | |
| 1434 | - } | |
| 1435 | - } | |
| 1436 | - | |
| 943 | + | |
| 1437 | 944 | // Get bot_id from form submission |
| 1438 | 945 | $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default'; |
| 1439 | 946 | |
| 1440 | - // Get bot-specific options and validate the embedding decision — | |
| 1441 | - // custom-provider-aware (plan cbd5fd). | |
| 947 | + // Get bot-specific options and validate API key | |
| 1442 | 948 | $bot_options = $this->get_bot_options($bot_id); |
| 1443 | 949 | $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options'); |
| 1444 | - | |
| 1445 | - $preflight = MxChat_Utils::embedding_preflight($options); | |
| 1446 | - if (!$preflight['ok']) { | |
| 1447 | - set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30); | |
| 950 | + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002'; | |
| 951 | + | |
| 952 | + if (strpos($selected_model, 'voyage') === 0) { | |
| 953 | + $api_key = $options['voyage_api_key'] ?? ''; | |
| 954 | + $provider_name = 'Voyage AI'; | |
| 955 | + } elseif (strpos($selected_model, 'gemini-embedding') === 0) { | |
| 956 | + $api_key = $options['gemini_api_key'] ?? ''; | |
| 957 | + $provider_name = 'Google Gemini'; | |
| 958 | + } else { | |
| 959 | + $api_key = $options['api_key'] ?? ''; | |
| 960 | + $provider_name = 'OpenAI'; | |
| 961 | + } | |
| 962 | + | |
| 963 | + if (empty($api_key)) { | |
| 964 | + $error_message = sprintf( | |
| 965 | + esc_html__('%s API key is not configured. Please add your API key in the settings before submitting content.', 'mxchat'), | |
| 966 | + $provider_name | |
| 967 | + ); | |
| 968 | + set_transient('mxchat_admin_notice_error', $error_message, 30); | |
| 1448 | 969 | wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts'))); |
| 1449 | 970 | exit; |
| 1450 | 971 | } |
| 1451 | - $api_key = $preflight['api_key']; | |
| 1452 | 972 | |
| 1453 | - // Fetch URL — send an honest, versioned MXChat crawler UA (not a spoofed | |
| 1454 | - // browser). Stale browser UAs are exactly what WAFs like SiteGround's | |
| 1455 | - // ModSecurity flag as scrapers, 403-ing the fetch (including PDFs served | |
| 1456 | - // from the site's own media library, which route through this same call). | |
| 1457 | - // See mxchat_ingest_user_agent(). Accept is kept for content negotiation; | |
| 1458 | - // the browser-only Accept-Language fingerprint is dropped so it stays | |
| 1459 | - // coherent with a bot identity. | |
| 1460 | - $response = wp_remote_get($submitted_url, array( | |
| 1461 | - 'timeout' => 30, | |
| 1462 | - 'sslverify' => false, | |
| 1463 | - 'user-agent' => mxchat_ingest_user_agent(), | |
| 1464 | - 'headers' => array( | |
| 1465 | - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', | |
| 1466 | - ), | |
| 1467 | - )); | |
| 973 | + // Fetch URL | |
| 974 | + $response = wp_remote_get($submitted_url, array('timeout' => 30)); | |
| 1468 | 975 | |
| 1469 | 976 | if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) { |
| 1470 | 977 | $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP Status: ' . wp_remote_retrieve_response_code($response); |
| 1471 | 978 | set_transient('mxchat_admin_notice_error', |
| @@ -1534,22 +1041,12 @@ | ||
| 1534 | 1041 | esc_html__('Sitemap queued for processing. Processing will start automatically.', 'mxchat'), |
| 1535 | 1042 | 30 |
| 1536 | 1043 | ); |
| 1537 | 1044 | } else { |
| 1538 | - // Surface the reason the handler already computed (embedding pre-flight, | |
| 1539 | - // empty sitemap, queue failure). The old message pointed at the status | |
| 1540 | - // area, which is empty on this path — nothing was ever queued. | |
| 1541 | - if (is_string($result) && $result !== '') { | |
| 1542 | - set_transient('mxchat_admin_notice_error', | |
| 1543 | - esc_html__('Failed to queue sitemap processing: ', 'mxchat') . esc_html($result), | |
| 1544 | - 30 | |
| 1545 | - ); | |
| 1546 | - } else { | |
| 1547 | - set_transient('mxchat_admin_notice_error', | |
| 1548 | - esc_html__('Failed to queue sitemap processing.', 'mxchat'), | |
| 1549 | - 30 | |
| 1550 | - ); | |
| 1551 | - } | |
| 1045 | + set_transient('mxchat_admin_notice_error', | |
| 1046 | + esc_html__('Failed to queue sitemap processing. Please check the status below for details.', 'mxchat'), | |
| 1047 | + 30 | |
| 1048 | + ); | |
| 1552 | 1049 | } |
| 1553 | 1050 | |
| 1554 | 1051 | wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts'))); |
| 1555 | 1052 | exit; |
| @@ -1737,20 +1234,14 @@ | ||
| 1737 | 1234 | |
| 1738 | 1235 | // Ensure valid UTF-8 encoding |
| 1739 | 1236 | $content = wp_check_invalid_utf8($content); |
| 1740 | 1237 | |
| 1741 | - // Remove extremely long runs with no whitespace (base64 blobs, minified JS). | |
| 1742 | - // Counts CHARACTERS (/u), and never strips a run containing characters from a | |
| 1743 | - // script written without spaces — Japanese, Chinese, Thai, Khmer, Lao, Myanmar — | |
| 1744 | - // where a normal paragraph is legitimately one unbroken run. | |
| 1745 | - $content = preg_replace_callback('/\S{300,}/u', function ($m) { | |
| 1746 | - return preg_match('/[\p{Han}\p{Hiragana}\p{Katakana}\p{Thai}\p{Khmer}\p{Lao}\p{Myanmar}]/u', $m[0]) ? $m[0] : ' '; | |
| 1747 | - }, $content); | |
| 1748 | - | |
| 1749 | - // Remove emoji/symbol blocks only — not the whole supplementary plane, which | |
| 1750 | - // also holds CJK Extension B ideographs used in real Chinese/Japanese names | |
| 1751 | - $content = preg_replace('/[\x{1F000}-\x{1F0FF}\x{1F300}-\x{1FAFF}]/u', '', $content); | |
| 1238 | + // Remove any extremely long strings without spaces (often garbage) | |
| 1239 | + $content = preg_replace('/\S{300,}/', ' ', $content); | |
| 1752 | 1240 | |
| 1241 | + // Replace problematic characters that often cause database issues | |
| 1242 | + $content = preg_replace('/[\x{10000}-\x{10FFFF}]/u', '', $content); // Remove emoji and other high Unicode characters | |
| 1243 | + | |
| 1753 | 1244 | // Replace any remaining potentially problematic characters with spaces |
| 1754 | 1245 | // BUT preserve newlines by temporarily replacing them |
| 1755 | 1246 | $content = str_replace("\n", "NEWLINE_PLACEHOLDER", $content); |
| 1756 | 1247 | $content = preg_replace('/[^\p{L}\p{N}\p{P}\p{Z}\p{Sm}]/u', ' ', $content); |
| @@ -1755,13 +1246,12 @@ | ||
| 1755 | 1246 | $content = str_replace("\n", "NEWLINE_PLACEHOLDER", $content); |
| 1756 | 1247 | $content = preg_replace('/[^\p{L}\p{N}\p{P}\p{Z}\p{Sm}]/u', ' ', $content); |
| 1757 | 1248 | $content = str_replace("NEWLINE_PLACEHOLDER", "\n", $content); |
| 1758 | 1249 | |
| 1759 | - // Limit to reasonable length if needed (byte limit — MySQL TEXT is byte-sized, | |
| 1760 | - // but cut on a character boundary so a multibyte char is never split mid-sequence) | |
| 1250 | + // Limit to reasonable length if needed | |
| 1761 | 1251 | $max_length = 65000; // Just under MySQL TEXT field limit |
| 1762 | 1252 | if (strlen($content) > $max_length) { |
| 1763 | - $content = mb_strcut($content, 0, $max_length, 'UTF-8'); | |
| 1253 | + $content = substr($content, 0, $max_length); | |
| 1764 | 1254 | } |
| 1765 | 1255 | |
| 1766 | 1256 | //error_log('[MXCHAT-SANITIZE] Sanitized content preview: ' . substr($content, 0, 500) . '...'); |
| 1767 | 1257 | return $content; |
| @@ -3316,12 +2806,11 @@ | ||
| 3316 | 2806 | foreach ($primary_indexes as $path => $source) { |
| 3317 | 2807 | $url = trailingslashit($site_url) . $path; |
| 3318 | 2808 | |
| 3319 | 2809 | $response = wp_remote_head($url, array( |
| 3320 | - 'timeout' => 10, | |
| 2810 | + 'timeout' => 3, // Short timeout | |
| 3321 | 2811 | 'sslverify' => false, |
| 3322 | - 'redirection' => 1, | |
| 3323 | - 'user-agent' => mxchat_ingest_user_agent(), | |
| 2812 | + 'redirection' => 1 | |
| 3324 | 2813 | )); |
| 3325 | 2814 | |
| 3326 | 2815 | if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) { |
| 3327 | 2816 | // Found a sitemap index - parse it to get sub-sitemaps |
| @@ -3379,14 +2868,10 @@ | ||
| 3379 | 2868 | private function parse_sitemap_index($url) { |
| 3380 | 2869 | $sub_sitemaps = array(); |
| 3381 | 2870 | |
| 3382 | 2871 | $response = wp_remote_get($url, array( |
| 3383 | - 'timeout' => 30, | |
| 3384 | - 'sslverify' => false, | |
| 3385 | - 'user-agent' => mxchat_ingest_user_agent(), | |
| 3386 | - 'headers' => array( | |
| 3387 | - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', | |
| 3388 | - ), | |
| 2872 | + 'timeout' => 5, | |
| 2873 | + 'sslverify' => false | |
| 3389 | 2874 | )); |
| 3390 | 2875 | |
| 3391 | 2876 | if (is_wp_error($response)) { |
| 3392 | 2877 | return $sub_sitemaps; |
| @@ -3437,14 +2922,10 @@ | ||
| 3437 | 2922 | * Get URL count from a sitemap |
| 3438 | 2923 | */ |
| 3439 | 2924 | private function get_sitemap_url_count($url) { |
| 3440 | 2925 | $response = wp_remote_get($url, array( |
| 3441 | - 'timeout' => 30, | |
| 3442 | - 'sslverify' => false, | |
| 3443 | - 'user-agent' => mxchat_ingest_user_agent(), | |
| 3444 | - 'headers' => array( | |
| 3445 | - 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', | |
| 3446 | - ), | |
| 2926 | + 'timeout' => 10, | |
| 2927 | + 'sslverify' => false | |
| 3447 | 2928 | )); |
| 3448 | 2929 | |
| 3449 | 2930 | if (is_wp_error($response)) { |
| 3450 | 2931 | return 0; |
| @@ -3467,11 +2948,10 @@ | ||
| 3467 | 2948 | $sitemaps = array(); |
| 3468 | 2949 | $robots_url = trailingslashit($site_url) . 'robots.txt'; |
| 3469 | 2950 | |
| 3470 | 2951 | $response = wp_remote_get($robots_url, array( |
| 3471 | - 'timeout' => 15, | |
| 3472 | - 'sslverify' => false, | |
| 3473 | - 'user-agent' => mxchat_ingest_user_agent(), | |
| 2952 | + 'timeout' => 5, | |
| 2953 | + 'sslverify' => false | |
| 3474 | 2954 | )); |
| 3475 | 2955 | |
| 3476 | 2956 | if (is_wp_error($response)) { |
| 3477 | 2957 | return $sitemaps; |
| @@ -3961,14 +3441,9 @@ | ||
| 3961 | 3441 | } |
| 3962 | 3442 | |
| 3963 | 3443 | // Get bot_id from request |
| 3964 | 3444 | $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default'; |
| 3965 | - | |
| 3966 | - // ACF→PDF extraction is an install-level setting (Knowledge → ACF Fields, | |
| 3967 | - // plan 11720c). The import modal shows a passive status line pointing | |
| 3968 | - // there; the old per-batch checkbox and its remembered default are gone. | |
| 3969 | - $extract_acf_pdfs = get_option('mxchat_acf_pdf_extraction', '0') === '1'; | |
| 3970 | - | |
| 3445 | + | |
| 3971 | 3446 | // Process only ONE post at a time to avoid request size issues |
| 3972 | 3447 | $post_id = reset($post_ids); |
| 3973 | 3448 | $post = get_post($post_id); |
| 3974 | 3449 | |
| @@ -3976,35 +3451,18 @@ | ||
| 3976 | 3451 | wp_send_json_error('Post not found'); |
| 3977 | 3452 | exit; |
| 3978 | 3453 | } |
| 3979 | 3454 | |
| 3980 | - /** | |
| 3981 | - * Allow developers to modify post data before processing into the knowledge base. | |
| 3982 | - * Applied on BOTH content-preparation paths (this manual bulk import and the | |
| 3983 | - * auto-sync path in mxchat_handle_post_update) with the same signature, so a | |
| 3984 | - * callback registered once covers every indexing route. Purely additive — | |
| 3985 | - * zero behaviour change when unhooked. | |
| 3986 | - * | |
| 3987 | - * @param WP_Post $post The post about to be indexed. | |
| 3988 | - * @param string $bot_id Bot context for this import. | |
| 3989 | - */ | |
| 3455 | + // Allow developers to modify post data before processing into knowledge base | |
| 3990 | 3456 | $post = apply_filters('mxchat_before_process_post', $post, $bot_id); |
| 3991 | - if (!($post instanceof WP_Post)) { | |
| 3992 | - $post = get_post($post_id); // defend against a bad callback return | |
| 3993 | - } | |
| 3994 | 3457 | |
| 3995 | 3458 | // Get content including title, short description (for WooCommerce), and main content |
| 3996 | 3459 | $content = $post->post_title . "\n\n"; |
| 3997 | 3460 | |
| 3998 | 3461 | // Add short description if it exists (WooCommerce products use post_excerpt for short description) |
| 3999 | - // Strip FIRST, then test: an excerpt that is nothing but shortcodes strips to | |
| 4000 | - // empty, and testing the raw value emitted a bare "Short Description: " label | |
| 4001 | - // with no value after it. Matches mxchat_index_published_post. | |
| 4002 | - // trim() only in the TEST — the emitted value is untouched, so a populated | |
| 4003 | - // excerpt is byte-identical to before. A whitespace-only excerpt is an empty | |
| 4004 | - // excerpt and must not produce a labelled line with nothing after it. | |
| 4005 | - $clean_excerpt = $this->strip_shortcode_tags_preserve_content($post->post_excerpt); | |
| 4006 | - if (trim($clean_excerpt) !== '') { | |
| 3462 | + if (!empty($post->post_excerpt)) { | |
| 3463 | + // Remove shortcode tags but preserve content inside them | |
| 3464 | + $clean_excerpt = $this->strip_shortcode_tags_preserve_content($post->post_excerpt); | |
| 4007 | 3465 | $content .= "Short Description: " . wp_strip_all_tags($clean_excerpt) . "\n\n"; |
| 4008 | 3466 | } |
| 4009 | 3467 | |
| 4010 | 3468 | // Add main content - remove shortcode tags but preserve content inside them |
| @@ -4089,88 +3547,25 @@ | ||
| 4089 | 3547 | } |
| 4090 | 3548 | } |
| 4091 | 3549 | } |
| 4092 | 3550 | |
| 4093 | - // For custom post types like job_listing, include additional fields | |
| 4094 | - // (verbatim parity with mxchat_index_published_post — a bulk import used to | |
| 4095 | - // index the body alone, losing location/type/company that auto-sync captured) | |
| 4096 | - if (get_post_type($post_id) === 'job_listing') { | |
| 4097 | - // Add job-specific meta if available | |
| 4098 | - $job_location = get_post_meta($post_id, '_job_location', true); | |
| 4099 | - if (!empty($job_location)) { | |
| 4100 | - $content .= "\n\nLocation: " . $job_location; | |
| 4101 | - } | |
| 4102 | - | |
| 4103 | - // Get job type terms | |
| 4104 | - $job_types = get_the_terms($post_id, 'job_listing_type'); | |
| 4105 | - if (!empty($job_types) && !is_wp_error($job_types)) { | |
| 4106 | - $types = array(); | |
| 4107 | - foreach ($job_types as $type) { | |
| 4108 | - $types[] = $type->name; | |
| 4109 | - } | |
| 4110 | - $content .= "\n\nJob Type: " . implode(', ', $types); | |
| 4111 | - } | |
| 4112 | - | |
| 4113 | - // Get company name if available | |
| 4114 | - $company_name = get_post_meta($post_id, '_company_name', true); | |
| 4115 | - if (!empty($company_name)) { | |
| 4116 | - $content .= "\n\nCompany: " . $company_name; | |
| 4117 | - } | |
| 4118 | - } | |
| 4119 | - | |
| 4120 | 3551 | // ADD ACF FIELDS SUPPORT |
| 4121 | 3552 | $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id); |
| 4122 | - $pdf_extracted_count = 0; | |
| 4123 | 3553 | if (!empty($acf_fields)) { |
| 4124 | 3554 | $acf_content_parts = array(); |
| 4125 | - $pdf_attachment_ids = array(); | |
| 4126 | - | |
| 3555 | + | |
| 4127 | 3556 | foreach ($acf_fields as $field_name => $field_value) { |
| 4128 | 3557 | $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id); |
| 4129 | - | |
| 3558 | + | |
| 4130 | 3559 | if (!empty($formatted_value)) { |
| 4131 | - // Both separators: a hyphenated ACF name should read as words, and | |
| 4132 | - // this is what mxchat_index_published_post already does. | |
| 4133 | - $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name)); | |
| 3560 | + $field_label = ucwords(str_replace('_', ' ', $field_name)); | |
| 4134 | 3561 | $acf_content_parts[] = $field_label . ": " . $formatted_value; |
| 4135 | 3562 | } |
| 4136 | - | |
| 4137 | - // Walk this field's value tree for any PDF attachment references and queue them for extraction. | |
| 4138 | - // Only when the user opted into ACF→PDF extraction for this batch; otherwise the ACF text | |
| 4139 | - // still lands in the KB but the heavier PDF parsing is skipped. | |
| 4140 | - if ($extract_acf_pdfs) { | |
| 4141 | - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids); | |
| 4142 | - } | |
| 4143 | 3563 | } |
| 4144 | - | |
| 3564 | + | |
| 4145 | 3565 | if (!empty($acf_content_parts)) { |
| 4146 | 3566 | $content .= "\n\n" . implode("\n", $acf_content_parts); |
| 4147 | 3567 | } |
| 4148 | - | |
| 4149 | - // Extract text from each unique PDF found in ACF fields and append as a labeled section | |
| 4150 | - if ($extract_acf_pdfs && !empty($pdf_attachment_ids)) { | |
| 4151 | - $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids))); | |
| 4152 | - $pdf_sections = array(); | |
| 4153 | - foreach ($pdf_attachment_ids as $att_id) { | |
| 4154 | - $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id); | |
| 4155 | - if (!empty($pdf_text)) { | |
| 4156 | - $pdf_title = get_the_title($att_id); | |
| 4157 | - $pdf_url = wp_get_attachment_url($att_id); | |
| 4158 | - $header = 'PDF Attachment'; | |
| 4159 | - if (!empty($pdf_title)) { | |
| 4160 | - $header .= ': ' . $pdf_title; | |
| 4161 | - } | |
| 4162 | - if (!empty($pdf_url)) { | |
| 4163 | - $header .= ' (' . $pdf_url . ')'; | |
| 4164 | - } | |
| 4165 | - $pdf_sections[] = $header . "\n" . $pdf_text; | |
| 4166 | - $pdf_extracted_count++; | |
| 4167 | - } | |
| 4168 | - } | |
| 4169 | - if (!empty($pdf_sections)) { | |
| 4170 | - $content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections); | |
| 4171 | - } | |
| 4172 | - } | |
| 4173 | 3568 | } |
| 4174 | 3569 | |
| 4175 | 3570 | // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields) |
| 4176 | 3571 | $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id); |
| @@ -4195,19 +3590,29 @@ | ||
| 4195 | 3590 | //error_log('[MXCHAT-WP-IMPORT-DEBUG] Content preview: ' . substr($content, 0, 300)); |
| 4196 | 3591 | |
| 4197 | 3592 | // Note: Removed 10,000 char limit - chunking now handles large content properly |
| 4198 | 3593 | |
| 4199 | - // Get bot-specific embedding decision — custom-provider-aware (plan cbd5fd) | |
| 3594 | + // Get bot-specific API key | |
| 4200 | 3595 | $bot_options = $this->get_bot_options($bot_id); |
| 4201 | 3596 | $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options'); |
| 4202 | - | |
| 4203 | - $preflight = MxChat_Utils::embedding_preflight($options); | |
| 4204 | - if (!$preflight['ok']) { | |
| 4205 | - MxChat_Admin::mxchat_log_debug('api_error', $preflight['reason'] . ' (knowledge processing)'); | |
| 4206 | - wp_send_json_error($preflight['reason']); | |
| 3597 | + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002'; | |
| 3598 | + | |
| 3599 | + if (strpos($selected_model, 'voyage') === 0) { | |
| 3600 | + $api_key = $options['voyage_api_key'] ?? ''; | |
| 3601 | + $provider_name = 'Voyage AI'; | |
| 3602 | + } elseif (strpos($selected_model, 'gemini-embedding') === 0) { | |
| 3603 | + $api_key = $options['gemini_api_key'] ?? ''; | |
| 3604 | + $provider_name = 'Google Gemini'; | |
| 3605 | + } else { | |
| 3606 | + $api_key = $options['api_key'] ?? ''; | |
| 3607 | + $provider_name = 'OpenAI'; | |
| 3608 | + } | |
| 3609 | + | |
| 3610 | + if (empty($api_key)) { | |
| 3611 | + MxChat_Admin::mxchat_log_debug('api_error', $provider_name . ' API key not configured for knowledge processing'); | |
| 3612 | + wp_send_json_error($provider_name . ' API key not configured'); | |
| 4207 | 3613 | exit; |
| 4208 | 3614 | } |
| 4209 | - $api_key = $preflight['api_key']; | |
| 4210 | 3615 | |
| 4211 | 3616 | $source_url = get_permalink($post_id); |
| 4212 | 3617 | $vector_id = md5($source_url); // Vector ID for Pinecone |
| 4213 | 3618 | |
| @@ -4290,9 +3695,8 @@ | ||
| 4290 | 3695 | 'title' => $post->post_title, |
| 4291 | 3696 | 'operation_type' => $operation_type, |
| 4292 | 3697 | 'vector_id' => $vector_id, |
| 4293 | 3698 | 'acf_fields_found' => $acf_field_count, |
| 4294 | - 'pdf_extracted_count' => (int) $pdf_extracted_count, | |
| 4295 | 3699 | 'content_preview' => substr($content, 0, 100) . '...', |
| 4296 | 3700 | 'bot_id' => $bot_id |
| 4297 | 3701 | )); |
| 4298 | 3702 | exit; |
| @@ -4707,20 +4111,9 @@ | ||
| 4707 | 4111 | |
| 4708 | 4112 | // Get bot-specific options |
| 4709 | 4113 | $bot_options = $this->get_bot_options($bot_id); |
| 4710 | 4114 | $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options'); |
| 4711 | - | |
| 4712 | - // Opt-in: when the custom provider is selected for embeddings, index through | |
| 4713 | - // the same custom endpoint the query path uses so stored vectors and query | |
| 4714 | - // vectors share a model. Returns the vector array on success, or an error | |
| 4715 | - // string on failure (this function's existing failure contract). | |
| 4716 | - if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') { | |
| 4717 | - if (!class_exists('MxChat_Utils')) { | |
| 4718 | - require_once dirname(__FILE__) . '/../includes/class-mxchat-utils.php'; | |
| 4719 | - } | |
| 4720 | - return MxChat_Utils::generate_embedding_custom($text, $options); | |
| 4721 | - } | |
| 4722 | - | |
| 4115 | + | |
| 4723 | 4116 | $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002'; |
| 4724 | 4117 | //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model); |
| 4725 | 4118 | |
| 4726 | 4119 | // Determine provider and endpoint |
| @@ -4832,23 +4225,13 @@ | ||
| 4832 | 4225 | $error_message = $error_json['error']['message'] ?? 'No message'; |
| 4833 | 4226 | //error_log('[MXCHAT-EMBED] API Error Type: ' . $error_type); |
| 4834 | 4227 | //error_log('[MXCHAT-EMBED] API Error Message: ' . $error_message); |
| 4835 | 4228 | |
| 4836 | - // Keep the provider's own diagnostic — a restricted-key 401 names the | |
| 4837 | - // exact missing scope, and replacing it with "check your API key" sent | |
| 4838 | - // a customer to regenerate two keys (plan 46b596). Same shape as | |
| 4839 | - // MxChat_Utils::embedding_failure_error() so both ingestion paths read | |
| 4840 | - // identically. Key never appears in provider messages, but scrub anyway. | |
| 4841 | - if ($error_type === 'invalid_request_error' || $error_type === 'authentication_error') { | |
| 4842 | - if (is_string($api_key) && $api_key !== '') { | |
| 4843 | - $error_message = str_replace($api_key, '[redacted]', $error_message); | |
| 4844 | - } | |
| 4845 | - $error_message = sprintf( | |
| 4846 | - 'Embedding failed (%s, HTTP %d): %s', | |
| 4847 | - $selected_model, | |
| 4848 | - $http_code, | |
| 4849 | - substr($error_message, 0, 300) | |
| 4850 | - ); | |
| 4229 | + // Customize error message for common API errors | |
| 4230 | + if ($error_type === 'invalid_request_error' && strpos($error_message, 'API key') !== false) { | |
| 4231 | + $error_message = sprintf('Invalid %s API key for bot %s. Please check your API key in the bot settings.', $provider_name, $bot_id); | |
| 4232 | + } elseif ($error_type === 'authentication_error') { | |
| 4233 | + $error_message = sprintf('%s authentication failed for bot %s. Please verify your API key in the bot settings.', $provider_name, $bot_id); | |
| 4851 | 4234 | } |
| 4852 | 4235 | |
| 4853 | 4236 | //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message); |
| 4854 | 4237 | return $error_message; |
| @@ -5648,197 +5031,8 @@ | ||
| 5648 | 5031 | return implode(', ', array_filter($text_parts)); |
| 5649 | 5032 | } |
| 5650 | 5033 | |
| 5651 | 5034 | /** |
| 5652 | - * Walk an ACF field value tree and collect attachment IDs for any value that | |
| 5653 | - * resolves to a PDF in the WordPress media library. Handles the three shapes | |
| 5654 | - * ACF returns for File/Image/URL fields (array with ID+url, integer attachment ID, | |
| 5655 | - * plain URL string), and recurses through repeater/group/flexible content. | |
| 5656 | - * | |
| 5657 | - * @param mixed $value The ACF field value (any depth) | |
| 5658 | - * @param array $out Accumulator (passed by reference) for attachment IDs | |
| 5659 | - * @param int $depth Recursion guard | |
| 5660 | - */ | |
| 5661 | -private function mxchat_collect_pdf_attachment_ids_from_acf_value($value, &$out, $depth = 0) { | |
| 5662 | - if ($depth > 6) { | |
| 5663 | - return; // prevent runaway recursion on circular/very-deep structures | |
| 5664 | - } | |
| 5665 | - | |
| 5666 | - if (empty($value)) { | |
| 5667 | - return; | |
| 5668 | - } | |
| 5669 | - | |
| 5670 | - // Array shapes: ACF File/Image return value=array; repeaters/groups are arrays of arrays | |
| 5671 | - if (is_array($value)) { | |
| 5672 | - // Direct File/Image-style array (has 'url' and usually 'ID' + 'mime_type') | |
| 5673 | - $looks_like_attachment = isset($value['url']) || isset($value['ID']) || isset($value['id']); | |
| 5674 | - if ($looks_like_attachment) { | |
| 5675 | - $att_id = 0; | |
| 5676 | - if (!empty($value['ID']) && is_numeric($value['ID'])) { | |
| 5677 | - $att_id = (int) $value['ID']; | |
| 5678 | - } elseif (!empty($value['id']) && is_numeric($value['id'])) { | |
| 5679 | - $att_id = (int) $value['id']; | |
| 5680 | - } elseif (!empty($value['url']) && is_string($value['url'])) { | |
| 5681 | - $att_id = (int) attachment_url_to_postid($value['url']); | |
| 5682 | - } | |
| 5683 | - | |
| 5684 | - $is_pdf = false; | |
| 5685 | - if (!empty($value['mime_type']) && $value['mime_type'] === 'application/pdf') { | |
| 5686 | - $is_pdf = true; | |
| 5687 | - } elseif (!empty($value['subtype']) && strtolower((string) $value['subtype']) === 'pdf') { | |
| 5688 | - $is_pdf = true; | |
| 5689 | - } elseif (!empty($value['url']) && is_string($value['url']) && $this->mxchat_url_looks_like_pdf($value['url'])) { | |
| 5690 | - $is_pdf = true; | |
| 5691 | - } elseif ($att_id && get_post_mime_type($att_id) === 'application/pdf') { | |
| 5692 | - $is_pdf = true; | |
| 5693 | - } | |
| 5694 | - | |
| 5695 | - if ($is_pdf && $att_id && get_post_mime_type($att_id) === 'application/pdf') { | |
| 5696 | - $out[] = $att_id; | |
| 5697 | - } | |
| 5698 | - // An array node that represents one attachment doesn't contain other | |
| 5699 | - // attachments inside it — done with this branch. | |
| 5700 | - return; | |
| 5701 | - } | |
| 5702 | - | |
| 5703 | - // Recurse: repeater rows, flexible-content layouts, groups, etc. | |
| 5704 | - foreach ($value as $sub) { | |
| 5705 | - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($sub, $out, $depth + 1); | |
| 5706 | - } | |
| 5707 | - return; | |
| 5708 | - } | |
| 5709 | - | |
| 5710 | - // Plain numeric attachment ID (ACF File field set to "Return: ID") | |
| 5711 | - if (is_numeric($value)) { | |
| 5712 | - $att_id = (int) $value; | |
| 5713 | - if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') { | |
| 5714 | - $out[] = $att_id; | |
| 5715 | - } | |
| 5716 | - return; | |
| 5717 | - } | |
| 5718 | - | |
| 5719 | - // Plain string — URL pointing at a PDF (ACF File field set to "Return: URL", or a custom URL/text field) | |
| 5720 | - if (is_string($value)) { | |
| 5721 | - $trimmed = trim($value); | |
| 5722 | - if ($trimmed !== '' && $this->mxchat_url_looks_like_pdf($trimmed)) { | |
| 5723 | - $att_id = (int) attachment_url_to_postid($trimmed); | |
| 5724 | - if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') { | |
| 5725 | - $out[] = $att_id; | |
| 5726 | - } | |
| 5727 | - } | |
| 5728 | - return; | |
| 5729 | - } | |
| 5730 | -} | |
| 5731 | - | |
| 5732 | -/** | |
| 5733 | - * Heuristic: does this URL/string look like a PDF reference? | |
| 5734 | - * Tolerates query strings and fragments (#page=2). | |
| 5735 | - */ | |
| 5736 | -private function mxchat_url_looks_like_pdf($url) { | |
| 5737 | - if (!is_string($url) || $url === '') { | |
| 5738 | - return false; | |
| 5739 | - } | |
| 5740 | - // Strip query + fragment before checking extension | |
| 5741 | - $path = preg_replace('/[?#].*$/', '', $url); | |
| 5742 | - return (bool) preg_match('/\.pdf$/i', $path); | |
| 5743 | -} | |
| 5744 | - | |
| 5745 | -/** | |
| 5746 | - * Extract text from a PDF attachment by ID using the bundled Smalot parser. | |
| 5747 | - * Reads the file directly from disk via get_attached_file (no HTTP fetch). | |
| 5748 | - * Result is cached on the attachment as post_meta keyed by file mtime so we | |
| 5749 | - * only parse the same PDF once unless the file changes on disk. | |
| 5750 | - * | |
| 5751 | - * @param int $attachment_id | |
| 5752 | - * @return string Extracted plain text, or '' on failure. | |
| 5753 | - */ | |
| 5754 | -private function mxchat_extract_pdf_text_by_attachment_id($attachment_id) { | |
| 5755 | - $attachment_id = (int) $attachment_id; | |
| 5756 | - if ($attachment_id <= 0) { | |
| 5757 | - return ''; | |
| 5758 | - } | |
| 5759 | - if (get_post_mime_type($attachment_id) !== 'application/pdf') { | |
| 5760 | - return ''; | |
| 5761 | - } | |
| 5762 | - | |
| 5763 | - $pdf_path = get_attached_file($attachment_id); | |
| 5764 | - if (empty($pdf_path) || !file_exists($pdf_path) || !is_readable($pdf_path)) { | |
| 5765 | - return ''; | |
| 5766 | - } | |
| 5767 | - | |
| 5768 | - // Raw-file size cap. Parsing very large PDFs can OOM the request; skip with a log entry | |
| 5769 | - // and let the rest of the ACF content land in the KB. Filterable for users who need it bigger. | |
| 5770 | - $default_max_bytes = 25 * 1024 * 1024; | |
| 5771 | - $max_bytes = (int) apply_filters('mxchat_acf_pdf_max_bytes', $default_max_bytes, $attachment_id, $pdf_path); | |
| 5772 | - if ($max_bytes > 0) { | |
| 5773 | - $file_size = @filesize($pdf_path); | |
| 5774 | - if ($file_size !== false && $file_size > $max_bytes) { | |
| 5775 | - error_log(sprintf( | |
| 5776 | - '[mxchat] ACF PDF skipped (over size cap): attachment %d "%s" %d bytes > cap %d', | |
| 5777 | - $attachment_id, | |
| 5778 | - basename($pdf_path), | |
| 5779 | - $file_size, | |
| 5780 | - $max_bytes | |
| 5781 | - )); | |
| 5782 | - return ''; | |
| 5783 | - } | |
| 5784 | - } | |
| 5785 | - | |
| 5786 | - $mtime = @filemtime($pdf_path); | |
| 5787 | - $cache_meta_key = '_mxchat_acf_pdf_text_v1'; | |
| 5788 | - $cached = get_post_meta($attachment_id, $cache_meta_key, true); | |
| 5789 | - if (is_array($cached) && isset($cached['mtime'], $cached['text']) && (int) $cached['mtime'] === (int) $mtime) { | |
| 5790 | - return (string) $cached['text']; | |
| 5791 | - } | |
| 5792 | - | |
| 5793 | - $text = ''; | |
| 5794 | - try { | |
| 5795 | - if (function_exists('mxchat_load_pdf_parser')) { | |
| 5796 | - mxchat_load_pdf_parser(); | |
| 5797 | - } | |
| 5798 | - if (!class_exists('\\Smalot\\PdfParser\\Parser')) { | |
| 5799 | - return ''; | |
| 5800 | - } | |
| 5801 | - $parser = new \Smalot\PdfParser\Parser(); | |
| 5802 | - $pdf = $parser->parseFile($pdf_path); | |
| 5803 | - $pages = $pdf->getPages(); | |
| 5804 | - $page_texts = array(); | |
| 5805 | - foreach ($pages as $page) { | |
| 5806 | - $page_text = ''; | |
| 5807 | - try { | |
| 5808 | - $page_text = $page->getText(); | |
| 5809 | - } catch (\Exception $e) { | |
| 5810 | - $page_text = ''; | |
| 5811 | - } | |
| 5812 | - if (!empty($page_text)) { | |
| 5813 | - $page_texts[] = $page_text; | |
| 5814 | - } | |
| 5815 | - } | |
| 5816 | - $text = trim(implode("\n\n", $page_texts)); | |
| 5817 | - } catch (\Exception $e) { | |
| 5818 | - error_log('[mxchat] ACF PDF extraction failed for attachment ' . $attachment_id . ': ' . $e->getMessage()); | |
| 5819 | - return ''; | |
| 5820 | - } catch (\Throwable $e) { | |
| 5821 | - error_log('[mxchat] ACF PDF extraction error for attachment ' . $attachment_id . ': ' . $e->getMessage()); | |
| 5822 | - return ''; | |
| 5823 | - } | |
| 5824 | - | |
| 5825 | - // Cap per-PDF text to avoid blowing up the embedding payload on enormous PDFs. | |
| 5826 | - // The chunker downstream will still split this into multiple vectors. | |
| 5827 | - $max_len = (int) apply_filters('mxchat_acf_pdf_text_max_length', 50000); | |
| 5828 | - if ($max_len > 0 && strlen($text) > $max_len) { | |
| 5829 | - $text = substr($text, 0, $max_len); | |
| 5830 | - } | |
| 5831 | - | |
| 5832 | - update_post_meta($attachment_id, $cache_meta_key, array( | |
| 5833 | - 'mtime' => (int) $mtime, | |
| 5834 | - 'text' => $text, | |
| 5835 | - )); | |
| 5836 | - | |
| 5837 | - return $text; | |
| 5838 | -} | |
| 5839 | - | |
| 5840 | -/** | |
| 5841 | 5035 | * Handle ACF save - fires after ACF fields are saved |
| 5842 | 5036 | * This ensures ACF field data is available when syncing to knowledge base |
| 5843 | 5037 | */ |
| 5844 | 5038 | public function mxchat_handle_acf_save($post_id) { |
| @@ -5908,13 +5102,8 @@ | ||
| 5908 | 5102 | $this->mxchat_handle_post_update($post_id, $post, true); |
| 5909 | 5103 | } |
| 5910 | 5104 | |
| 5911 | 5105 | public function mxchat_handle_post_update($post_id, $post, $update) { |
| 5912 | - // The in-flight-update marker has done its job the moment post_updated runs; drop it | |
| 5913 | - // before any early return so it can never outlive its own save (a failed $wpdb->update | |
| 5914 | - // inside wp_insert_post returns after pre_post_update but before the transition). | |
| 5915 | - unset($this->pending_post_update[$post_id]); | |
| 5916 | - | |
| 5917 | 5106 | // Basic validation checks |
| 5918 | 5107 | if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) { |
| 5919 | 5108 | return; |
| 5920 | 5109 | } |
| @@ -5951,35 +5140,40 @@ | ||
| 5951 | 5140 | // If the post was previously published but is now not published, remove from knowledge base |
| 5952 | 5141 | if ($previous_status === 'publish' && $post->post_status !== 'publish') { |
| 5953 | 5142 | // Use the stored URL from when it was published, or fall back to current permalink |
| 5954 | 5143 | $source_url = $previous_url ?: get_permalink($post_id); |
| 5144 | + | |
| 5145 | + if ($source_url) { | |
| 5146 | + // Check if Pinecone is enabled | |
| 5147 | + $pinecone_options = get_option('mxchat_pinecone_addon_options', array()); | |
| 5148 | + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1'; | |
| 5955 | 5149 | |
| 5956 | - // mxchat_handle_status_transition already deleted for this post earlier in this | |
| 5957 | - // request (it fires first inside wp_insert_post); skip the redundant round-trip. | |
| 5958 | - if ($source_url && empty($this->transition_deleted_posts[$post_id])) { | |
| 5959 | - // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks) | |
| 5960 | - MxChat_Utils::delete_chunks_for_url($source_url, 'default'); | |
| 5150 | + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) { | |
| 5151 | + // Delete from Pinecone | |
| 5152 | + $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options); | |
| 5153 | + } else { | |
| 5154 | + // Delete from WordPress DB | |
| 5155 | + global $wpdb; | |
| 5156 | + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content'; | |
| 5157 | + | |
| 5158 | + $result = $wpdb->delete( | |
| 5159 | + $table_name, | |
| 5160 | + array('source_url' => $source_url), | |
| 5161 | + array('%s') | |
| 5162 | + ); | |
| 5163 | + } | |
| 5961 | 5164 | } |
| 5962 | - | |
| 5165 | + | |
| 5963 | 5166 | // Clean up the transients and exit early |
| 5964 | 5167 | delete_transient($previous_status_key); |
| 5965 | 5168 | delete_transient($previous_url_key); |
| 5966 | 5169 | return; |
| 5967 | 5170 | } |
| 5968 | - | |
| 5969 | - // Slug/permalink rename while still published: delete the old vectors before upserting new ones. | |
| 5970 | - // Without this, md5(old_url) vectors (base + chunks) would be orphaned under the stale URL. | |
| 5971 | - if ($post->post_status === 'publish' && !empty($previous_url)) { | |
| 5972 | - $current_url = get_permalink($post_id); | |
| 5973 | - if ($current_url && $current_url !== $previous_url) { | |
| 5974 | - MxChat_Utils::delete_chunks_for_url($previous_url, 'default'); | |
| 5975 | - } | |
| 5976 | - } | |
| 5977 | - | |
| 5171 | + | |
| 5978 | 5172 | // Store the current status for next time (if this is an update) |
| 5979 | 5173 | if ($update) { |
| 5980 | 5174 | set_transient($previous_status_key, $post->post_status, DAY_IN_SECONDS); |
| 5981 | - | |
| 5175 | + | |
| 5982 | 5176 | // If the post is currently published, also store its URL |
| 5983 | 5177 | if ($post->post_status === 'publish') { |
| 5984 | 5178 | $current_url = get_permalink($post_id); |
| 5985 | 5179 | set_transient($previous_url_key, $current_url, DAY_IN_SECONDS); |
| @@ -5985,75 +5179,18 @@ | ||
| 5985 | 5179 | set_transient($previous_url_key, $current_url, DAY_IN_SECONDS); |
| 5986 | 5180 | } |
| 5987 | 5181 | } |
| 5988 | 5182 | |
| 5989 | - // Only process currently published content for adding/updating. | |
| 5990 | - // transition_indexed_posts: mxchat_handle_status_transition's arrival edge may have | |
| 5991 | - // already indexed this post earlier in this request (editor publishes fire | |
| 5992 | - // transition_post_status first, then post_updated) — skip the duplicate embed. | |
| 5993 | - // Consume-once: the flag is cleared when honoured, so a LATER save of the same | |
| 5994 | - // post in one long-running process (WP-CLI scripts, importers) re-indexes normally. | |
| 5183 | + // Only process currently published content for adding/updating | |
| 5995 | 5184 | if ($post->post_status === 'publish') { |
| 5996 | - if (!empty($this->transition_indexed_posts[$post_id])) { | |
| 5997 | - unset($this->transition_indexed_posts[$post_id]); | |
| 5998 | - } else { | |
| 5999 | - $this->mxchat_index_published_post($post_id, $post); | |
| 6000 | - } | |
| 6001 | - } | |
| 6002 | - | |
| 6003 | - // Clean up the stored previous status if not used above | |
| 6004 | - if ($previous_status !== 'publish' || $post->post_status === 'publish') { | |
| 6005 | - delete_transient($previous_status_key); | |
| 6006 | - delete_transient($previous_url_key); | |
| 6007 | - } | |
| 6008 | -} | |
| 6009 | - | |
| 6010 | -/** | |
| 6011 | - * Index a published post into the knowledge base: preprocessing filter, content | |
| 6012 | - * assembly (title/excerpt/body), WooCommerce product enrichment, job_listing meta, | |
| 6013 | - * ACF fields (+ optional PDF extraction), whitelisted custom meta, embedding and | |
| 6014 | - * upsert, then tag-based role restriction. | |
| 6015 | - * | |
| 6016 | - * Shared by the post_updated auto-sync path (mxchat_handle_post_update) and the | |
| 6017 | - * transition_post_status arrival edge (mxchat_handle_status_transition), so | |
| 6018 | - * scheduled publishes (wp_publish_post) and direct status=publish inserts index | |
| 6019 | - * identically to editor saves (plan 3055e1). Pure extraction of the former | |
| 6020 | - * publish branch — body indentation retained to keep the diff reviewable. | |
| 6021 | - */ | |
| 6022 | -private function mxchat_index_published_post($post_id, $post) { | |
| 6023 | - $post_type = $post->post_type; | |
| 6024 | - | |
| 6025 | 5185 | // Get the source URL |
| 6026 | 5186 | $source_url = get_permalink($post_id); |
| 5187 | + | |
| 5188 | + // Get content with proper formatting (matching ajax_mxchat_process_selected_content) | |
| 5189 | + $title = get_the_title($post_id); | |
| 5190 | + $content = get_post_field('post_content', $post_id); | |
| 5191 | + $excerpt = get_post_field('post_excerpt', $post_id); | |
| 6027 | 5192 | |
| 6028 | - /** | |
| 6029 | - * Allow developers to modify post data before processing into the knowledge base. | |
| 6030 | - * Same filter and signature as the manual bulk-import path | |
| 6031 | - * (ajax_mxchat_process_selected_content), so a callback registered once covers | |
| 6032 | - * every indexing route. Purely additive — zero behaviour change when unhooked. | |
| 6033 | - * Auto-sync runs under the 'default' bot context, matching the rest of this | |
| 6034 | - * function. | |
| 6035 | - * | |
| 6036 | - * @param WP_Post $post The post about to be indexed. | |
| 6037 | - * @param string $bot_id Bot context ('default' on auto-sync). | |
| 6038 | - */ | |
| 6039 | - $post = apply_filters('mxchat_before_process_post', $post, 'default'); | |
| 6040 | - if (!($post instanceof WP_Post)) { | |
| 6041 | - $post = get_post($post_id); // defend against a bad callback return | |
| 6042 | - } | |
| 6043 | - | |
| 6044 | - // Get content with proper formatting (matching ajax_mxchat_process_selected_content), | |
| 6045 | - // reading from the FILTERED post object — not re-fetched by ID, which would discard it | |
| 6046 | - // Raw post_title, NOT get_the_title(): the_title applies wptexturize + | |
| 6047 | - // convert_chars (curly quotes and em-dashes become HTML entities in the | |
| 6048 | - // embedded string) and prepends the "Protected:" / "Private:" display | |
| 6049 | - // chrome. The knowledge base stores facts, not display strings — and the | |
| 6050 | - // bulk-import path has always read the raw title, so this is also what | |
| 6051 | - // makes the two paths agree. | |
| 6052 | - $title = $post->post_title; | |
| 6053 | - $content = get_post_field('post_content', $post); | |
| 6054 | - $excerpt = get_post_field('post_excerpt', $post); | |
| 6055 | - | |
| 6056 | 5193 | // Remove shortcode tags but preserve content inside them |
| 6057 | 5194 | $content = $this->strip_shortcode_tags_preserve_content($content); |
| 6058 | 5195 | $excerpt = $this->strip_shortcode_tags_preserve_content($excerpt); |
| 6059 | 5196 | |
| @@ -6063,10 +5200,9 @@ | ||
| 6063 | 5200 | // Combine title, short description (if exists), and content |
| 6064 | 5201 | $final_content = $title . "\n\n"; |
| 6065 | 5202 | |
| 6066 | 5203 | // Add short description if it exists (WooCommerce products use post_excerpt for short description) |
| 6067 | - // trim() only in the TEST — see the matching note on the bulk-import path. | |
| 6068 | - if (trim($excerpt) !== '') { | |
| 5204 | + if (!empty($excerpt)) { | |
| 6069 | 5205 | $final_content .= "Short Description: " . wp_strip_all_tags($excerpt) . "\n\n"; |
| 6070 | 5206 | } |
| 6071 | 5207 | |
| 6072 | 5208 | $final_content .= $content; |
| @@ -6146,9 +5282,8 @@ | ||
| 6146 | 5282 | // ADD ACF FIELDS SUPPORT (matches ajax_mxchat_process_selected_content behavior) |
| 6147 | 5283 | $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id); |
| 6148 | 5284 | if (!empty($acf_fields)) { |
| 6149 | 5285 | $acf_content_parts = array(); |
| 6150 | - $pdf_attachment_ids = array(); | |
| 6151 | 5286 | |
| 6152 | 5287 | foreach ($acf_fields as $field_name => $field_value) { |
| 6153 | 5288 | $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id); |
| 6154 | 5289 | if (!empty($formatted_value)) { |
| @@ -6155,44 +5290,13 @@ | ||
| 6155 | 5290 | // Convert field name to readable label |
| 6156 | 5291 | $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name)); |
| 6157 | 5292 | $acf_content_parts[] = $field_label . ": " . $formatted_value; |
| 6158 | 5293 | } |
| 6159 | - | |
| 6160 | - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids); | |
| 6161 | 5294 | } |
| 6162 | 5295 | |
| 6163 | 5296 | if (!empty($acf_content_parts)) { |
| 6164 | 5297 | $final_content .= "\n\n" . implode("\n", $acf_content_parts); |
| 6165 | 5298 | } |
| 6166 | - | |
| 6167 | - // Gate the auto-sync PDF-extraction loop behind an opt-in option. | |
| 6168 | - // Mirrors the per-batch checkbox the manual content selector has; the | |
| 6169 | - // 25 MB size cap lives in the shared extractor so it applies in both | |
| 6170 | - // paths regardless. Default OFF — re-parsing every ACF PDF on every | |
| 6171 | - // editor save is expensive and most sites don't want it. | |
| 6172 | - $autosync_extract_acf_pdfs = get_option('mxchat_auto_sync_acf_pdfs', '0') === '1'; | |
| 6173 | - if ($autosync_extract_acf_pdfs && !empty($pdf_attachment_ids)) { | |
| 6174 | - $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids))); | |
| 6175 | - $pdf_sections = array(); | |
| 6176 | - foreach ($pdf_attachment_ids as $att_id) { | |
| 6177 | - $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id); | |
| 6178 | - if (!empty($pdf_text)) { | |
| 6179 | - $pdf_title = get_the_title($att_id); | |
| 6180 | - $pdf_url = wp_get_attachment_url($att_id); | |
| 6181 | - $header = 'PDF Attachment'; | |
| 6182 | - if (!empty($pdf_title)) { | |
| 6183 | - $header .= ': ' . $pdf_title; | |
| 6184 | - } | |
| 6185 | - if (!empty($pdf_url)) { | |
| 6186 | - $header .= ' (' . $pdf_url . ')'; | |
| 6187 | - } | |
| 6188 | - $pdf_sections[] = $header . "\n" . $pdf_text; | |
| 6189 | - } | |
| 6190 | - } | |
| 6191 | - if (!empty($pdf_sections)) { | |
| 6192 | - $final_content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections); | |
| 6193 | - } | |
| 6194 | - } | |
| 6195 | 5299 | } |
| 6196 | 5300 | |
| 6197 | 5301 | // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields) |
| 6198 | 5302 | $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id); |
| @@ -6209,22 +5313,28 @@ | ||
| 6209 | 5313 | $final_content .= "\n\n" . implode("\n", $meta_content_parts); |
| 6210 | 5314 | } |
| 6211 | 5315 | } |
| 6212 | 5316 | |
| 6213 | - // Embedding decision — custom-provider-aware. Gating on a cloud API key | |
| 6214 | - // here silently killed auto-sync on keyless custom-embeddings sites, | |
| 6215 | - // because generate_embedding() routes custom FIRST and never needs the | |
| 6216 | - // key (plan cbd5fd). Silent-return shape preserved. | |
| 6217 | - $preflight = MxChat_Utils::embedding_preflight(get_option('mxchat_options')); | |
| 6218 | - if (!$preflight['ok']) { | |
| 5317 | + // Get API key with proper model detection | |
| 5318 | + $options = get_option('mxchat_options'); | |
| 5319 | + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002'; | |
| 5320 | + | |
| 5321 | + if (strpos($selected_model, 'voyage') === 0) { | |
| 5322 | + $api_key = $options['voyage_api_key'] ?? ''; | |
| 5323 | + } elseif (strpos($selected_model, 'gemini-embedding') === 0) { | |
| 5324 | + $api_key = $options['gemini_api_key'] ?? ''; | |
| 5325 | + } else { | |
| 5326 | + $api_key = $options['api_key'] ?? ''; | |
| 5327 | + } | |
| 5328 | + | |
| 5329 | + if (empty($api_key)) { | |
| 6219 | 5330 | return; |
| 6220 | 5331 | } |
| 6221 | - $api_key = $preflight['api_key']; | |
| 6222 | - | |
| 5332 | + | |
| 6223 | 5333 | // Use the centralized utility function for storage |
| 6224 | 5334 | $result = MxChat_Utils::submit_content_to_db( |
| 6225 | - $final_content, | |
| 6226 | - $source_url, | |
| 5335 | + $final_content, | |
| 5336 | + $source_url, | |
| 6227 | 5337 | $api_key, |
| 6228 | 5338 | md5($source_url) // Vector ID for Pinecone |
| 6229 | 5339 | ); |
| 6230 | 5340 | |
| @@ -6231,8 +5341,15 @@ | ||
| 6231 | 5341 | // After successful storage, apply role restriction based on tags |
| 6232 | 5342 | if (!is_wp_error($result)) { |
| 6233 | 5343 | $this->apply_role_restriction_to_post($post_id, $source_url); |
| 6234 | 5344 | } |
| 5345 | + } | |
| 5346 | + | |
| 5347 | + // Clean up the stored previous status if not used above | |
| 5348 | + if ($previous_status !== 'publish' || $post->post_status === 'publish') { | |
| 5349 | + delete_transient($previous_status_key); | |
| 5350 | + delete_transient($previous_url_key); | |
| 5351 | + } | |
| 6235 | 5352 | } |
| 6236 | 5353 | |
| 6237 | 5354 | /** |
| 6238 | 5355 | * Store the post status and URL before update to detect status transitions |
| @@ -6238,12 +5355,8 @@ | ||
| 6238 | 5355 | * Store the post status and URL before update to detect status transitions |
| 6239 | 5356 | * This runs before the post is actually updated in the database |
| 6240 | 5357 | */ |
| 6241 | 5358 | public function mxchat_store_pre_update_status($post_id, $data) { |
| 6242 | - // Core is inside wp_insert_post's update branch, so a post_updated WILL fire later | |
| 6243 | - // this request and can consume the arrival-edge guard (plan a664f3). | |
| 6244 | - $this->pending_post_update[$post_id] = true; | |
| 6245 | - | |
| 6246 | 5359 | // Get the current post from database (before update) |
| 6247 | 5360 | $current_post = get_post($post_id); |
| 6248 | 5361 | |
| 6249 | 5362 | if ($current_post) { |
| @@ -6259,199 +5372,8 @@ | ||
| 6259 | 5372 | } |
| 6260 | 5373 | } |
| 6261 | 5374 | } |
| 6262 | 5375 | |
| 6263 | -/** | |
| 6264 | - * Whether auto-sync is enabled for a post type (mirrors the checks used by the | |
| 6265 | - * update/delete handlers; kept as one helper so new call sites cannot drift). | |
| 6266 | - */ | |
| 6267 | -private function mxchat_is_auto_sync_enabled($post_type) { | |
| 6268 | - if ($post_type === 'post') { | |
| 6269 | - return get_option('mxchat_auto_sync_posts') === '1'; | |
| 6270 | - } | |
| 6271 | - if ($post_type === 'page') { | |
| 6272 | - return get_option('mxchat_auto_sync_pages') === '1'; | |
| 6273 | - } | |
| 6274 | - return get_option('mxchat_auto_sync_' . $post_type) === '1'; | |
| 6275 | -} | |
| 6276 | - | |
| 6277 | -/** | |
| 6278 | - * Remove a post's vectors the moment it leaves 'publish', using the authoritative | |
| 6279 | - * old status core passes to transition_post_status — no transient involved (plan 816fb1). | |
| 6280 | - * | |
| 6281 | - * Covers status changes that never route through wp_update_post (scheduled-expiry | |
| 6282 | - * plugins and others that flip post_status directly and call wp_transition_post_status), | |
| 6283 | - * where neither pre_post_update nor post_updated fires and the old detection missed. | |
| 6284 | - */ | |
| 6285 | -public function mxchat_handle_status_transition($new_status, $old_status, $post) { | |
| 6286 | - if (!($post instanceof WP_Post) || wp_is_post_revision($post->ID)) { | |
| 6287 | - return; | |
| 6288 | - } | |
| 6289 | - | |
| 6290 | - // Arrival edge (plan 3055e1): a post BECOMING published is indexed here, because | |
| 6291 | - // wp_publish_post() — the path scheduled posts take via check_and_publish_future_post — | |
| 6292 | - // and direct wp_insert_post(status=publish) creates never fire post_updated, so the | |
| 6293 | - // auto-sync ADD path alone misses them. Editor publishes also pass through here; | |
| 6294 | - // the transition_indexed_posts guard keeps mxchat_handle_post_update from embedding | |
| 6295 | - // a second time in the same request. | |
| 6296 | - if ($new_status === 'publish' && $old_status !== 'publish') { | |
| 6297 | - if ($this->mxchat_is_auto_sync_enabled($post->post_type)) { | |
| 6298 | - $this->mxchat_index_published_post($post->ID, $post); | |
| 6299 | - | |
| 6300 | - // Arm the double-fire guard ONLY when a post_updated is actually coming to | |
| 6301 | - // consume it (plan a664f3). Two publish paths never fire post_updated at all: | |
| 6302 | - // a direct wp_insert_post(status=publish) create, and wp_publish_post() — the | |
| 6303 | - // call check_and_publish_future_post() makes for scheduled posts. Arming the | |
| 6304 | - // guard unconditionally left it set with nothing to consume it, so the NEXT | |
| 6305 | - // update of that post was swallowed entirely: zero embed calls, no knowledge | |
| 6306 | - // -base row, silently. Consume-once on this side too, so a guard can never | |
| 6307 | - // outlive the single save it was armed for. | |
| 6308 | - if (!empty($this->pending_post_update[$post->ID])) { | |
| 6309 | - unset($this->pending_post_update[$post->ID]); | |
| 6310 | - $this->transition_indexed_posts[$post->ID] = true; | |
| 6311 | - } | |
| 6312 | - } | |
| 6313 | - return; | |
| 6314 | - } | |
| 6315 | - | |
| 6316 | - // Only the publish -> not-publish edge matters here. | |
| 6317 | - if ($old_status !== 'publish' || $new_status === 'publish') { | |
| 6318 | - return; | |
| 6319 | - } | |
| 6320 | - // Trash is handled by mxchat_handle_post_delete (wp_trash_post) with pre-trash URL | |
| 6321 | - // resolution; skip to avoid a second network round-trip per trash. | |
| 6322 | - if ($new_status === 'trash') { | |
| 6323 | - return; | |
| 6324 | - } | |
| 6325 | - if (!$this->mxchat_is_auto_sync_enabled($post->post_type)) { | |
| 6326 | - return; | |
| 6327 | - } | |
| 6328 | - | |
| 6329 | - $urls = array(); | |
| 6330 | - | |
| 6331 | - // The DB may already hold the new status when this fires, so get_permalink() on the | |
| 6332 | - // live post could build a draft-style URL whose md5 misses the stored vector IDs. | |
| 6333 | - // Reconstruct the published permalink from a clone instead. | |
| 6334 | - $published_clone = clone $post; | |
| 6335 | - $published_clone->post_status = 'publish'; | |
| 6336 | - $published_url = get_permalink($published_clone); | |
| 6337 | - if ($published_url) { | |
| 6338 | - $urls[] = $published_url; | |
| 6339 | - } | |
| 6340 | - | |
| 6341 | - // Honour the pre-update capture when present (covers a slug change in the same save). | |
| 6342 | - $previous_url = get_transient('mxchat_prev_url_' . $post->ID); | |
| 6343 | - if (!empty($previous_url)) { | |
| 6344 | - $urls[] = $previous_url; | |
| 6345 | - } | |
| 6346 | - | |
| 6347 | - foreach (array_unique($urls) as $url) { | |
| 6348 | - MxChat_Utils::delete_chunks_for_url($url, 'default'); | |
| 6349 | - } | |
| 6350 | - | |
| 6351 | - if (!empty($urls)) { | |
| 6352 | - $this->transition_deleted_posts[$post->ID] = true; | |
| 6353 | - } | |
| 6354 | -} | |
| 6355 | - | |
| 6356 | -/** | |
| 6357 | - * WP-CLI: remove knowledge-base entries left behind by posts that were unpublished, | |
| 6358 | - * trashed, or made private before the transition_post_status handler existed. | |
| 6359 | - * | |
| 6360 | - * Walks every auto-synced post type's non-published posts, reconstructs each one's | |
| 6361 | - * published-era permalink, and deletes its vectors (routes to Pinecone or the WP table). | |
| 6362 | - * Deletion is idempotent, so never-indexed posts are a cheap no-op. | |
| 6363 | - * | |
| 6364 | - * ## OPTIONS | |
| 6365 | - * | |
| 6366 | - * [--dry-run] | |
| 6367 | - * : Report what would be removed without deleting anything. | |
| 6368 | - * | |
| 6369 | - * ## EXAMPLES | |
| 6370 | - * | |
| 6371 | - * wp mxchat prune-unpublished --dry-run | |
| 6372 | - * wp mxchat prune-unpublished | |
| 6373 | - */ | |
| 6374 | -public function cli_prune_unpublished($args, $assoc_args) { | |
| 6375 | - global $wpdb; | |
| 6376 | - $dry_run = !empty($assoc_args['dry-run']); | |
| 6377 | - $table = $wpdb->prefix . 'mxchat_system_prompt_content'; | |
| 6378 | - | |
| 6379 | - $candidate_types = array_merge(array('post', 'page'), array_values(get_post_types(array('_builtin' => false), 'names'))); | |
| 6380 | - $synced_types = array(); | |
| 6381 | - foreach ($candidate_types as $type) { | |
| 6382 | - if ($this->mxchat_is_auto_sync_enabled($type)) { | |
| 6383 | - $synced_types[] = $type; | |
| 6384 | - } | |
| 6385 | - } | |
| 6386 | - if (empty($synced_types)) { | |
| 6387 | - WP_CLI::success('No post types have auto-sync enabled; nothing to prune.'); | |
| 6388 | - return; | |
| 6389 | - } | |
| 6390 | - | |
| 6391 | - $scanned = 0; | |
| 6392 | - $pruned = 0; | |
| 6393 | - $paged = 1; | |
| 6394 | - do { | |
| 6395 | - $query = new WP_Query(array( | |
| 6396 | - 'post_type' => $synced_types, | |
| 6397 | - 'post_status' => array('draft', 'pending', 'private', 'future', 'trash'), | |
| 6398 | - 'posts_per_page' => 100, | |
| 6399 | - 'paged' => $paged, | |
| 6400 | - 'fields' => 'ids', | |
| 6401 | - )); | |
| 6402 | - foreach ($query->posts as $post_id) { | |
| 6403 | - $post = get_post($post_id); | |
| 6404 | - if (!$post) { | |
| 6405 | - continue; | |
| 6406 | - } | |
| 6407 | - $scanned++; | |
| 6408 | - | |
| 6409 | - // Rebuild the permalink the post had while published: publish-status clone, | |
| 6410 | - // with wp_trash_post's __trashed slug suffix stripped for trashed posts. | |
| 6411 | - $clone = clone $post; | |
| 6412 | - $clone->post_status = 'publish'; | |
| 6413 | - if (substr($clone->post_name, -9) === '__trashed') { | |
| 6414 | - $clone->post_name = substr($clone->post_name, 0, -9); | |
| 6415 | - } | |
| 6416 | - $url = get_permalink($clone); | |
| 6417 | - if (!$url) { | |
| 6418 | - continue; | |
| 6419 | - } | |
| 6420 | - | |
| 6421 | - // Local-table row count is exact in WordPress-DB mode; in Pinecone mode it | |
| 6422 | - // reads 0 but the delete below still routes to Pinecone and is idempotent. | |
| 6423 | - $local_rows = (int) $wpdb->get_var($wpdb->prepare( | |
| 6424 | - "SELECT COUNT(*) FROM {$table} WHERE source_url = %s", $url | |
| 6425 | - )); | |
| 6426 | - | |
| 6427 | - if ($dry_run) { | |
| 6428 | - if ($local_rows > 0) { | |
| 6429 | - WP_CLI::log(sprintf('Would remove %d row(s): %s (post %d, %s)', $local_rows, $url, $post_id, $post->post_status)); | |
| 6430 | - $pruned += $local_rows; | |
| 6431 | - } | |
| 6432 | - continue; | |
| 6433 | - } | |
| 6434 | - | |
| 6435 | - MxChat_Utils::delete_chunks_for_url($url, 'default'); | |
| 6436 | - if ($local_rows > 0) { | |
| 6437 | - WP_CLI::log(sprintf('Removed %d row(s): %s (post %d, %s)', $local_rows, $url, $post_id, $post->post_status)); | |
| 6438 | - $pruned += $local_rows; | |
| 6439 | - } | |
| 6440 | - } | |
| 6441 | - $more = $paged < $query->max_num_pages; | |
| 6442 | - $paged++; | |
| 6443 | - } while ($more); | |
| 6444 | - | |
| 6445 | - WP_CLI::success(sprintf( | |
| 6446 | - '%s %d local knowledge row(s) across %d non-published post(s) scanned.%s', | |
| 6447 | - $dry_run ? 'Would remove' : 'Removed', | |
| 6448 | - $pruned, | |
| 6449 | - $scanned, | |
| 6450 | - ' (Pinecone-mode deletions are not counted locally.)' | |
| 6451 | - )); | |
| 6452 | -} | |
| 6453 | - | |
| 6454 | 5376 | public function mxchat_handle_post_delete($post_id) { |
| 6455 | 5377 | // Get post data before it's deleted |
| 6456 | 5378 | $post = get_post($post_id); |
| 6457 | 5379 | |
| @@ -6481,14 +5403,12 @@ | ||
| 6481 | 5403 | if (!$should_sync) { |
| 6482 | 5404 | return; |
| 6483 | 5405 | } |
| 6484 | 5406 | |
| 6485 | - // Resolve the pre-trash URL. wp_trash_post renames the slug with "__trashed" before firing | |
| 6486 | - // this hook, so get_permalink() here would return the trashed URL and md5() would miss the | |
| 6487 | - // real vector IDs stored under the original URL. | |
| 6488 | - $source_url = $this->mxchat_resolve_pre_trash_url($post_id); | |
| 5407 | + // Get the URL before post is deleted | |
| 5408 | + $source_url = get_permalink($post_id); | |
| 6489 | 5409 | if (!$source_url) { |
| 6490 | - //error_log('MXChat: Failed to resolve source URL for post ' . $post_id); | |
| 5410 | + //error_log('MXChat: Failed to get permalink for post ' . $post_id); | |
| 6491 | 5411 | return; |
| 6492 | 5412 | } |
| 6493 | 5413 | |
| 6494 | 5414 | // Use chunk-aware deletion (handles both chunked and non-chunked content) |
| @@ -6496,36 +5416,56 @@ | ||
| 6496 | 5416 | |
| 6497 | 5417 | if (is_wp_error($delete_result)) { |
| 6498 | 5418 | //error_log('MXChat: Chunk-aware deletion failed for URL: ' . $source_url . ' - ' . $delete_result->get_error_message()); |
| 6499 | 5419 | } |
| 5420 | +} | |
| 6500 | 5421 | |
| 6501 | - delete_transient('mxchat_prev_url_' . $post_id); | |
| 6502 | - delete_transient('mxchat_prev_status_' . $post_id); | |
| 6503 | -} | |
| 6504 | 5422 | |
| 6505 | -/** | |
| 6506 | - * Resolve the source URL for a post being trashed/deleted. | |
| 6507 | - * | |
| 6508 | - * Why: wp_trash_post appends "__trashed" to the slug before the wp_trash_post action fires, so | |
| 6509 | - * get_permalink() returns a URL whose md5() won't match the vector IDs stored in Pinecone or | |
| 6510 | - * the source_url rows in the WP DB. Prefer the URL captured by mxchat_store_pre_update_status | |
| 6511 | - * (runs on pre_post_update, before the rename); fall back to stripping the __trashed suffix. | |
| 6512 | - */ | |
| 6513 | -private function mxchat_resolve_pre_trash_url($post_id) { | |
| 6514 | - $previous_url = get_transient('mxchat_prev_url_' . $post_id); | |
| 6515 | - if (!empty($previous_url)) { | |
| 6516 | - return $previous_url; | |
| 6517 | - } | |
| 5423 | + /** | |
| 5424 | + * Deletes data from Pinecone using a source URL | |
| 5425 | + */ | |
| 5426 | + public function mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options) { | |
| 5427 | + $host = $pinecone_options['mxchat_pinecone_host'] ?? ''; | |
| 5428 | + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? ''; | |
| 6518 | 5429 | |
| 6519 | - $current = get_permalink($post_id); | |
| 6520 | - if (!$current) { | |
| 6521 | - return ''; | |
| 6522 | - } | |
| 6523 | - return preg_replace('#__trashed(/?)$#', '$1', $current); | |
| 6524 | -} | |
| 5430 | + if (empty($host) || empty($api_key)) { | |
| 5431 | + //error_log('MXChat: Pinecone deletion failed - missing configuration'); | |
| 5432 | + return false; | |
| 5433 | + } | |
| 6525 | 5434 | |
| 5435 | + $api_endpoint = "https://{$host}/vectors/delete"; | |
| 5436 | + $vector_id = md5($source_url); | |
| 6526 | 5437 | |
| 5438 | + $request_body = array( | |
| 5439 | + 'ids' => array($vector_id) | |
| 5440 | + ); | |
| 6527 | 5441 | |
| 5442 | + $response = wp_remote_post($api_endpoint, array( | |
| 5443 | + 'headers' => array( | |
| 5444 | + 'Api-Key' => $api_key, | |
| 5445 | + 'accept' => 'application/json', | |
| 5446 | + 'content-type' => 'application/json' | |
| 5447 | + ), | |
| 5448 | + 'body' => wp_json_encode($request_body), | |
| 5449 | + 'timeout' => 30 | |
| 5450 | + )); | |
| 5451 | + | |
| 5452 | + if (is_wp_error($response)) { | |
| 5453 | + //error_log('MXChat: Pinecone deletion error - ' . $response->get_error_message()); | |
| 5454 | + return false; | |
| 5455 | + } | |
| 5456 | + | |
| 5457 | + $response_code = wp_remote_retrieve_response_code($response); | |
| 5458 | + if ($response_code !== 200) { | |
| 5459 | + //error_log('MXChat: Pinecone deletion failed with status ' . $response_code); | |
| 5460 | + return false; | |
| 5461 | + } | |
| 5462 | + | |
| 5463 | + return true; | |
| 5464 | + } | |
| 5465 | + | |
| 5466 | + | |
| 5467 | + | |
| 6528 | 5468 | public function mxchat_handle_product_change($post_id, $post, $update) { |
| 6529 | 5469 | if ($post->post_type !== 'product') { |
| 6530 | 5470 | return; |
| 6531 | 5471 | } |
| @@ -6637,16 +5577,24 @@ | ||
| 6637 | 5577 | } |
| 6638 | 5578 | } |
| 6639 | 5579 | } |
| 6640 | 5580 | |
| 6641 | - // Embedding decision — custom-provider-aware (plan cbd5fd); silent-return | |
| 6642 | - // shape preserved. | |
| 6643 | - $preflight = MxChat_Utils::embedding_preflight(get_option('mxchat_options')); | |
| 6644 | - if (!$preflight['ok']) { | |
| 6645 | - //error_log('MxChat Auto-sync: embedding pre-flight failed: ' . $preflight['reason']); | |
| 5581 | + // Get API key with proper model detection | |
| 5582 | + $options = get_option('mxchat_options'); | |
| 5583 | + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002'; | |
| 5584 | + | |
| 5585 | + if (strpos($selected_model, 'voyage') === 0) { | |
| 5586 | + $api_key = $options['voyage_api_key'] ?? ''; | |
| 5587 | + } elseif (strpos($selected_model, 'gemini-embedding') === 0) { | |
| 5588 | + $api_key = $options['gemini_api_key'] ?? ''; | |
| 5589 | + } else { | |
| 5590 | + $api_key = $options['api_key'] ?? ''; | |
| 5591 | + } | |
| 5592 | + | |
| 5593 | + if (empty($api_key)) { | |
| 5594 | + //error_log('MxChat Auto-sync: No API key configured for embedding model'); | |
| 6646 | 5595 | return; |
| 6647 | 5596 | } |
| 6648 | - $api_key = $preflight['api_key']; | |
| 6649 | 5597 | |
| 6650 | 5598 | // Use the centralized utility function for storage |
| 6651 | 5599 | $result = MxChat_Utils::submit_content_to_db( |
| 6652 | 5600 | $content, |
| @@ -6669,18 +5617,28 @@ | ||
| 6669 | 5617 | if (get_post_type($post_id) !== 'product') { |
| 6670 | 5618 | return; |
| 6671 | 5619 | } |
| 6672 | 5620 | |
| 6673 | - $source_url = $this->mxchat_resolve_pre_trash_url($post_id); | |
| 6674 | - if (!$source_url) { | |
| 6675 | - return; | |
| 6676 | - } | |
| 5621 | + $source_url = get_permalink($post_id); | |
| 6677 | 5622 | |
| 6678 | - // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks) | |
| 6679 | - MxChat_Utils::delete_chunks_for_url($source_url, 'default'); | |
| 5623 | + // Check if Pinecone is enabled | |
| 5624 | + $pinecone_options = get_option('mxchat_pinecone_addon_options', array()); | |
| 5625 | + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1'; | |
| 6680 | 5626 | |
| 6681 | - delete_transient('mxchat_prev_url_' . $post_id); | |
| 6682 | - delete_transient('mxchat_prev_status_' . $post_id); | |
| 5627 | + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) { | |
| 5628 | + // Delete from Pinecone | |
| 5629 | + $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options); | |
| 5630 | + } else { | |
| 5631 | + // Delete from WordPress DB | |
| 5632 | + global $wpdb; | |
| 5633 | + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content'; | |
| 5634 | + | |
| 5635 | + $wpdb->delete( | |
| 5636 | + $table_name, | |
| 5637 | + array('source_url' => $source_url), | |
| 5638 | + array('%s') | |
| 5639 | + ); | |
| 5640 | + } | |
| 6683 | 5641 | } |
| 6684 | 5642 | |
| 6685 | 5643 | /** |
| 6686 | 5644 | * Handle individual Pinecone content deletion |
| @@ -7384,16 +6342,16 @@ | ||
| 7384 | 6342 | wp_send_json_error('Unauthorized access'); |
| 7385 | 6343 | exit; |
| 7386 | 6344 | } |
| 7387 | 6345 | |
| 7388 | - $tag_input = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : ''; | |
| 6346 | + $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : ''; | |
| 7389 | 6347 | $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public'; |
| 7390 | - | |
| 7391 | - if (empty($tag_input)) { | |
| 7392 | - wp_send_json_error('Please enter a tag name or slug'); | |
| 6348 | + | |
| 6349 | + if (empty($tag_slug)) { | |
| 6350 | + wp_send_json_error('Tag slug is required'); | |
| 7393 | 6351 | exit; |
| 7394 | 6352 | } |
| 7395 | - | |
| 6353 | + | |
| 7396 | 6354 | // Validate role restriction |
| 7397 | 6355 | $valid_roles = array_keys($this->mxchat_get_role_options()); |
| 7398 | 6356 | if (!in_array($role_restriction, $valid_roles)) { |
| 7399 | 6357 | wp_send_json_error('Invalid role restriction'); |
| @@ -7398,27 +6356,16 @@ | ||
| 7398 | 6356 | if (!in_array($role_restriction, $valid_roles)) { |
| 7399 | 6357 | wp_send_json_error('Invalid role restriction'); |
| 7400 | 6358 | exit; |
| 7401 | 6359 | } |
| 7402 | - | |
| 7403 | - // Resolve the tag by slug first, then fall back to its display name, so users can | |
| 7404 | - // enter either "premium-content" or "Premium Content". (plan b8bcf5 — the field is | |
| 7405 | - // labeled by name but previously validated by slug only, producing the confusing | |
| 7406 | - // "Tag does not exist in WordPress" error when a real tag's name was typed.) | |
| 7407 | - $term = get_term_by('slug', $tag_input, 'post_tag'); | |
| 6360 | + | |
| 6361 | + // Check if tag exists in WordPress | |
| 6362 | + $term = get_term_by('slug', $tag_slug, 'post_tag'); | |
| 7408 | 6363 | if (!$term) { |
| 7409 | - $term = get_term_by('name', $tag_input, 'post_tag'); | |
| 7410 | - } | |
| 7411 | - if (!$term) { | |
| 7412 | - wp_send_json_error('No tag with that name or slug exists yet. Create it under Posts → Tags first, then enter its name or slug.'); | |
| 6364 | + wp_send_json_error('Tag does not exist in WordPress'); | |
| 7413 | 6365 | exit; |
| 7414 | 6366 | } |
| 7415 | - | |
| 7416 | - // Always key the mapping by the RESOLVED slug — apply_role_restriction_to_post() | |
| 7417 | - // compares against each post's tag slugs, so the stored key must be a slug, | |
| 7418 | - // never the raw (possibly display-name) input. | |
| 7419 | - $tag_slug = $term->slug; | |
| 7420 | - | |
| 6367 | + | |
| 7421 | 6368 | // Get existing mappings |
| 7422 | 6369 | $mappings = get_option('mxchat_tag_role_mappings', array()); |
| 7423 | 6370 | |
| 7424 | 6371 | // Check if mapping already exists |
| @@ -8102,27 +7049,13 @@ | ||
| 8102 | 7049 | |
| 8103 | 7050 | $result = false; |
| 8104 | 7051 | $error_message = ''; |
| 8105 | 7052 | |
| 8106 | - // Read item directly from DB to get queue_id and preserve special chars in item_data | |
| 8107 | - // (POST round-trip through JS mangles characters like apostrophes in URLs) | |
| 8108 | - $db_item = $wpdb->get_row($wpdb->prepare( | |
| 8109 | - "SELECT queue_id, item_data FROM $table_name WHERE id = %d", | |
| 8110 | - $item_id | |
| 8111 | - )); | |
| 8112 | - $item_queue_id = $db_item ? $db_item->queue_id : ''; | |
| 8113 | - if ($db_item && !empty($db_item->item_data)) { | |
| 8114 | - $db_data = json_decode($db_item->item_data, true); | |
| 8115 | - if (is_array($db_data)) { | |
| 8116 | - $item_data = $db_data; | |
| 8117 | - } | |
| 8118 | - } | |
| 8119 | - | |
| 8120 | 7053 | switch ($item_type) { |
| 8121 | 7054 | case 'url': |
| 8122 | - $result = $this->mxchat_process_queue_url($item_data, $bot_id, $item_queue_id); | |
| 7055 | + $result = $this->mxchat_process_queue_url($item_data, $bot_id); | |
| 8123 | 7056 | break; |
| 8124 | - | |
| 7057 | + | |
| 8125 | 7058 | case 'pdf_page': |
| 8126 | 7059 | $result = $this->mxchat_process_queue_pdf_page($item_data, $bot_id); |
| 8127 | 7060 | break; |
| 8128 | 7061 | |
| @@ -8130,37 +7063,11 @@ | ||
| 8130 | 7063 | throw new Exception('Unknown item type: ' . $item_type); |
| 8131 | 7064 | } |
| 8132 | 7065 | |
| 8133 | 7066 | if (is_wp_error($result)) { |
| 8134 | - $error_code = $result->get_error_code(); | |
| 8135 | - // Content errors (empty page, sanitization) are permanent — retrying won't help | |
| 8136 | - $permanent_codes = array('empty_page', 'empty_after_sanitization', 'no_api_key', 'page_not_found'); | |
| 8137 | - if (in_array($error_code, $permanent_codes)) { | |
| 8138 | - // Mark as permanently failed — set attempts = max_attempts so it won't be retried | |
| 8139 | - $current_item = $wpdb->get_row($wpdb->prepare( | |
| 8140 | - "SELECT max_attempts FROM $table_name WHERE id = %d", $item_id | |
| 8141 | - )); | |
| 8142 | - $wpdb->update( | |
| 8143 | - $table_name, | |
| 8144 | - array( | |
| 8145 | - 'status' => 'failed', | |
| 8146 | - 'error_message' => $result->get_error_message(), | |
| 8147 | - 'attempts' => $current_item ? $current_item->max_attempts : 3 | |
| 8148 | - ), | |
| 8149 | - array('id' => $item_id), | |
| 8150 | - array('%s', '%s', '%d'), | |
| 8151 | - array('%d') | |
| 8152 | - ); | |
| 8153 | - wp_send_json_error(array( | |
| 8154 | - 'message' => $result->get_error_message(), | |
| 8155 | - 'permanent_failure' => true, | |
| 8156 | - 'item_id' => $item_id | |
| 8157 | - )); | |
| 8158 | - return; | |
| 8159 | - } | |
| 8160 | 7067 | throw new Exception($result->get_error_message()); |
| 8161 | 7068 | } |
| 8162 | - | |
| 7069 | + | |
| 8163 | 7070 | if ($result === false) { |
| 8164 | 7071 | throw new Exception('Processing returned false - item may be empty or invalid'); |
| 8165 | 7072 | } |
| 8166 | 7073 | |
| @@ -8236,9 +7143,9 @@ | ||
| 8236 | 7143 | |
| 8237 | 7144 | /** |
| 8238 | 7145 | * Process a URL from the queue |
| 8239 | 7146 | */ |
| 8240 | -private function mxchat_process_queue_url($item_data, $bot_id = 'default', $queue_id = '') { | |
| 7147 | +private function mxchat_process_queue_url($item_data, $bot_id = 'default') { | |
| 8241 | 7148 | $url = isset($item_data['url']) ? $item_data['url'] : ''; |
| 8242 | 7149 | |
| 8243 | 7150 | if (empty($url)) { |
| 8244 | 7151 | return new WP_Error('invalid_url', 'URL is empty'); |
| @@ -8243,19 +7150,25 @@ | ||
| 8243 | 7150 | if (empty($url)) { |
| 8244 | 7151 | return new WP_Error('invalid_url', 'URL is empty'); |
| 8245 | 7152 | } |
| 8246 | 7153 | |
| 8247 | - // Get bot-specific embedding decision early (needed for both paths) — | |
| 8248 | - // custom-provider-aware (plan cbd5fd). Error code preserved. | |
| 7154 | + // Get bot-specific API key early (needed for both paths) | |
| 8249 | 7155 | $bot_options = $this->get_bot_options($bot_id); |
| 8250 | 7156 | $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options'); |
| 7157 | + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002'; | |
| 8251 | 7158 | |
| 8252 | - $preflight = MxChat_Utils::embedding_preflight($options); | |
| 8253 | - if (!$preflight['ok']) { | |
| 8254 | - return new WP_Error('no_api_key', $preflight['reason'] . ' (bot: ' . $bot_id . ')'); | |
| 7159 | + if (strpos($selected_model, 'voyage') === 0) { | |
| 7160 | + $api_key = $options['voyage_api_key'] ?? ''; | |
| 7161 | + } elseif (strpos($selected_model, 'gemini-embedding') === 0) { | |
| 7162 | + $api_key = $options['gemini_api_key'] ?? ''; | |
| 7163 | + } else { | |
| 7164 | + $api_key = $options['api_key'] ?? ''; | |
| 8255 | 7165 | } |
| 8256 | - $api_key = $preflight['api_key']; | |
| 8257 | 7166 | |
| 7167 | + if (empty($api_key)) { | |
| 7168 | + return new WP_Error('no_api_key', 'API key not configured for bot: ' . $bot_id); | |
| 7169 | + } | |
| 7170 | + | |
| 8258 | 7171 | // Check if this is a WooCommerce product URL and WooCommerce is active |
| 8259 | 7172 | $is_product_url = (strpos($url, '/product/') !== false || strpos($url, '/shop/') !== false); |
| 8260 | 7173 | $content_type = $is_product_url ? 'product' : 'url'; |
| 8261 | 7174 | |
| @@ -8280,11 +7193,11 @@ | ||
| 8280 | 7193 | |
| 8281 | 7194 | // Fetch URL content (fallback for non-products or when WooCommerce extraction fails) |
| 8282 | 7195 | $is_likely_pdf = (strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION)) === 'pdf'); |
| 8283 | 7196 | $response = wp_remote_get($url, array( |
| 8284 | - 'timeout' => $is_likely_pdf ? 120 : 30, | |
| 7197 | + 'timeout' => $is_likely_pdf ? 60 : 30, | |
| 8285 | 7198 | 'redirection' => 5, |
| 8286 | - 'user-agent' => mxchat_ingest_user_agent(), | |
| 7199 | + 'user-agent' => 'MxChat/1.0' | |
| 8287 | 7200 | )); |
| 8288 | 7201 | |
| 8289 | 7202 | if (is_wp_error($response)) { |
| 8290 | 7203 | return $response; |
| @@ -8291,14 +7204,14 @@ | ||
| 8291 | 7204 | } |
| 8292 | 7205 | |
| 8293 | 7206 | $response_code = wp_remote_retrieve_response_code($response); |
| 8294 | 7207 | if ($response_code !== 200) { |
| 8295 | - return new WP_Error('http_error', 'HTTP ' . $response_code . ' error for: ' . $url); | |
| 7208 | + return new WP_Error('http_error', 'HTTP ' . $response_code . ' error'); | |
| 8296 | 7209 | } |
| 8297 | 7210 | |
| 8298 | - // Check if URL is a PDF — expand into per-page queue items using the standard PDF pipeline | |
| 7211 | + // Check if URL is a PDF — process through PDF pipeline instead of HTML | |
| 8299 | 7212 | if ($this->mxchat_is_pdf_url($url, $response)) { |
| 8300 | - return $this->mxchat_expand_pdf_to_queue($url, $response, $bot_id, $queue_id); | |
| 7213 | + return $this->mxchat_process_pdf_url_inline($url, $response, $api_key, $bot_id); | |
| 8301 | 7214 | } |
| 8302 | 7215 | |
| 8303 | 7216 | $html = wp_remote_retrieve_body($response); |
| 8304 | 7217 | |
| @@ -8328,89 +7241,11 @@ | ||
| 8328 | 7241 | return $result; |
| 8329 | 7242 | } |
| 8330 | 7243 | |
| 8331 | 7244 | /** |
| 8332 | - * Expand a PDF URL into per-page queue items using the standard PDF pipeline. | |
| 8333 | - * Called when a sitemap URL turns out to be a PDF — downloads, parses page count, | |
| 8334 | - * and adds pdf_page items to the same queue so they process with full progress tracking. | |
| 7245 | + * Process a PDF URL inline during sitemap queue processing. | |
| 7246 | + * Downloads the PDF, extracts all pages, and submits each to the DB. | |
| 8335 | 7247 | */ |
| 8336 | -private function mxchat_expand_pdf_to_queue($pdf_url, $response, $bot_id = 'default', $queue_id = '') { | |
| 8337 | - set_time_limit(120); // PDFs need extra time for download + parsing | |
| 8338 | - | |
| 8339 | - $upload_dir = wp_upload_dir(); | |
| 8340 | - $pdf_filename = sanitize_file_name('mxchat_kb_' . md5($pdf_url) . '.pdf'); | |
| 8341 | - $pdf_path = trailingslashit($upload_dir['path']) . $pdf_filename; | |
| 8342 | - | |
| 8343 | - $response_body = wp_remote_retrieve_body($response); | |
| 8344 | - if (empty($response_body)) { | |
| 8345 | - return new WP_Error('empty_pdf', 'Empty PDF response for: ' . $pdf_url); | |
| 8346 | - } | |
| 8347 | - | |
| 8348 | - if (!wp_mkdir_p(dirname($pdf_path))) { | |
| 8349 | - return new WP_Error('dir_error', 'Failed to create upload directory'); | |
| 8350 | - } | |
| 8351 | - | |
| 8352 | - file_put_contents($pdf_path, $response_body); | |
| 8353 | - | |
| 8354 | - if (!file_exists($pdf_path)) { | |
| 8355 | - return new WP_Error('save_error', 'Failed to save PDF file'); | |
| 8356 | - } | |
| 8357 | - | |
| 8358 | - try { | |
| 8359 | - $total_pages = $this->mxchat_validate_and_count_pdf_pages($pdf_path); | |
| 8360 | - | |
| 8361 | - if ($total_pages === false || $total_pages < 1) { | |
| 8362 | - wp_delete_file($pdf_path); | |
| 8363 | - return new WP_Error('no_pages', 'PDF has no pages: ' . $pdf_url); | |
| 8364 | - } | |
| 8365 | - | |
| 8366 | - // Build per-page items identical to mxchat_handle_pdf_for_knowledge_base | |
| 8367 | - $pages = array(); | |
| 8368 | - for ($i = 1; $i <= $total_pages; $i++) { | |
| 8369 | - $pages[] = array( | |
| 8370 | - 'pdf_path' => $pdf_path, | |
| 8371 | - 'pdf_url' => $pdf_url, | |
| 8372 | - 'page_number' => $i, | |
| 8373 | - 'total_pages' => $total_pages | |
| 8374 | - ); | |
| 8375 | - } | |
| 8376 | - | |
| 8377 | - // Add pdf_page items to the SAME queue so the JS picks them up automatically | |
| 8378 | - if (!empty($queue_id)) { | |
| 8379 | - $queued_count = $this->mxchat_add_to_queue($queue_id, 'pdf_page', $pages, $bot_id); | |
| 8380 | - } else { | |
| 8381 | - // Fallback: create a new PDF queue (shouldn't happen in sitemap flow) | |
| 8382 | - $new_queue_id = 'pdf_' . md5($pdf_url . time()); | |
| 8383 | - $queued_count = $this->mxchat_add_to_queue($new_queue_id, 'pdf_page', $pages, $bot_id); | |
| 8384 | - $this->mxchat_set_queue_meta($new_queue_id, 'source_url', $pdf_url); | |
| 8385 | - $this->mxchat_set_queue_meta($new_queue_id, 'queue_type', 'pdf'); | |
| 8386 | - $this->mxchat_set_queue_meta($new_queue_id, 'total_items', $total_pages); | |
| 8387 | - $this->mxchat_set_queue_meta($new_queue_id, 'bot_id', $bot_id); | |
| 8388 | - $this->mxchat_set_queue_meta($new_queue_id, 'pdf_path', $pdf_path); | |
| 8389 | - $this->mxchat_set_queue_meta($new_queue_id, 'created_at', current_time('mysql')); | |
| 8390 | - } | |
| 8391 | - | |
| 8392 | - if ($queued_count === 0) { | |
| 8393 | - wp_delete_file($pdf_path); | |
| 8394 | - return new WP_Error('queue_error', 'Failed to add PDF pages to queue'); | |
| 8395 | - } | |
| 8396 | - | |
| 8397 | - // Return true so the original URL item is marked complete | |
| 8398 | - // The new pdf_page items will be processed in subsequent batches | |
| 8399 | - return true; | |
| 8400 | - | |
| 8401 | - } catch (Exception $e) { | |
| 8402 | - if (file_exists($pdf_path)) { | |
| 8403 | - wp_delete_file($pdf_path); | |
| 8404 | - } | |
| 8405 | - return new WP_Error('pdf_parse_error', 'Error parsing PDF: ' . $e->getMessage()); | |
| 8406 | - } | |
| 8407 | -} | |
| 8408 | - | |
| 8409 | -/** | |
| 8410 | - * Legacy: Process a PDF URL inline during sitemap queue processing. | |
| 8411 | - * @deprecated Use mxchat_expand_pdf_to_queue instead — kept for reference only. | |
| 8412 | - */ | |
| 8413 | 7248 | private function mxchat_process_pdf_url_inline($pdf_url, $response, $api_key, $bot_id = 'default') { |
| 8414 | 7249 | set_time_limit(120); // PDFs need more time — downloading + parsing all pages |
| 8415 | 7250 | |
| 8416 | 7251 | $upload_dir = wp_upload_dir(); |
| @@ -8444,24 +7279,21 @@ | ||
| 8444 | 7279 | return new WP_Error('no_pages', 'PDF has no pages'); |
| 8445 | 7280 | } |
| 8446 | 7281 | |
| 8447 | 7282 | $processed = 0; |
| 8448 | - $skipped_pages = array(); | |
| 8449 | 7283 | |
| 8450 | 7284 | for ($i = 0; $i < $total_pages; $i++) { |
| 8451 | - $page_num = $i + 1; | |
| 8452 | 7285 | $text = $pages[$i]->getText(); |
| 8453 | 7286 | if (empty($text)) { |
| 8454 | - $skipped_pages[] = 'Page ' . $page_num . ': No text could be extracted — page may contain only images, links, or non-standard encoding'; | |
| 8455 | 7287 | continue; |
| 8456 | 7288 | } |
| 8457 | 7289 | |
| 8458 | 7290 | $sanitized = $this->mxchat_sanitize_content_for_api($text); |
| 8459 | 7291 | if (empty($sanitized)) { |
| 8460 | - $skipped_pages[] = 'Page ' . $page_num . ': Text was extracted but contained only special characters, control codes, or unsupported content'; | |
| 8461 | 7292 | continue; |
| 8462 | 7293 | } |
| 8463 | 7294 | |
| 7295 | + $page_num = $i + 1; | |
| 8464 | 7296 | $metadata = array( |
| 8465 | 7297 | 'document_type' => 'pdf', |
| 8466 | 7298 | 'total_pages' => $total_pages, |
| 8467 | 7299 | 'current_page' => $page_num, |
| @@ -8485,12 +7317,8 @@ | ||
| 8485 | 7317 | |
| 8486 | 7318 | // Clean up the temp PDF file |
| 8487 | 7319 | wp_delete_file($pdf_path); |
| 8488 | 7320 | |
| 8489 | - if (!empty($skipped_pages)) { | |
| 8490 | - error_log('MxChat PDF: Skipped ' . count($skipped_pages) . ' of ' . $total_pages . ' pages: ' . implode('; ', $skipped_pages)); | |
| 8491 | - } | |
| 8492 | - | |
| 8493 | 7321 | return $processed > 0 ? true : false; |
| 8494 | 7322 | |
| 8495 | 7323 | } catch (Exception $e) { |
| 8496 | 7324 | if (file_exists($pdf_path)) { |
| @@ -8655,17 +7483,18 @@ | ||
| 8655 | 7483 | return new WP_Error('page_not_found', 'Page ' . $page_number . ' not found in PDF'); |
| 8656 | 7484 | } |
| 8657 | 7485 | |
| 8658 | 7486 | $text = $pages[$page_number - 1]->getText(); |
| 8659 | - | |
| 7487 | + | |
| 8660 | 7488 | if (empty($text)) { |
| 8661 | - return new WP_Error('empty_page', 'Page ' . $page_number . ': No text could be extracted — page may contain only images, links, or non-standard encoding'); | |
| 7489 | + // Not an error - just an empty page | |
| 7490 | + return false; | |
| 8662 | 7491 | } |
| 8663 | - | |
| 7492 | + | |
| 8664 | 7493 | $sanitized = $this->mxchat_sanitize_content_for_api($text); |
| 8665 | - | |
| 7494 | + | |
| 8666 | 7495 | if (empty($sanitized)) { |
| 8667 | - return new WP_Error('empty_after_sanitization', 'Page ' . $page_number . ': Text was extracted but contained only special characters, control codes, or unsupported content that was removed during cleanup'); | |
| 7496 | + return false; | |
| 8668 | 7497 | } |
| 8669 | 7498 | |
| 8670 | 7499 | // Create metadata |
| 8671 | 7500 | $metadata = array( |
| @@ -8677,18 +7506,24 @@ | ||
| 8677 | 7506 | |
| 8678 | 7507 | $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized; |
| 8679 | 7508 | $page_url = esc_url($pdf_url . "#page=" . $page_number); |
| 8680 | 7509 | |
| 8681 | - // Get bot-specific embedding decision — custom-provider-aware | |
| 8682 | - // (plan cbd5fd). Error code preserved. | |
| 7510 | + // Get bot-specific API key | |
| 8683 | 7511 | $bot_options = $this->get_bot_options($bot_id); |
| 8684 | 7512 | $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options'); |
| 8685 | - | |
| 8686 | - $preflight = MxChat_Utils::embedding_preflight($options); | |
| 8687 | - if (!$preflight['ok']) { | |
| 8688 | - return new WP_Error('no_api_key', $preflight['reason'] . ' (bot: ' . $bot_id . ')'); | |
| 7513 | + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002'; | |
| 7514 | + | |
| 7515 | + if (strpos($selected_model, 'voyage') === 0) { | |
| 7516 | + $api_key = $options['voyage_api_key'] ?? ''; | |
| 7517 | + } elseif (strpos($selected_model, 'gemini-embedding') === 0) { | |
| 7518 | + $api_key = $options['gemini_api_key'] ?? ''; | |
| 7519 | + } else { | |
| 7520 | + $api_key = $options['api_key'] ?? ''; | |
| 8689 | 7521 | } |
| 8690 | - $api_key = $preflight['api_key']; | |
| 7522 | + | |
| 7523 | + if (empty($api_key)) { | |
| 7524 | + return new WP_Error('no_api_key', 'API key not configured for bot: ' . $bot_id); | |
| 7525 | + } | |
| 8691 | 7526 | |
| 8692 | 7527 | // Submit to database - UPDATED 2.5.6: Added content_type 'pdf' |
| 8693 | 7528 | $result = MxChat_Utils::submit_content_to_db( |
| 8694 | 7529 | $content_with_metadata, |
| @@ -8763,16 +7598,17 @@ | ||
| 8763 | 7598 | |
| 8764 | 7599 | // Calculate percentage |
| 8765 | 7600 | $percentage = $total > 0 ? round((($completed + $failed) / $total) * 100) : 0; |
| 8766 | 7601 | |
| 8767 | - // Get failed items details (include all failed items, not just those that exhausted retries) | |
| 7602 | + // Get failed items details | |
| 8768 | 7603 | $failed_items = array(); |
| 8769 | 7604 | if ($failed > 0) { |
| 8770 | 7605 | $failed_items = $wpdb->get_results($wpdb->prepare( |
| 8771 | - "SELECT item_type, item_data, error_message, attempts | |
| 8772 | - FROM $table_name | |
| 8773 | - WHERE queue_id = %s | |
| 7606 | + "SELECT item_type, item_data, error_message, attempts | |
| 7607 | + FROM $table_name | |
| 7608 | + WHERE queue_id = %s | |
| 8774 | 7609 | AND status = 'failed' |
| 7610 | + AND attempts >= max_attempts | |
| 8775 | 7611 | ORDER BY id DESC |
| 8776 | 7612 | LIMIT 50", |
| 8777 | 7613 | $queue_id |
| 8778 | 7614 | )); |