| @@ -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,34 +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 | - // In-place repair for RTL KB rows imported in visual order before the | |
| 101 | - // 32bf9e normalizer existed: wp mxchat rtl-repair (plan d1e6f7) | |
| 102 | - WP_CLI::add_command('mxchat rtl-repair', array($this, 'cli_rtl_repair')); | |
| 103 | - } | |
| 104 | - | |
| 105 | 76 | add_action('wp_ajax_mxchat_mark_queue_complete', array($this, 'ajax_mxchat_mark_queue_complete')); |
| 106 | 77 | |
| 107 | 78 | // WooCommerce product hooks (if WooCommerce is active) |
| 108 | 79 | if (class_exists('WooCommerce')) { |
| @@ -150,17 +121,26 @@ | ||
| 150 | 121 | |
| 151 | 122 | // Get bot-specific options and API key |
| 152 | 123 | $bot_options = $this->get_bot_options($bot_id); |
| 153 | 124 | $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options'); |
| 154 | - | |
| 155 | - // Custom-provider-aware decision; keyless custom sites must pass (plan cbd5fd). | |
| 156 | - $preflight = MxChat_Utils::embedding_preflight($options); | |
| 157 | - if (!$preflight['ok']) { | |
| 158 | - 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 | + ); | |
| 159 | 140 | wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts'))); |
| 160 | 141 | exit; |
| 161 | 142 | } |
| 162 | - $api_key = $preflight['api_key']; | |
| 163 | 143 | |
| 164 | 144 | // Use centralized utility function with bot_id |
| 165 | 145 | $result = MxChat_Utils::submit_content_to_db($article_content, $article_url, $api_key, null, $bot_id); |
| 166 | 146 | |
| @@ -179,238 +159,8 @@ | ||
| 179 | 159 | wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts'))); |
| 180 | 160 | exit; |
| 181 | 161 | } |
| 182 | 162 | |
| 183 | -/** | |
| 184 | - * Handle the "YouTube" KB import source (admin-post form submission). | |
| 185 | - * | |
| 186 | - * Per-video description mode: | |
| 187 | - * - auto: fetch oEmbed metadata (reliable) + best-effort captions transcript. | |
| 188 | - * If no usable transcript, index the metadata anyway, tell the admin, | |
| 189 | - * and bounce back with the manual box pre-filled (never fail silently). | |
| 190 | - * - manual: the admin's own description is what gets indexed; metadata rides along. | |
| 191 | - * | |
| 192 | - * The row is stored with content_type 'youtube' and source_url = the canonical | |
| 193 | - * watch URL, so re-importing the same video UPDATES the entry (source_url | |
| 194 | - * duplicate handling in MxChat_Utils::store_in_wordpress_db) — that is also the | |
| 195 | - * "augment a metadata-only entry" path. | |
| 196 | - */ | |
| 197 | -public function mxchat_handle_youtube_submission() { | |
| 198 | - if (!isset($_POST['submit_youtube']) || !current_user_can('manage_options')) { | |
| 199 | - wp_die(esc_html__('Unauthorized access', 'mxchat')); | |
| 200 | - } | |
| 201 | - | |
| 202 | - check_admin_referer('mxchat_submit_youtube_action', 'mxchat_submit_youtube_nonce'); | |
| 203 | - | |
| 204 | - $redirect_url = admin_url('admin.php?page=mxchat-prompts'); | |
| 205 | - | |
| 206 | - $youtube_url = isset($_POST['youtube_url']) ? esc_url_raw(wp_unslash($_POST['youtube_url'])) : ''; | |
| 207 | - $video_id = MxChat_Utils::parse_youtube_id($youtube_url); | |
| 208 | - | |
| 209 | - if (empty($video_id)) { | |
| 210 | - set_transient('mxchat_admin_notice_error', | |
| 211 | - esc_html__('That does not look like a link to a single YouTube video. Please paste a watch, youtu.be, or Shorts URL.', 'mxchat'), | |
| 212 | - 30 | |
| 213 | - ); | |
| 214 | - wp_safe_redirect(esc_url($redirect_url)); | |
| 215 | - exit; | |
| 216 | - } | |
| 217 | - | |
| 218 | - $canonical_url = 'https://www.youtube.com/watch?v=' . $video_id; | |
| 219 | - | |
| 220 | - $description_mode = (isset($_POST['youtube_description_mode']) && $_POST['youtube_description_mode'] === 'manual') ? 'manual' : 'auto'; | |
| 221 | - $manual_description = isset($_POST['youtube_description']) ? trim(wp_kses_post(wp_unslash($_POST['youtube_description']))) : ''; | |
| 222 | - | |
| 223 | - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default'; | |
| 224 | - | |
| 225 | - // Resolve the embedding decision exactly like the sibling handlers — | |
| 226 | - // custom-provider-aware (plan cbd5fd). | |
| 227 | - $bot_options = $this->get_bot_options($bot_id); | |
| 228 | - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options'); | |
| 229 | - | |
| 230 | - $preflight = MxChat_Utils::embedding_preflight($options); | |
| 231 | - if (!$preflight['ok']) { | |
| 232 | - set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30); | |
| 233 | - wp_safe_redirect(esc_url($redirect_url)); | |
| 234 | - exit; | |
| 235 | - } | |
| 236 | - $api_key = $preflight['api_key']; | |
| 237 | - | |
| 238 | - // Metadata is fetched in BOTH modes — it is the reliable half of auto, and in | |
| 239 | - // manual mode it enriches the indexed text with the real title/channel. | |
| 240 | - $meta = $this->mxchat_fetch_youtube_oembed($video_id); | |
| 241 | - $video_title = isset($meta['title']) ? sanitize_text_field($meta['title']) : ''; | |
| 242 | - $video_channel = isset($meta['author_name']) ? sanitize_text_field($meta['author_name']) : ''; | |
| 243 | - | |
| 244 | - $header_lines = 'YouTube Video: ' . ($video_title !== '' ? $video_title : $canonical_url) . "\n"; | |
| 245 | - if ($video_channel !== '') { | |
| 246 | - $header_lines .= 'Channel: ' . $video_channel . "\n"; | |
| 247 | - } | |
| 248 | - $header_lines .= 'URL: ' . $canonical_url . "\n\n"; | |
| 249 | - | |
| 250 | - $transcript_missing = false; | |
| 251 | - | |
| 252 | - if ($description_mode === 'manual') { | |
| 253 | - if ($manual_description === '') { | |
| 254 | - set_transient('mxchat_admin_notice_error', | |
| 255 | - esc_html__('Please write a description for the video, or switch to auto-fetch.', 'mxchat'), | |
| 256 | - 30 | |
| 257 | - ); | |
| 258 | - wp_safe_redirect(esc_url($redirect_url)); | |
| 259 | - exit; | |
| 260 | - } | |
| 261 | - $indexed_text = $header_lines . $manual_description; | |
| 262 | - } else { | |
| 263 | - $transcript = $this->mxchat_fetch_youtube_transcript($video_id); | |
| 264 | - | |
| 265 | - if (strlen($transcript) >= 200) { | |
| 266 | - $indexed_text = $header_lines . $transcript; | |
| 267 | - } else { | |
| 268 | - // Graceful fallback: captions disabled / blocked / no speech. Auto | |
| 269 | - // reliably gets metadata; it does NOT guarantee a transcript. | |
| 270 | - $transcript_missing = true; | |
| 271 | - | |
| 272 | - if ($video_title === '' && $video_channel === '') { | |
| 273 | - // Both halves failed — nothing meaningful to index. | |
| 274 | - set_transient('mxchat_admin_notice_error', | |
| 275 | - 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'), | |
| 276 | - 30 | |
| 277 | - ); | |
| 278 | - wp_safe_redirect(esc_url($redirect_url)); | |
| 279 | - exit; | |
| 280 | - } | |
| 281 | - | |
| 282 | - $indexed_text = $header_lines . sprintf( | |
| 283 | - /* translators: 1: video title, 2: channel name */ | |
| 284 | - __('A YouTube video titled "%1$s" from the channel %2$s.', 'mxchat'), | |
| 285 | - $video_title !== '' ? $video_title : $canonical_url, | |
| 286 | - $video_channel !== '' ? $video_channel : 'YouTube' | |
| 287 | - ); | |
| 288 | - } | |
| 289 | - } | |
| 290 | - | |
| 291 | - $result = MxChat_Utils::submit_content_to_db($indexed_text, $canonical_url, $api_key, null, $bot_id, 'youtube'); | |
| 292 | - | |
| 293 | - if (is_wp_error($result)) { | |
| 294 | - set_transient('mxchat_admin_notice_error', | |
| 295 | - esc_html__('Error storing video in the knowledge base: ', 'mxchat') . $result->get_error_message(), | |
| 296 | - 30 | |
| 297 | - ); | |
| 298 | - wp_safe_redirect(esc_url($redirect_url)); | |
| 299 | - exit; | |
| 300 | - } | |
| 301 | - | |
| 302 | - if ($transcript_missing) { | |
| 303 | - set_transient('mxchat_admin_notice_success', | |
| 304 | - 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'), | |
| 305 | - 30 | |
| 306 | - ); | |
| 307 | - // Bounce back with prefill args so the page reopens the YouTube form in | |
| 308 | - // manual mode with the URL + fetched title ready to augment. | |
| 309 | - $redirect_url = add_query_arg(array( | |
| 310 | - 'mxchat_yt_prefill' => '1', | |
| 311 | - 'yt_url' => rawurlencode($canonical_url), | |
| 312 | - 'yt_title' => rawurlencode($video_title), | |
| 313 | - ), $redirect_url); | |
| 314 | - } else { | |
| 315 | - set_transient('mxchat_admin_notice_success', | |
| 316 | - esc_html__('YouTube video successfully added to the knowledge base!', 'mxchat'), | |
| 317 | - 30 | |
| 318 | - ); | |
| 319 | - } | |
| 320 | - | |
| 321 | - wp_safe_redirect(esc_url_raw($redirect_url)); | |
| 322 | - exit; | |
| 323 | -} | |
| 324 | - | |
| 325 | -/** | |
| 326 | - * Fetch YouTube oEmbed metadata for a video (no API key required). | |
| 327 | - * Returns the decoded array (title, author_name, thumbnail_url, ...) or array(). | |
| 328 | - */ | |
| 329 | -private function mxchat_fetch_youtube_oembed($video_id) { | |
| 330 | - $oembed_url = 'https://www.youtube.com/oembed?url=' . rawurlencode('https://www.youtube.com/watch?v=' . $video_id) . '&format=json'; | |
| 331 | - $response = wp_remote_get($oembed_url, array('timeout' => 15)); | |
| 332 | - if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) { | |
| 333 | - return array(); | |
| 334 | - } | |
| 335 | - $data = json_decode(wp_remote_retrieve_body($response), true); | |
| 336 | - return is_array($data) ? $data : array(); | |
| 337 | -} | |
| 338 | - | |
| 339 | -/** | |
| 340 | - * Best-effort captions transcript for a video. Deliberately ISOLATED: this uses | |
| 341 | - * YouTube's unofficial timedtext route (the caption track list embedded in the | |
| 342 | - * watch page), which YouTube has broken before and will break again. Every | |
| 343 | - * failure mode returns '' so a break degrades to the metadata-only import path | |
| 344 | - * instead of erroring the whole submission. Do not let anything in here throw. | |
| 345 | - */ | |
| 346 | -private function mxchat_fetch_youtube_transcript($video_id) { | |
| 347 | - $watch_url = 'https://www.youtube.com/watch?v=' . $video_id . '&hl=en'; | |
| 348 | - | |
| 349 | - // First try the honest ingest UA; some responses omit the player config for | |
| 350 | - // bot UAs, so retry once with a browser UA before giving up. | |
| 351 | - $user_agents = array( | |
| 352 | - mxchat_ingest_user_agent(), | |
| 353 | - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', | |
| 354 | - ); | |
| 355 | - | |
| 356 | - $tracks = array(); | |
| 357 | - foreach ($user_agents as $ua) { | |
| 358 | - $response = wp_remote_get($watch_url, array( | |
| 359 | - 'timeout' => 20, | |
| 360 | - 'user-agent' => $ua, | |
| 361 | - )); | |
| 362 | - if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) { | |
| 363 | - continue; | |
| 364 | - } | |
| 365 | - $body = wp_remote_retrieve_body($response); | |
| 366 | - if (!is_string($body) || $body === '' || !preg_match('/"captionTracks":(\[.*?\])(?=,")/s', $body, $m)) { | |
| 367 | - continue; | |
| 368 | - } | |
| 369 | - $decoded = json_decode($m[1], true); | |
| 370 | - if (is_array($decoded) && !empty($decoded)) { | |
| 371 | - $tracks = $decoded; | |
| 372 | - break; | |
| 373 | - } | |
| 374 | - } | |
| 375 | - | |
| 376 | - if (empty($tracks)) { | |
| 377 | - return ''; | |
| 378 | - } | |
| 379 | - | |
| 380 | - // Prefer an English track, else take the first offered. | |
| 381 | - $chosen = null; | |
| 382 | - foreach ($tracks as $track) { | |
| 383 | - if (isset($track['languageCode']) && strpos($track['languageCode'], 'en') === 0) { | |
| 384 | - $chosen = $track; | |
| 385 | - break; | |
| 386 | - } | |
| 387 | - } | |
| 388 | - if ($chosen === null) { | |
| 389 | - $chosen = $tracks[0]; | |
| 390 | - } | |
| 391 | - if (empty($chosen['baseUrl']) || !is_string($chosen['baseUrl'])) { | |
| 392 | - return ''; | |
| 393 | - } | |
| 394 | - | |
| 395 | - $timedtext = wp_remote_get($chosen['baseUrl'], array('timeout' => 20)); | |
| 396 | - if (is_wp_error($timedtext) || wp_remote_retrieve_response_code($timedtext) !== 200) { | |
| 397 | - return ''; | |
| 398 | - } | |
| 399 | - $xml = wp_remote_retrieve_body($timedtext); | |
| 400 | - if (!is_string($xml) || strpos($xml, '<text') === false) { | |
| 401 | - return ''; | |
| 402 | - } | |
| 403 | - | |
| 404 | - // <text start=".." dur="..">caption</text> — strip tags, decode the | |
| 405 | - // double-encoded entities timedtext ships, collapse whitespace. | |
| 406 | - $text = preg_replace('/<[^>]+>/', ' ', $xml); | |
| 407 | - $text = html_entity_decode(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'), ENT_QUOTES | ENT_HTML5, 'UTF-8'); | |
| 408 | - $text = trim(preg_replace('/\s+/u', ' ', $text)); | |
| 409 | - | |
| 410 | - return $text; | |
| 411 | -} | |
| 412 | - | |
| 413 | 163 | public function mxchat_is_pdf_url($url, $response) { |
| 414 | 164 | $content_type = wp_remote_retrieve_header($response, 'content-type'); |
| 415 | 165 | $file_extension = strtolower(pathinfo($url, PATHINFO_EXTENSION)); |
| 416 | 166 | |
| @@ -1000,18 +750,17 @@ | ||
| 1000 | 750 | $base_id = md5( $source_url ); |
| 1001 | 751 | $vector_ids = array( $base_id ); |
| 1002 | 752 | |
| 1003 | 753 | // Find chunk vectors |
| 1004 | - // NOTE: Pinecone's /vectors/list is a GET endpoint with query parameters. | |
| 1005 | - // A POST is answered 200-with-an-empty-body, which reads as "no vectors". | |
| 1006 | - $list_url = "https://{$host}/vectors/list"; | |
| 1007 | - $list_params = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 ); | |
| 754 | + $list_url = "https://{$host}/vectors/list"; | |
| 755 | + $list_body = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 ); | |
| 1008 | 756 | if ( ! empty($namespace) ) { |
| 1009 | - $list_params['namespace'] = $namespace; | |
| 757 | + $list_body['namespace'] = $namespace; | |
| 1010 | 758 | } |
| 1011 | 759 | |
| 1012 | - $list_resp = wp_remote_get( $list_url . '?' . http_build_query( $list_params ), array( | |
| 1013 | - 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ), | |
| 760 | + $list_resp = wp_remote_post( $list_url, array( | |
| 761 | + 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ), | |
| 762 | + 'body' => wp_json_encode( $list_body ), | |
| 1014 | 763 | 'timeout' => 15, |
| 1015 | 764 | ) ); |
| 1016 | 765 | |
| 1017 | 766 | if ( ! is_wp_error($list_resp) ) { |
| @@ -1023,21 +772,17 @@ | ||
| 1023 | 772 | } |
| 1024 | 773 | } |
| 1025 | 774 | |
| 1026 | 775 | // Fetch vectors with metadata |
| 1027 | - // NOTE: /vectors/fetch is a GET endpoint too, and Pinecone expects the ids | |
| 1028 | - // repeated (ids=a&ids=b) — http_build_query would emit ids[0]=a, so build | |
| 1029 | - // the query string explicitly. | |
| 1030 | - $fetch_query = array(); | |
| 1031 | - foreach ( $vector_ids as $fetch_vid ) { | |
| 1032 | - $fetch_query[] = 'ids=' . rawurlencode( $fetch_vid ); | |
| 1033 | - } | |
| 776 | + $fetch_url = "https://{$host}/vectors/fetch"; | |
| 777 | + $fetch_body = array( 'ids' => $vector_ids ); | |
| 1034 | 778 | if ( ! empty($namespace) ) { |
| 1035 | - $fetch_query[] = 'namespace=' . rawurlencode( $namespace ); | |
| 779 | + $fetch_body['namespace'] = $namespace; | |
| 1036 | 780 | } |
| 1037 | 781 | |
| 1038 | - $fetch_resp = wp_remote_get( "https://{$host}/vectors/fetch?" . implode( '&', $fetch_query ), array( | |
| 1039 | - 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ), | |
| 782 | + $fetch_resp = wp_remote_post( $fetch_url, array( | |
| 783 | + 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ), | |
| 784 | + 'body' => wp_json_encode( $fetch_body ), | |
| 1040 | 785 | 'timeout' => 15, |
| 1041 | 786 | ) ); |
| 1042 | 787 | |
| 1043 | 788 | if ( is_wp_error($fetch_resp) ) { |
| @@ -1072,235 +817,8 @@ | ||
| 1072 | 817 | ); |
| 1073 | 818 | } |
| 1074 | 819 | |
| 1075 | 820 | /** |
| 1076 | - * AJAX: Inspect a knowledge entry — returns the per-chunk stored text + metadata | |
| 1077 | - * WITHOUT collapsing it, so a site owner can see exactly what was indexed for an | |
| 1078 | - * entry (plan-mxchat-20260628-d8cb4b). READ-ONLY: never re-embeds or mutates. | |
| 1079 | - */ | |
| 1080 | -public function ajax_mxchat_inspect_entry() { | |
| 1081 | - check_ajax_referer('mxchat_inspect_entry_nonce', 'nonce'); | |
| 1082 | - | |
| 1083 | - if ( ! current_user_can('manage_options') ) { | |
| 1084 | - wp_send_json_error( array( 'message' => esc_html__('Permission denied.', 'mxchat') ) ); | |
| 1085 | - } | |
| 1086 | - | |
| 1087 | - $source_url = isset($_POST['source_url']) ? sanitize_text_field( wp_unslash($_POST['source_url']) ) : ''; | |
| 1088 | - $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0; | |
| 1089 | - $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress'; | |
| 1090 | - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default'; | |
| 1091 | - | |
| 1092 | - if ( $data_source === 'pinecone' ) { | |
| 1093 | - $result = $this->inspect_pinecone_entry( $source_url, $entry_id, $bot_id ); | |
| 1094 | - } else { | |
| 1095 | - $result = $this->inspect_wordpress_entry( $source_url, $entry_id ); | |
| 1096 | - } | |
| 1097 | - | |
| 1098 | - if ( is_wp_error( $result ) ) { | |
| 1099 | - wp_send_json_error( array( 'message' => $result->get_error_message() ) ); | |
| 1100 | - } | |
| 1101 | - | |
| 1102 | - wp_send_json_success( $result ); | |
| 1103 | -} | |
| 1104 | - | |
| 1105 | -/** | |
| 1106 | - * Read-only inspector for WordPress-DB entries. Mirrors get_wordpress_entry_content() | |
| 1107 | - * but returns each STORED chunk's exact text + length (no implode), plus the assembled | |
| 1108 | - * embedded text. This shows what is actually in the index, not a re-derivation from the post. | |
| 1109 | - */ | |
| 1110 | -private function inspect_wordpress_entry( $source_url, $entry_id ) { | |
| 1111 | - global $wpdb; | |
| 1112 | - $table = $wpdb->prefix . 'mxchat_system_prompt_content'; | |
| 1113 | - | |
| 1114 | - $rows = array(); | |
| 1115 | - | |
| 1116 | - // Group by the real stored source_url — this INCLUDES "mxchat://" manual | |
| 1117 | - // Direct Content entries (the spec's manual-entry case), which share one | |
| 1118 | - // source_url across their chunk rows. Only the synthetic "_ungrouped_<id>" | |
| 1119 | - // display key (invented by the table view for rows with no source_url) is | |
| 1120 | - // excluded; those fall through to the entry_id lookup below. | |
| 1121 | - if ( ! empty( $source_url ) && strpos( $source_url, '_ungrouped_' ) !== 0 ) { | |
| 1122 | - $rows = $wpdb->get_results( $wpdb->prepare( | |
| 1123 | - "SELECT id, article_content, source_url, content_type FROM {$table} WHERE source_url = %s ORDER BY id ASC", | |
| 1124 | - $source_url | |
| 1125 | - ) ); | |
| 1126 | - } | |
| 1127 | - | |
| 1128 | - // Fallback / manual "Direct Content" entries: fetch the single row by id. | |
| 1129 | - if ( empty( $rows ) && $entry_id > 0 ) { | |
| 1130 | - $row = $wpdb->get_row( $wpdb->prepare( | |
| 1131 | - "SELECT id, article_content, source_url, content_type FROM {$table} WHERE id = %d", | |
| 1132 | - $entry_id | |
| 1133 | - ) ); | |
| 1134 | - if ( $row ) { | |
| 1135 | - $rows = array( $row ); | |
| 1136 | - } | |
| 1137 | - } | |
| 1138 | - | |
| 1139 | - if ( empty( $rows ) ) { | |
| 1140 | - return new WP_Error( 'not_found', esc_html__('Entry not found in the local knowledge database.', 'mxchat') ); | |
| 1141 | - } | |
| 1142 | - | |
| 1143 | - $chunks = array(); | |
| 1144 | - $content_type = ''; | |
| 1145 | - foreach ( $rows as $row ) { | |
| 1146 | - $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content ); | |
| 1147 | - $text = isset( $parsed['text'] ) ? $parsed['text'] : ''; | |
| 1148 | - $index = isset( $parsed['metadata']['chunk_index'] ) ? intval( $parsed['metadata']['chunk_index'] ) : count( $chunks ); | |
| 1149 | - $content_type = $row->content_type; | |
| 1150 | - $chunks[] = array( | |
| 1151 | - 'index' => $index, | |
| 1152 | - 'text' => $text, | |
| 1153 | - 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ), | |
| 1154 | - 'row_id' => intval( $row->id ), | |
| 1155 | - ); | |
| 1156 | - } | |
| 1157 | - | |
| 1158 | - usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } ); | |
| 1159 | - | |
| 1160 | - $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) ); | |
| 1161 | - | |
| 1162 | - return array( | |
| 1163 | - 'store' => 'wordpress', | |
| 1164 | - 'source_url' => $source_url, | |
| 1165 | - 'content_type' => $content_type, | |
| 1166 | - 'is_chunked' => count( $chunks ) > 1, | |
| 1167 | - 'chunk_count' => count( $chunks ), | |
| 1168 | - 'assembled' => $assembled, | |
| 1169 | - 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ), | |
| 1170 | - 'chunks' => array_values( $chunks ), | |
| 1171 | - // WP-DB storage carries no separate vector metadata; surface that fact | |
| 1172 | - // rather than letting the owner guess (the spec's taxonomy question). | |
| 1173 | - 'metadata' => array(), | |
| 1174 | - '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'), | |
| 1175 | - ); | |
| 1176 | -} | |
| 1177 | - | |
| 1178 | -/** | |
| 1179 | - * Read-only inspector for Pinecone entries. Mirrors get_pinecone_entry_content() | |
| 1180 | - * but keeps each vector's text + metadata instead of imploding, so the owner can | |
| 1181 | - * confirm exactly which metadata fields (text/source_url/type/last_updated/created_at/bot_id) | |
| 1182 | - * are present per chunk. READ-ONLY. | |
| 1183 | - */ | |
| 1184 | -private function inspect_pinecone_entry( $source_url, $entry_id, $bot_id ) { | |
| 1185 | - if ( ! class_exists('MxChat_Pinecone_Manager') ) { | |
| 1186 | - return new WP_Error( 'pinecone_unavailable', esc_html__('Pinecone manager not available.', 'mxchat') ); | |
| 1187 | - } | |
| 1188 | - | |
| 1189 | - if ( $bot_id === 'default' || ! class_exists('MxChat_Multi_Bot_Manager') ) { | |
| 1190 | - $pinecone_options = get_option('mxchat_pinecone_addon_options'); | |
| 1191 | - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? ''; | |
| 1192 | - $host = $pinecone_options['mxchat_pinecone_host'] ?? ''; | |
| 1193 | - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? ''; | |
| 1194 | - } else { | |
| 1195 | - $bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id); | |
| 1196 | - $api_key = $bot_config['api_key'] ?? ''; | |
| 1197 | - $host = $bot_config['host'] ?? ''; | |
| 1198 | - $namespace = $bot_config['namespace'] ?? ''; | |
| 1199 | - } | |
| 1200 | - | |
| 1201 | - if ( empty($host) || empty($api_key) ) { | |
| 1202 | - return new WP_Error( 'pinecone_config', esc_html__('Pinecone not configured.', 'mxchat') ); | |
| 1203 | - } | |
| 1204 | - | |
| 1205 | - $base_id = md5( $source_url ); | |
| 1206 | - $vector_ids = array( $base_id ); | |
| 1207 | - | |
| 1208 | - // NOTE: Pinecone's /vectors/list is a GET endpoint with query parameters. | |
| 1209 | - // A POST is answered 200-with-an-empty-body, which reads as "no vectors". | |
| 1210 | - $list_url = "https://{$host}/vectors/list"; | |
| 1211 | - $list_params = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 ); | |
| 1212 | - if ( ! empty($namespace) ) { | |
| 1213 | - $list_params['namespace'] = $namespace; | |
| 1214 | - } | |
| 1215 | - | |
| 1216 | - $list_resp = wp_remote_get( $list_url . '?' . http_build_query( $list_params ), array( | |
| 1217 | - 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ), | |
| 1218 | - 'timeout' => 15, | |
| 1219 | - ) ); | |
| 1220 | - | |
| 1221 | - if ( ! is_wp_error($list_resp) ) { | |
| 1222 | - $list_data = json_decode( wp_remote_retrieve_body($list_resp), true ); | |
| 1223 | - if ( ! empty($list_data['vectors']) ) { | |
| 1224 | - foreach ( $list_data['vectors'] as $v ) { | |
| 1225 | - $vector_ids[] = $v['id']; | |
| 1226 | - } | |
| 1227 | - } | |
| 1228 | - } | |
| 1229 | - | |
| 1230 | - // NOTE: /vectors/fetch is a GET endpoint too, and Pinecone expects the ids | |
| 1231 | - // repeated (ids=a&ids=b) — http_build_query would emit ids[0]=a, so build | |
| 1232 | - // the query string explicitly. | |
| 1233 | - $fetch_query = array(); | |
| 1234 | - foreach ( $vector_ids as $fetch_vid ) { | |
| 1235 | - $fetch_query[] = 'ids=' . rawurlencode( $fetch_vid ); | |
| 1236 | - } | |
| 1237 | - if ( ! empty($namespace) ) { | |
| 1238 | - $fetch_query[] = 'namespace=' . rawurlencode( $namespace ); | |
| 1239 | - } | |
| 1240 | - | |
| 1241 | - $fetch_resp = wp_remote_get( "https://{$host}/vectors/fetch?" . implode( '&', $fetch_query ), array( | |
| 1242 | - 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ), | |
| 1243 | - 'timeout' => 15, | |
| 1244 | - ) ); | |
| 1245 | - | |
| 1246 | - if ( is_wp_error($fetch_resp) ) { | |
| 1247 | - return new WP_Error( 'pinecone_fetch', esc_html__('Failed to fetch from Pinecone.', 'mxchat') ); | |
| 1248 | - } | |
| 1249 | - | |
| 1250 | - $fetch_data = json_decode( wp_remote_retrieve_body($fetch_resp), true ); | |
| 1251 | - $vectors = $fetch_data['vectors'] ?? array(); | |
| 1252 | - | |
| 1253 | - if ( empty($vectors) ) { | |
| 1254 | - return new WP_Error( 'not_found', esc_html__('Entry not found in Pinecone.', 'mxchat') ); | |
| 1255 | - } | |
| 1256 | - | |
| 1257 | - // Whitelisted metadata fields the spec calls out — shown so devs can confirm | |
| 1258 | - // what is (and is NOT) stored per vector. | |
| 1259 | - $meta_fields = array( 'text', 'source_url', 'type', 'last_updated', 'created_at', 'bot_id', 'chunk_index', 'total_chunks' ); | |
| 1260 | - $chunks = array(); | |
| 1261 | - $content_type = ''; | |
| 1262 | - foreach ( $vectors as $vid => $vector ) { | |
| 1263 | - $meta = isset($vector['metadata']) && is_array($vector['metadata']) ? $vector['metadata'] : array(); | |
| 1264 | - $text = $meta['text'] ?? ''; | |
| 1265 | - $index = isset($meta['chunk_index']) ? intval($meta['chunk_index']) : count($chunks); | |
| 1266 | - $content_type = $meta['type'] ?? $content_type; | |
| 1267 | - | |
| 1268 | - $clean_meta = array(); | |
| 1269 | - foreach ( $meta_fields as $field ) { | |
| 1270 | - if ( array_key_exists( $field, $meta ) && $field !== 'text' ) { | |
| 1271 | - $clean_meta[ $field ] = is_scalar( $meta[ $field ] ) ? (string) $meta[ $field ] : wp_json_encode( $meta[ $field ] ); | |
| 1272 | - } | |
| 1273 | - } | |
| 1274 | - | |
| 1275 | - $chunks[] = array( | |
| 1276 | - 'index' => $index, | |
| 1277 | - 'text' => $text, | |
| 1278 | - 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ), | |
| 1279 | - 'vector_id' => (string) $vid, | |
| 1280 | - 'metadata' => $clean_meta, | |
| 1281 | - ); | |
| 1282 | - } | |
| 1283 | - | |
| 1284 | - usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } ); | |
| 1285 | - | |
| 1286 | - $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) ); | |
| 1287 | - | |
| 1288 | - return array( | |
| 1289 | - 'store' => 'pinecone', | |
| 1290 | - 'source_url' => $source_url, | |
| 1291 | - 'content_type' => $content_type, | |
| 1292 | - 'is_chunked' => count( $chunks ) > 1, | |
| 1293 | - 'chunk_count' => count( $chunks ), | |
| 1294 | - 'assembled' => $assembled, | |
| 1295 | - 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ), | |
| 1296 | - 'chunks' => array_values( $chunks ), | |
| 1297 | - 'metadata' => array(), | |
| 1298 | - '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'), | |
| 1299 | - ); | |
| 1300 | -} | |
| 1301 | - | |
| 1302 | -/** | |
| 1303 | 821 | * AJAX: Save edited content — re-chunks and re-embeds as needed. |
| 1304 | 822 | * Works for both WordPress DB and Pinecone entries. |
| 1305 | 823 | */ |
| 1306 | 824 | public function ajax_mxchat_save_entry_content() { |
| @@ -1449,34 +967,42 @@ | ||
| 1449 | 967 | |
| 1450 | 968 | // Get bot_id from form submission |
| 1451 | 969 | $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default'; |
| 1452 | 970 | |
| 1453 | - // Get bot-specific options and validate the embedding decision — | |
| 1454 | - // custom-provider-aware (plan cbd5fd). | |
| 971 | + // Get bot-specific options and validate API key | |
| 1455 | 972 | $bot_options = $this->get_bot_options($bot_id); |
| 1456 | 973 | $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options'); |
| 1457 | - | |
| 1458 | - $preflight = MxChat_Utils::embedding_preflight($options); | |
| 1459 | - if (!$preflight['ok']) { | |
| 1460 | - set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30); | |
| 974 | + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002'; | |
| 975 | + | |
| 976 | + if (strpos($selected_model, 'voyage') === 0) { | |
| 977 | + $api_key = $options['voyage_api_key'] ?? ''; | |
| 978 | + $provider_name = 'Voyage AI'; | |
| 979 | + } elseif (strpos($selected_model, 'gemini-embedding') === 0) { | |
| 980 | + $api_key = $options['gemini_api_key'] ?? ''; | |
| 981 | + $provider_name = 'Google Gemini'; | |
| 982 | + } else { | |
| 983 | + $api_key = $options['api_key'] ?? ''; | |
| 984 | + $provider_name = 'OpenAI'; | |
| 985 | + } | |
| 986 | + | |
| 987 | + if (empty($api_key)) { | |
| 988 | + $error_message = sprintf( | |
| 989 | + esc_html__('%s API key is not configured. Please add your API key in the settings before submitting content.', 'mxchat'), | |
| 990 | + $provider_name | |
| 991 | + ); | |
| 992 | + set_transient('mxchat_admin_notice_error', $error_message, 30); | |
| 1461 | 993 | wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts'))); |
| 1462 | 994 | exit; |
| 1463 | 995 | } |
| 1464 | - $api_key = $preflight['api_key']; | |
| 1465 | 996 | |
| 1466 | - // Fetch URL — send an honest, versioned MXChat crawler UA (not a spoofed | |
| 1467 | - // browser). Stale browser UAs are exactly what WAFs like SiteGround's | |
| 1468 | - // ModSecurity flag as scrapers, 403-ing the fetch (including PDFs served | |
| 1469 | - // from the site's own media library, which route through this same call). | |
| 1470 | - // See mxchat_ingest_user_agent(). Accept is kept for content negotiation; | |
| 1471 | - // the browser-only Accept-Language fingerprint is dropped so it stays | |
| 1472 | - // coherent with a bot identity. | |
| 997 | + // Fetch URL — use browser-like headers so servers with bot protection don't block us | |
| 1473 | 998 | $response = wp_remote_get($submitted_url, array( |
| 1474 | 999 | 'timeout' => 30, |
| 1475 | 1000 | 'sslverify' => false, |
| 1476 | - 'user-agent' => mxchat_ingest_user_agent(), | |
| 1001 | + 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', | |
| 1477 | 1002 | 'headers' => array( |
| 1478 | 1003 | 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', |
| 1004 | + 'Accept-Language' => 'en-US,en;q=0.9', | |
| 1479 | 1005 | ), |
| 1480 | 1006 | )); |
| 1481 | 1007 | |
| 1482 | 1008 | if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) { |
| @@ -1547,22 +1073,12 @@ | ||
| 1547 | 1073 | esc_html__('Sitemap queued for processing. Processing will start automatically.', 'mxchat'), |
| 1548 | 1074 | 30 |
| 1549 | 1075 | ); |
| 1550 | 1076 | } else { |
| 1551 | - // Surface the reason the handler already computed (embedding pre-flight, | |
| 1552 | - // empty sitemap, queue failure). The old message pointed at the status | |
| 1553 | - // area, which is empty on this path — nothing was ever queued. | |
| 1554 | - if (is_string($result) && $result !== '') { | |
| 1555 | - set_transient('mxchat_admin_notice_error', | |
| 1556 | - esc_html__('Failed to queue sitemap processing: ', 'mxchat') . esc_html($result), | |
| 1557 | - 30 | |
| 1558 | - ); | |
| 1559 | - } else { | |
| 1560 | - set_transient('mxchat_admin_notice_error', | |
| 1561 | - esc_html__('Failed to queue sitemap processing.', 'mxchat'), | |
| 1562 | - 30 | |
| 1563 | - ); | |
| 1564 | - } | |
| 1077 | + set_transient('mxchat_admin_notice_error', | |
| 1078 | + esc_html__('Failed to queue sitemap processing. Please check the status below for details.', 'mxchat'), | |
| 1079 | + 30 | |
| 1080 | + ); | |
| 1565 | 1081 | } |
| 1566 | 1082 | |
| 1567 | 1083 | wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts'))); |
| 1568 | 1084 | exit; |
| @@ -1705,140 +1221,8 @@ | ||
| 1705 | 1221 | * |
| 1706 | 1222 | * @param string $content The content containing shortcodes |
| 1707 | 1223 | * @return string Content with shortcode tags removed but inner content preserved |
| 1708 | 1224 | */ |
| 1709 | -/** | |
| 1710 | - * Single-pass HTML entity decode for text entering the knowledge base. | |
| 1711 | - * The corpus should hold what a human reads: a stored `&` consumes | |
| 1712 | - * extra tokens, distorts the vector away from the form a visitor's | |
| 1713 | - * question uses, and can be quoted back verbatim in an answer. | |
| 1714 | - * Deliberately NOT looped to a fixed point — a stored `&amp;` is a | |
| 1715 | - * legitimate literal `&` and must not collapse further (data loss). | |
| 1716 | - * UTF-8 charset keeps multibyte (CJK/RTL) text untouched. Both assembly | |
| 1717 | - * paths call this at their output points so the treatment cannot drift. | |
| 1718 | - * (Plan d2c92e.) | |
| 1719 | - */ | |
| 1720 | -private function mxchat_decode_entities_for_indexing($text) { | |
| 1721 | - return html_entity_decode((string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); | |
| 1722 | -} | |
| 1723 | - | |
| 1724 | -/** | |
| 1725 | - * Price lines for a product's indexed text, pinned to the store's BASE currency. | |
| 1726 | - * | |
| 1727 | - * The four product assembly paths each used to call get_woocommerce_currency_symbol() | |
| 1728 | - * with no argument, which resolves the currency active on the CURRENT request. | |
| 1729 | - * Multi-currency plugins (CURCY, WOOCS, Aelia, WPML Multicurrency) filter that per | |
| 1730 | - * request, so whichever currency the store happened to be serving when an import ran | |
| 1731 | - * was frozen into every product it indexed. The amounts have the mirror problem: the | |
| 1732 | - * woocommerce_product_get_* filters convert prices in the 'view' context but not in | |
| 1733 | - * 'edit', so a converted amount could be paired with an unconverted symbol and produce | |
| 1734 | - * a price that is not merely wrong but incoherent. | |
| 1735 | - * | |
| 1736 | - * Base currency option + 'edit' context makes both halves agree and makes the output | |
| 1737 | - * independent of when the import ran. The currency CODE is emitted alongside the symbol | |
| 1738 | - * so the model cannot read a bare "$" as USD on a store whose plugin swapped symbols. | |
| 1739 | - * (Plan 7403ec.) | |
| 1740 | - */ | |
| 1741 | -private function mxchat_product_price_lines($product) { | |
| 1742 | - if (!is_object($product) || !method_exists($product, 'get_regular_price')) { | |
| 1743 | - return ''; | |
| 1744 | - } | |
| 1745 | - | |
| 1746 | - $currency = get_option('woocommerce_currency'); | |
| 1747 | - $currency = is_string($currency) ? trim($currency) : ''; | |
| 1748 | - $symbol = ($currency !== '') | |
| 1749 | - ? get_woocommerce_currency_symbol($currency) | |
| 1750 | - : get_woocommerce_currency_symbol(); | |
| 1751 | - $symbol = $this->mxchat_decode_entities_for_indexing($symbol); | |
| 1752 | - | |
| 1753 | - $regular_price = $product->get_regular_price('edit'); | |
| 1754 | - $sale_price = $product->get_sale_price('edit'); | |
| 1755 | - $price = $product->get_price('edit'); | |
| 1756 | - | |
| 1757 | - $lines = ''; | |
| 1758 | - | |
| 1759 | - if (!empty($regular_price)) { | |
| 1760 | - $lines .= "Price: " . $this->mxchat_format_indexed_price($regular_price, $currency, $symbol) . "\n"; | |
| 1761 | - } elseif (!empty($price)) { | |
| 1762 | - $lines .= "Price: " . $this->mxchat_format_indexed_price($price, $currency, $symbol) . "\n"; | |
| 1763 | - } | |
| 1764 | - | |
| 1765 | - if (!empty($sale_price) && $sale_price !== $regular_price) { | |
| 1766 | - $lines .= "Sale Price: " . $this->mxchat_format_indexed_price($sale_price, $currency, $symbol) . "\n"; | |
| 1767 | - } | |
| 1768 | - | |
| 1769 | - if ($product->is_type('variable')) { | |
| 1770 | - list($min_price, $max_price) = $this->mxchat_variation_price_range($product); | |
| 1771 | - if ($min_price !== null && $max_price !== null && (float) $min_price !== (float) $max_price) { | |
| 1772 | - $lines .= "Price Range: " . $this->mxchat_format_indexed_price($min_price, $currency, $symbol) | |
| 1773 | - . " - " . $this->mxchat_format_indexed_price($max_price, $currency, $symbol) . "\n"; | |
| 1774 | - } | |
| 1775 | - } | |
| 1776 | - | |
| 1777 | - return $lines; | |
| 1778 | -} | |
| 1779 | - | |
| 1780 | -/** | |
| 1781 | - * One indexed price amount, labelled with its currency code. | |
| 1782 | - * | |
| 1783 | - * "INR 1299.00 (Rs.1299.00)" — the code is what the model should reason from; the symbol | |
| 1784 | - * is kept so a quoted price still reads naturally. Falls back to the old symbol-only | |
| 1785 | - * shape when WooCommerce has no base currency configured, and drops the parenthetical | |
| 1786 | - * when the symbol is absent or IS the code (several currencies have no distinct glyph). | |
| 1787 | - */ | |
| 1788 | -private function mxchat_format_indexed_price($amount, $currency, $symbol) { | |
| 1789 | - $amount = (string) $amount; | |
| 1790 | - | |
| 1791 | - if ($currency === '') { | |
| 1792 | - return $symbol . $amount; | |
| 1793 | - } | |
| 1794 | - | |
| 1795 | - if ($symbol === '' || $symbol === $currency) { | |
| 1796 | - return $currency . ' ' . $amount; | |
| 1797 | - } | |
| 1798 | - | |
| 1799 | - return $currency . ' ' . $amount . ' (' . $symbol . $amount . ')'; | |
| 1800 | -} | |
| 1801 | - | |
| 1802 | -/** | |
| 1803 | - * Min/max variation price read from the variations themselves in 'edit' context. | |
| 1804 | - * | |
| 1805 | - * get_variation_price() reads WooCommerce's display price cache, which multi-currency | |
| 1806 | - * plugins populate with converted values — the same defect the rest of this helper | |
| 1807 | - * exists to remove. Returns raw stored strings (not floats) so the indexed text keeps | |
| 1808 | - * the store's own price formatting, and (null, null) when no variation carries a price. | |
| 1809 | - */ | |
| 1810 | -private function mxchat_variation_price_range($product) { | |
| 1811 | - $min_raw = null; | |
| 1812 | - $max_raw = null; | |
| 1813 | - $min_val = null; | |
| 1814 | - $max_val = null; | |
| 1815 | - | |
| 1816 | - $children = method_exists($product, 'get_children') ? $product->get_children() : array(); | |
| 1817 | - | |
| 1818 | - foreach ($children as $child_id) { | |
| 1819 | - $variation = wc_get_product($child_id); | |
| 1820 | - if (!$variation) { | |
| 1821 | - continue; | |
| 1822 | - } | |
| 1823 | - $raw = $variation->get_price('edit'); | |
| 1824 | - if ($raw === '' || $raw === null) { | |
| 1825 | - continue; | |
| 1826 | - } | |
| 1827 | - $val = (float) $raw; | |
| 1828 | - if ($min_val === null || $val < $min_val) { | |
| 1829 | - $min_val = $val; | |
| 1830 | - $min_raw = $raw; | |
| 1831 | - } | |
| 1832 | - if ($max_val === null || $val > $max_val) { | |
| 1833 | - $max_val = $val; | |
| 1834 | - $max_raw = $raw; | |
| 1835 | - } | |
| 1836 | - } | |
| 1837 | - | |
| 1838 | - return array($min_raw, $max_raw); | |
| 1839 | -} | |
| 1840 | - | |
| 1841 | 1225 | private function strip_shortcode_tags_preserve_content($content) { |
| 1842 | 1226 | // Single-pass regex removes all shortcode brackets: [tag], [tag attr="val"], [/tag], [tag /] |
| 1843 | 1227 | // Content between tags is inherently preserved since only brackets are targeted |
| 1844 | 1228 | $result = preg_replace('/\[\/?\w[\w-]*[^\]]*\]/', '', $content); |
| @@ -1882,50 +1266,24 @@ | ||
| 1882 | 1266 | |
| 1883 | 1267 | // Ensure valid UTF-8 encoding |
| 1884 | 1268 | $content = wp_check_invalid_utf8($content); |
| 1885 | 1269 | |
| 1886 | - // Remove extremely long runs with no whitespace (base64 blobs, minified JS). | |
| 1887 | - // Counts CHARACTERS (/u), and never strips a run containing characters from a | |
| 1888 | - // script written without spaces — Japanese, Chinese, Thai, Khmer, Lao, Myanmar — | |
| 1889 | - // where a normal paragraph is legitimately one unbroken run. | |
| 1890 | - $content = preg_replace_callback('/\S{300,}/u', function ($m) { | |
| 1891 | - return preg_match('/[\p{Han}\p{Hiragana}\p{Katakana}\p{Thai}\p{Khmer}\p{Lao}\p{Myanmar}]/u', $m[0]) ? $m[0] : ' '; | |
| 1892 | - }, $content); | |
| 1893 | - | |
| 1894 | - // Remove emoji/symbol blocks only — not the whole supplementary plane, which | |
| 1895 | - // also holds CJK Extension B ideographs used in real Chinese/Japanese names | |
| 1896 | - $content = preg_replace('/[\x{1F000}-\x{1F0FF}\x{1F300}-\x{1FAFF}]/u', '', $content); | |
| 1270 | + // Remove any extremely long strings without spaces (often garbage) | |
| 1271 | + $content = preg_replace('/\S{300,}/', ' ', $content); | |
| 1897 | 1272 | |
| 1273 | + // Replace problematic characters that often cause database issues | |
| 1274 | + $content = preg_replace('/[\x{10000}-\x{10FFFF}]/u', '', $content); // Remove emoji and other high Unicode characters | |
| 1275 | + | |
| 1898 | 1276 | // Replace any remaining potentially problematic characters with spaces |
| 1899 | 1277 | // BUT preserve newlines by temporarily replacing them |
| 1900 | - // | |
| 1901 | - // \p{Sc} (Symbol, currency) is in the allowlist because every currency sign — | |
| 1902 | - // $ € £ ¥ ₹ — is Sc, not Sm, and without it this pass silently replaced every | |
| 1903 | - // one of them with a space. That hit far more than product prices: any indexed | |
| 1904 | - // page quoting "$4.99" was embedded as " 4.99", leaving the model no way to know | |
| 1905 | - // which currency (or that it was money at all). Found while verifying plan 7403ec. | |
| 1906 | - // | |
| 1907 | - // \p{M} (Mark) is in the allowlist because combining marks are not decoration — | |
| 1908 | - // they are letters' other half. Arabic harakat, Hebrew niqqud, and above all the | |
| 1909 | - // Devanagari vowel signs and virama (Mc/Mn) are mandatory in their scripts. Each | |
| 1910 | - // one used to be replaced by a SPACE, which split one word into several fragments | |
| 1911 | - // and turned Indic text into gibberish. Decomposed (NFD) Latin lost every accent | |
| 1912 | - // the same way. Invisible in English, which is why it went unreported for so long. | |
| 1913 | - // | |
| 1914 | - // \p{So} (Symbol, other) covers ™ © ® ° ✓ — meaning-bearing marks that were also | |
| 1915 | - // becoming spaces ("Brand® name" indexed as "Brand name", "200°C" as "200 C"). | |
| 1916 | - // Emoji are ALSO So: they stay stripped by the emoji-block pass immediately above, | |
| 1917 | - // which runs BEFORE this line. That ordering is load-bearing now — moving this | |
| 1918 | - // line above the emoji strip would let emoji back into the index. Plan a19914. | |
| 1919 | 1278 | $content = str_replace("\n", "NEWLINE_PLACEHOLDER", $content); |
| 1920 | - $content = preg_replace('/[^\p{L}\p{N}\p{P}\p{Z}\p{Sm}\p{Sc}\p{M}\p{So}]/u', ' ', $content); | |
| 1279 | + $content = preg_replace('/[^\p{L}\p{N}\p{P}\p{Z}\p{Sm}]/u', ' ', $content); | |
| 1921 | 1280 | $content = str_replace("NEWLINE_PLACEHOLDER", "\n", $content); |
| 1922 | 1281 | |
| 1923 | - // Limit to reasonable length if needed (byte limit — MySQL TEXT is byte-sized, | |
| 1924 | - // but cut on a character boundary so a multibyte char is never split mid-sequence) | |
| 1282 | + // Limit to reasonable length if needed | |
| 1925 | 1283 | $max_length = 65000; // Just under MySQL TEXT field limit |
| 1926 | 1284 | if (strlen($content) > $max_length) { |
| 1927 | - $content = mb_strcut($content, 0, $max_length, 'UTF-8'); | |
| 1285 | + $content = substr($content, 0, $max_length); | |
| 1928 | 1286 | } |
| 1929 | 1287 | |
| 1930 | 1288 | //error_log('[MXCHAT-SANITIZE] Sanitized content preview: ' . substr($content, 0, 500) . '...'); |
| 1931 | 1289 | return $content; |
| @@ -3483,9 +2841,9 @@ | ||
| 3483 | 2841 | $response = wp_remote_head($url, array( |
| 3484 | 2842 | 'timeout' => 10, |
| 3485 | 2843 | 'sslverify' => false, |
| 3486 | 2844 | 'redirection' => 1, |
| 3487 | - 'user-agent' => mxchat_ingest_user_agent(), | |
| 2845 | + 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', | |
| 3488 | 2846 | )); |
| 3489 | 2847 | |
| 3490 | 2848 | if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) { |
| 3491 | 2849 | // Found a sitemap index - parse it to get sub-sitemaps |
| @@ -3545,11 +2903,12 @@ | ||
| 3545 | 2903 | |
| 3546 | 2904 | $response = wp_remote_get($url, array( |
| 3547 | 2905 | 'timeout' => 30, |
| 3548 | 2906 | 'sslverify' => false, |
| 3549 | - 'user-agent' => mxchat_ingest_user_agent(), | |
| 2907 | + 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', | |
| 3550 | 2908 | 'headers' => array( |
| 3551 | 2909 | 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', |
| 2910 | + 'Accept-Language' => 'en-US,en;q=0.9', | |
| 3552 | 2911 | ), |
| 3553 | 2912 | )); |
| 3554 | 2913 | |
| 3555 | 2914 | if (is_wp_error($response)) { |
| @@ -3603,11 +2962,12 @@ | ||
| 3603 | 2962 | private function get_sitemap_url_count($url) { |
| 3604 | 2963 | $response = wp_remote_get($url, array( |
| 3605 | 2964 | 'timeout' => 30, |
| 3606 | 2965 | 'sslverify' => false, |
| 3607 | - 'user-agent' => mxchat_ingest_user_agent(), | |
| 2966 | + 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', | |
| 3608 | 2967 | 'headers' => array( |
| 3609 | 2968 | 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', |
| 2969 | + 'Accept-Language' => 'en-US,en;q=0.9', | |
| 3610 | 2970 | ), |
| 3611 | 2971 | )); |
| 3612 | 2972 | |
| 3613 | 2973 | if (is_wp_error($response)) { |
| @@ -3633,9 +2993,9 @@ | ||
| 3633 | 2993 | |
| 3634 | 2994 | $response = wp_remote_get($robots_url, array( |
| 3635 | 2995 | 'timeout' => 15, |
| 3636 | 2996 | 'sslverify' => false, |
| 3637 | - 'user-agent' => mxchat_ingest_user_agent(), | |
| 2997 | + 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', | |
| 3638 | 2998 | )); |
| 3639 | 2999 | |
| 3640 | 3000 | if (is_wp_error($response)) { |
| 3641 | 3001 | return $sitemaps; |
| @@ -4125,14 +3485,9 @@ | ||
| 4125 | 3485 | } |
| 4126 | 3486 | |
| 4127 | 3487 | // Get bot_id from request |
| 4128 | 3488 | $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default'; |
| 4129 | - | |
| 4130 | - // ACF→PDF extraction is an install-level setting (Knowledge → ACF Fields, | |
| 4131 | - // plan 11720c). The import modal shows a passive status line pointing | |
| 4132 | - // there; the old per-batch checkbox and its remembered default are gone. | |
| 4133 | - $extract_acf_pdfs = get_option('mxchat_acf_pdf_extraction', '0') === '1'; | |
| 4134 | - | |
| 3489 | + | |
| 4135 | 3490 | // Process only ONE post at a time to avoid request size issues |
| 4136 | 3491 | $post_id = reset($post_ids); |
| 4137 | 3492 | $post = get_post($post_id); |
| 4138 | 3493 | |
| @@ -4140,43 +3495,24 @@ | ||
| 4140 | 3495 | wp_send_json_error('Post not found'); |
| 4141 | 3496 | exit; |
| 4142 | 3497 | } |
| 4143 | 3498 | |
| 4144 | - /** | |
| 4145 | - * Allow developers to modify post data before processing into the knowledge base. | |
| 4146 | - * Applied on BOTH content-preparation paths (this manual bulk import and the | |
| 4147 | - * auto-sync path in mxchat_handle_post_update) with the same signature, so a | |
| 4148 | - * callback registered once covers every indexing route. Purely additive — | |
| 4149 | - * zero behaviour change when unhooked. | |
| 4150 | - * | |
| 4151 | - * @param WP_Post $post The post about to be indexed. | |
| 4152 | - * @param string $bot_id Bot context for this import. | |
| 4153 | - */ | |
| 3499 | + // Allow developers to modify post data before processing into knowledge base | |
| 4154 | 3500 | $post = apply_filters('mxchat_before_process_post', $post, $bot_id); |
| 4155 | - if (!($post instanceof WP_Post)) { | |
| 4156 | - $post = get_post($post_id); // defend against a bad callback return | |
| 4157 | - } | |
| 4158 | 3501 | |
| 4159 | 3502 | // Get content including title, short description (for WooCommerce), and main content |
| 4160 | - // Entity decode at output time (single-pass, shared helper) — a stored | |
| 4161 | - // `&` embeds worse than `&` and gets quoted back to visitors (d2c92e). | |
| 4162 | - $content = $this->mxchat_decode_entities_for_indexing($post->post_title) . "\n\n"; | |
| 3503 | + $content = $post->post_title . "\n\n"; | |
| 4163 | 3504 | |
| 4164 | 3505 | // Add short description if it exists (WooCommerce products use post_excerpt for short description) |
| 4165 | - // Strip FIRST, then test: an excerpt that is nothing but shortcodes strips to | |
| 4166 | - // empty, and testing the raw value emitted a bare "Short Description: " label | |
| 4167 | - // with no value after it. Matches mxchat_index_published_post. | |
| 4168 | - // trim() only in the TEST — the emitted value is untouched, so a populated | |
| 4169 | - // excerpt is byte-identical to before. A whitespace-only excerpt is an empty | |
| 4170 | - // excerpt and must not produce a labelled line with nothing after it. | |
| 4171 | - $clean_excerpt = $this->strip_shortcode_tags_preserve_content($post->post_excerpt); | |
| 4172 | - if (trim($clean_excerpt) !== '') { | |
| 4173 | - $content .= "Short Description: " . $this->mxchat_decode_entities_for_indexing(wp_strip_all_tags($clean_excerpt)) . "\n\n"; | |
| 3506 | + if (!empty($post->post_excerpt)) { | |
| 3507 | + // Remove shortcode tags but preserve content inside them | |
| 3508 | + $clean_excerpt = $this->strip_shortcode_tags_preserve_content($post->post_excerpt); | |
| 3509 | + $content .= "Short Description: " . wp_strip_all_tags($clean_excerpt) . "\n\n"; | |
| 4174 | 3510 | } |
| 4175 | 3511 | |
| 4176 | 3512 | // Add main content - remove shortcode tags but preserve content inside them |
| 4177 | 3513 | $clean_content = $this->strip_shortcode_tags_preserve_content($post->post_content); |
| 4178 | - $content .= $this->mxchat_decode_entities_for_indexing(wp_strip_all_tags($clean_content)); | |
| 3514 | + $content .= wp_strip_all_tags($clean_content); | |
| 4179 | 3515 | |
| 4180 | 3516 | // ADD WOOCOMMERCE PRODUCT DATA (pricing, stock, categories, custom tabs) |
| 4181 | 3517 | if (get_post_type($post_id) === 'product' && class_exists('WooCommerce')) { |
| 4182 | 3518 | $product = wc_get_product($post_id); |
| @@ -4181,14 +3517,38 @@ | ||
| 4181 | 3517 | if (get_post_type($post_id) === 'product' && class_exists('WooCommerce')) { |
| 4182 | 3518 | $product = wc_get_product($post_id); |
| 4183 | 3519 | |
| 4184 | 3520 | if ($product) { |
| 3521 | + // Get pricing information | |
| 3522 | + $regular_price = $product->get_regular_price(); | |
| 3523 | + $sale_price = $product->get_sale_price(); | |
| 3524 | + $price = $product->get_price(); | |
| 4185 | 3525 | $sku = $product->get_sku(); |
| 4186 | 3526 | |
| 4187 | - // Add pricing information (base-currency pinned — see mxchat_product_price_lines) | |
| 3527 | + // Get currency symbol | |
| 3528 | + $currency_symbol = get_woocommerce_currency_symbol(); | |
| 3529 | + | |
| 3530 | + // Add pricing information | |
| 4188 | 3531 | $content .= "\n"; |
| 4189 | - $content .= $this->mxchat_product_price_lines($product); | |
| 3532 | + if (!empty($regular_price)) { | |
| 3533 | + $content .= "Price: " . $currency_symbol . $regular_price . "\n"; | |
| 3534 | + } elseif (!empty($price)) { | |
| 3535 | + $content .= "Price: " . $currency_symbol . $price . "\n"; | |
| 3536 | + } | |
| 4190 | 3537 | |
| 3538 | + if (!empty($sale_price) && $sale_price !== $regular_price) { | |
| 3539 | + $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n"; | |
| 3540 | + } | |
| 3541 | + | |
| 3542 | + // Handle variable products - show price range | |
| 3543 | + if ($product->is_type('variable')) { | |
| 3544 | + $min_price = $product->get_variation_price('min'); | |
| 3545 | + $max_price = $product->get_variation_price('max'); | |
| 3546 | + if ($min_price !== $max_price) { | |
| 3547 | + $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n"; | |
| 3548 | + } | |
| 3549 | + } | |
| 3550 | + | |
| 4191 | 3551 | if (!empty($sku)) { |
| 4192 | 3552 | $content .= "SKU: " . $sku . "\n"; |
| 4193 | 3553 | } |
| 4194 | 3554 | |
| @@ -4231,88 +3591,25 @@ | ||
| 4231 | 3591 | } |
| 4232 | 3592 | } |
| 4233 | 3593 | } |
| 4234 | 3594 | |
| 4235 | - // For custom post types like job_listing, include additional fields | |
| 4236 | - // (verbatim parity with mxchat_index_published_post — a bulk import used to | |
| 4237 | - // index the body alone, losing location/type/company that auto-sync captured) | |
| 4238 | - if (get_post_type($post_id) === 'job_listing') { | |
| 4239 | - // Add job-specific meta if available | |
| 4240 | - $job_location = get_post_meta($post_id, '_job_location', true); | |
| 4241 | - if (!empty($job_location)) { | |
| 4242 | - $content .= "\n\nLocation: " . $job_location; | |
| 4243 | - } | |
| 4244 | - | |
| 4245 | - // Get job type terms | |
| 4246 | - $job_types = get_the_terms($post_id, 'job_listing_type'); | |
| 4247 | - if (!empty($job_types) && !is_wp_error($job_types)) { | |
| 4248 | - $types = array(); | |
| 4249 | - foreach ($job_types as $type) { | |
| 4250 | - $types[] = $type->name; | |
| 4251 | - } | |
| 4252 | - $content .= "\n\nJob Type: " . implode(', ', $types); | |
| 4253 | - } | |
| 4254 | - | |
| 4255 | - // Get company name if available | |
| 4256 | - $company_name = get_post_meta($post_id, '_company_name', true); | |
| 4257 | - if (!empty($company_name)) { | |
| 4258 | - $content .= "\n\nCompany: " . $company_name; | |
| 4259 | - } | |
| 4260 | - } | |
| 4261 | - | |
| 4262 | 3595 | // ADD ACF FIELDS SUPPORT |
| 4263 | 3596 | $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id); |
| 4264 | - $pdf_extracted_count = 0; | |
| 4265 | 3597 | if (!empty($acf_fields)) { |
| 4266 | 3598 | $acf_content_parts = array(); |
| 4267 | - $pdf_attachment_ids = array(); | |
| 4268 | - | |
| 3599 | + | |
| 4269 | 3600 | foreach ($acf_fields as $field_name => $field_value) { |
| 4270 | 3601 | $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id); |
| 4271 | - | |
| 3602 | + | |
| 4272 | 3603 | if (!empty($formatted_value)) { |
| 4273 | - // Both separators: a hyphenated ACF name should read as words, and | |
| 4274 | - // this is what mxchat_index_published_post already does. | |
| 4275 | - $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name)); | |
| 3604 | + $field_label = ucwords(str_replace('_', ' ', $field_name)); | |
| 4276 | 3605 | $acf_content_parts[] = $field_label . ": " . $formatted_value; |
| 4277 | 3606 | } |
| 4278 | - | |
| 4279 | - // Walk this field's value tree for any PDF attachment references and queue them for extraction. | |
| 4280 | - // Only when the user opted into ACF→PDF extraction for this batch; otherwise the ACF text | |
| 4281 | - // still lands in the KB but the heavier PDF parsing is skipped. | |
| 4282 | - if ($extract_acf_pdfs) { | |
| 4283 | - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids); | |
| 4284 | - } | |
| 4285 | 3607 | } |
| 4286 | - | |
| 3608 | + | |
| 4287 | 3609 | if (!empty($acf_content_parts)) { |
| 4288 | 3610 | $content .= "\n\n" . implode("\n", $acf_content_parts); |
| 4289 | 3611 | } |
| 4290 | - | |
| 4291 | - // Extract text from each unique PDF found in ACF fields and append as a labeled section | |
| 4292 | - if ($extract_acf_pdfs && !empty($pdf_attachment_ids)) { | |
| 4293 | - $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids))); | |
| 4294 | - $pdf_sections = array(); | |
| 4295 | - foreach ($pdf_attachment_ids as $att_id) { | |
| 4296 | - $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id); | |
| 4297 | - if (!empty($pdf_text)) { | |
| 4298 | - $pdf_title = get_the_title($att_id); | |
| 4299 | - $pdf_url = wp_get_attachment_url($att_id); | |
| 4300 | - $header = 'PDF Attachment'; | |
| 4301 | - if (!empty($pdf_title)) { | |
| 4302 | - $header .= ': ' . $pdf_title; | |
| 4303 | - } | |
| 4304 | - if (!empty($pdf_url)) { | |
| 4305 | - $header .= ' (' . $pdf_url . ')'; | |
| 4306 | - } | |
| 4307 | - $pdf_sections[] = $header . "\n" . $pdf_text; | |
| 4308 | - $pdf_extracted_count++; | |
| 4309 | - } | |
| 4310 | - } | |
| 4311 | - if (!empty($pdf_sections)) { | |
| 4312 | - $content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections); | |
| 4313 | - } | |
| 4314 | - } | |
| 4315 | 3612 | } |
| 4316 | 3613 | |
| 4317 | 3614 | // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields) |
| 4318 | 3615 | $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id); |
| @@ -4337,19 +3634,29 @@ | ||
| 4337 | 3634 | //error_log('[MXCHAT-WP-IMPORT-DEBUG] Content preview: ' . substr($content, 0, 300)); |
| 4338 | 3635 | |
| 4339 | 3636 | // Note: Removed 10,000 char limit - chunking now handles large content properly |
| 4340 | 3637 | |
| 4341 | - // Get bot-specific embedding decision — custom-provider-aware (plan cbd5fd) | |
| 3638 | + // Get bot-specific API key | |
| 4342 | 3639 | $bot_options = $this->get_bot_options($bot_id); |
| 4343 | 3640 | $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options'); |
| 4344 | - | |
| 4345 | - $preflight = MxChat_Utils::embedding_preflight($options); | |
| 4346 | - if (!$preflight['ok']) { | |
| 4347 | - MxChat_Admin::mxchat_log_debug('api_error', $preflight['reason'] . ' (knowledge processing)'); | |
| 4348 | - wp_send_json_error($preflight['reason']); | |
| 3641 | + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002'; | |
| 3642 | + | |
| 3643 | + if (strpos($selected_model, 'voyage') === 0) { | |
| 3644 | + $api_key = $options['voyage_api_key'] ?? ''; | |
| 3645 | + $provider_name = 'Voyage AI'; | |
| 3646 | + } elseif (strpos($selected_model, 'gemini-embedding') === 0) { | |
| 3647 | + $api_key = $options['gemini_api_key'] ?? ''; | |
| 3648 | + $provider_name = 'Google Gemini'; | |
| 3649 | + } else { | |
| 3650 | + $api_key = $options['api_key'] ?? ''; | |
| 3651 | + $provider_name = 'OpenAI'; | |
| 3652 | + } | |
| 3653 | + | |
| 3654 | + if (empty($api_key)) { | |
| 3655 | + MxChat_Admin::mxchat_log_debug('api_error', $provider_name . ' API key not configured for knowledge processing'); | |
| 3656 | + wp_send_json_error($provider_name . ' API key not configured'); | |
| 4349 | 3657 | exit; |
| 4350 | 3658 | } |
| 4351 | - $api_key = $preflight['api_key']; | |
| 4352 | 3659 | |
| 4353 | 3660 | $source_url = get_permalink($post_id); |
| 4354 | 3661 | $vector_id = md5($source_url); // Vector ID for Pinecone |
| 4355 | 3662 | |
| @@ -4432,9 +3739,8 @@ | ||
| 4432 | 3739 | 'title' => $post->post_title, |
| 4433 | 3740 | 'operation_type' => $operation_type, |
| 4434 | 3741 | 'vector_id' => $vector_id, |
| 4435 | 3742 | 'acf_fields_found' => $acf_field_count, |
| 4436 | - 'pdf_extracted_count' => (int) $pdf_extracted_count, | |
| 4437 | 3743 | 'content_preview' => substr($content, 0, 100) . '...', |
| 4438 | 3744 | 'bot_id' => $bot_id |
| 4439 | 3745 | )); |
| 4440 | 3746 | exit; |
| @@ -4849,20 +4155,9 @@ | ||
| 4849 | 4155 | |
| 4850 | 4156 | // Get bot-specific options |
| 4851 | 4157 | $bot_options = $this->get_bot_options($bot_id); |
| 4852 | 4158 | $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options'); |
| 4853 | - | |
| 4854 | - // Opt-in: when the custom provider is selected for embeddings, index through | |
| 4855 | - // the same custom endpoint the query path uses so stored vectors and query | |
| 4856 | - // vectors share a model. Returns the vector array on success, or an error | |
| 4857 | - // string on failure (this function's existing failure contract). | |
| 4858 | - if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') { | |
| 4859 | - if (!class_exists('MxChat_Utils')) { | |
| 4860 | - require_once dirname(__FILE__) . '/../includes/class-mxchat-utils.php'; | |
| 4861 | - } | |
| 4862 | - return MxChat_Utils::generate_embedding_custom($text, $options); | |
| 4863 | - } | |
| 4864 | - | |
| 4159 | + | |
| 4865 | 4160 | $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002'; |
| 4866 | 4161 | //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model); |
| 4867 | 4162 | |
| 4868 | 4163 | // Determine provider and endpoint |
| @@ -4974,23 +4269,13 @@ | ||
| 4974 | 4269 | $error_message = $error_json['error']['message'] ?? 'No message'; |
| 4975 | 4270 | //error_log('[MXCHAT-EMBED] API Error Type: ' . $error_type); |
| 4976 | 4271 | //error_log('[MXCHAT-EMBED] API Error Message: ' . $error_message); |
| 4977 | 4272 | |
| 4978 | - // Keep the provider's own diagnostic — a restricted-key 401 names the | |
| 4979 | - // exact missing scope, and replacing it with "check your API key" sent | |
| 4980 | - // a customer to regenerate two keys (plan 46b596). Same shape as | |
| 4981 | - // MxChat_Utils::embedding_failure_error() so both ingestion paths read | |
| 4982 | - // identically. Key never appears in provider messages, but scrub anyway. | |
| 4983 | - if ($error_type === 'invalid_request_error' || $error_type === 'authentication_error') { | |
| 4984 | - if (is_string($api_key) && $api_key !== '') { | |
| 4985 | - $error_message = str_replace($api_key, '[redacted]', $error_message); | |
| 4986 | - } | |
| 4987 | - $error_message = sprintf( | |
| 4988 | - 'Embedding failed (%s, HTTP %d): %s', | |
| 4989 | - $selected_model, | |
| 4990 | - $http_code, | |
| 4991 | - substr($error_message, 0, 300) | |
| 4992 | - ); | |
| 4273 | + // Customize error message for common API errors | |
| 4274 | + if ($error_type === 'invalid_request_error' && strpos($error_message, 'API key') !== false) { | |
| 4275 | + $error_message = sprintf('Invalid %s API key for bot %s. Please check your API key in the bot settings.', $provider_name, $bot_id); | |
| 4276 | + } elseif ($error_type === 'authentication_error') { | |
| 4277 | + $error_message = sprintf('%s authentication failed for bot %s. Please verify your API key in the bot settings.', $provider_name, $bot_id); | |
| 4993 | 4278 | } |
| 4994 | 4279 | |
| 4995 | 4280 | //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message); |
| 4996 | 4281 | return $error_message; |
| @@ -5790,200 +5075,8 @@ | ||
| 5790 | 5075 | return implode(', ', array_filter($text_parts)); |
| 5791 | 5076 | } |
| 5792 | 5077 | |
| 5793 | 5078 | /** |
| 5794 | - * Walk an ACF field value tree and collect attachment IDs for any value that | |
| 5795 | - * resolves to a PDF in the WordPress media library. Handles the three shapes | |
| 5796 | - * ACF returns for File/Image/URL fields (array with ID+url, integer attachment ID, | |
| 5797 | - * plain URL string), and recurses through repeater/group/flexible content. | |
| 5798 | - * | |
| 5799 | - * @param mixed $value The ACF field value (any depth) | |
| 5800 | - * @param array $out Accumulator (passed by reference) for attachment IDs | |
| 5801 | - * @param int $depth Recursion guard | |
| 5802 | - */ | |
| 5803 | -private function mxchat_collect_pdf_attachment_ids_from_acf_value($value, &$out, $depth = 0) { | |
| 5804 | - if ($depth > 6) { | |
| 5805 | - return; // prevent runaway recursion on circular/very-deep structures | |
| 5806 | - } | |
| 5807 | - | |
| 5808 | - if (empty($value)) { | |
| 5809 | - return; | |
| 5810 | - } | |
| 5811 | - | |
| 5812 | - // Array shapes: ACF File/Image return value=array; repeaters/groups are arrays of arrays | |
| 5813 | - if (is_array($value)) { | |
| 5814 | - // Direct File/Image-style array (has 'url' and usually 'ID' + 'mime_type') | |
| 5815 | - $looks_like_attachment = isset($value['url']) || isset($value['ID']) || isset($value['id']); | |
| 5816 | - if ($looks_like_attachment) { | |
| 5817 | - $att_id = 0; | |
| 5818 | - if (!empty($value['ID']) && is_numeric($value['ID'])) { | |
| 5819 | - $att_id = (int) $value['ID']; | |
| 5820 | - } elseif (!empty($value['id']) && is_numeric($value['id'])) { | |
| 5821 | - $att_id = (int) $value['id']; | |
| 5822 | - } elseif (!empty($value['url']) && is_string($value['url'])) { | |
| 5823 | - $att_id = (int) attachment_url_to_postid($value['url']); | |
| 5824 | - } | |
| 5825 | - | |
| 5826 | - $is_pdf = false; | |
| 5827 | - if (!empty($value['mime_type']) && $value['mime_type'] === 'application/pdf') { | |
| 5828 | - $is_pdf = true; | |
| 5829 | - } elseif (!empty($value['subtype']) && strtolower((string) $value['subtype']) === 'pdf') { | |
| 5830 | - $is_pdf = true; | |
| 5831 | - } elseif (!empty($value['url']) && is_string($value['url']) && $this->mxchat_url_looks_like_pdf($value['url'])) { | |
| 5832 | - $is_pdf = true; | |
| 5833 | - } elseif ($att_id && get_post_mime_type($att_id) === 'application/pdf') { | |
| 5834 | - $is_pdf = true; | |
| 5835 | - } | |
| 5836 | - | |
| 5837 | - if ($is_pdf && $att_id && get_post_mime_type($att_id) === 'application/pdf') { | |
| 5838 | - $out[] = $att_id; | |
| 5839 | - } | |
| 5840 | - // An array node that represents one attachment doesn't contain other | |
| 5841 | - // attachments inside it — done with this branch. | |
| 5842 | - return; | |
| 5843 | - } | |
| 5844 | - | |
| 5845 | - // Recurse: repeater rows, flexible-content layouts, groups, etc. | |
| 5846 | - foreach ($value as $sub) { | |
| 5847 | - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($sub, $out, $depth + 1); | |
| 5848 | - } | |
| 5849 | - return; | |
| 5850 | - } | |
| 5851 | - | |
| 5852 | - // Plain numeric attachment ID (ACF File field set to "Return: ID") | |
| 5853 | - if (is_numeric($value)) { | |
| 5854 | - $att_id = (int) $value; | |
| 5855 | - if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') { | |
| 5856 | - $out[] = $att_id; | |
| 5857 | - } | |
| 5858 | - return; | |
| 5859 | - } | |
| 5860 | - | |
| 5861 | - // Plain string — URL pointing at a PDF (ACF File field set to "Return: URL", or a custom URL/text field) | |
| 5862 | - if (is_string($value)) { | |
| 5863 | - $trimmed = trim($value); | |
| 5864 | - if ($trimmed !== '' && $this->mxchat_url_looks_like_pdf($trimmed)) { | |
| 5865 | - $att_id = (int) attachment_url_to_postid($trimmed); | |
| 5866 | - if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') { | |
| 5867 | - $out[] = $att_id; | |
| 5868 | - } | |
| 5869 | - } | |
| 5870 | - return; | |
| 5871 | - } | |
| 5872 | -} | |
| 5873 | - | |
| 5874 | -/** | |
| 5875 | - * Heuristic: does this URL/string look like a PDF reference? | |
| 5876 | - * Tolerates query strings and fragments (#page=2). | |
| 5877 | - */ | |
| 5878 | -private function mxchat_url_looks_like_pdf($url) { | |
| 5879 | - if (!is_string($url) || $url === '') { | |
| 5880 | - return false; | |
| 5881 | - } | |
| 5882 | - // Strip query + fragment before checking extension | |
| 5883 | - $path = preg_replace('/[?#].*$/', '', $url); | |
| 5884 | - return (bool) preg_match('/\.pdf$/i', $path); | |
| 5885 | -} | |
| 5886 | - | |
| 5887 | -/** | |
| 5888 | - * Extract text from a PDF attachment by ID using the bundled Smalot parser. | |
| 5889 | - * Reads the file directly from disk via get_attached_file (no HTTP fetch). | |
| 5890 | - * Result is cached on the attachment as post_meta keyed by file mtime so we | |
| 5891 | - * only parse the same PDF once unless the file changes on disk. | |
| 5892 | - * | |
| 5893 | - * @param int $attachment_id | |
| 5894 | - * @return string Extracted plain text, or '' on failure. | |
| 5895 | - */ | |
| 5896 | -private function mxchat_extract_pdf_text_by_attachment_id($attachment_id) { | |
| 5897 | - $attachment_id = (int) $attachment_id; | |
| 5898 | - if ($attachment_id <= 0) { | |
| 5899 | - return ''; | |
| 5900 | - } | |
| 5901 | - if (get_post_mime_type($attachment_id) !== 'application/pdf') { | |
| 5902 | - return ''; | |
| 5903 | - } | |
| 5904 | - | |
| 5905 | - $pdf_path = get_attached_file($attachment_id); | |
| 5906 | - if (empty($pdf_path) || !file_exists($pdf_path) || !is_readable($pdf_path)) { | |
| 5907 | - return ''; | |
| 5908 | - } | |
| 5909 | - | |
| 5910 | - // Raw-file size cap. Parsing very large PDFs can OOM the request; skip with a log entry | |
| 5911 | - // and let the rest of the ACF content land in the KB. Filterable for users who need it bigger. | |
| 5912 | - $default_max_bytes = 25 * 1024 * 1024; | |
| 5913 | - $max_bytes = (int) apply_filters('mxchat_acf_pdf_max_bytes', $default_max_bytes, $attachment_id, $pdf_path); | |
| 5914 | - if ($max_bytes > 0) { | |
| 5915 | - $file_size = @filesize($pdf_path); | |
| 5916 | - if ($file_size !== false && $file_size > $max_bytes) { | |
| 5917 | - error_log(sprintf( | |
| 5918 | - '[mxchat] ACF PDF skipped (over size cap): attachment %d "%s" %d bytes > cap %d', | |
| 5919 | - $attachment_id, | |
| 5920 | - basename($pdf_path), | |
| 5921 | - $file_size, | |
| 5922 | - $max_bytes | |
| 5923 | - )); | |
| 5924 | - return ''; | |
| 5925 | - } | |
| 5926 | - } | |
| 5927 | - | |
| 5928 | - $mtime = @filemtime($pdf_path); | |
| 5929 | - $cache_meta_key = '_mxchat_acf_pdf_text_v1'; | |
| 5930 | - $cached = get_post_meta($attachment_id, $cache_meta_key, true); | |
| 5931 | - if (is_array($cached) && isset($cached['mtime'], $cached['text']) && (int) $cached['mtime'] === (int) $mtime) { | |
| 5932 | - return (string) $cached['text']; | |
| 5933 | - } | |
| 5934 | - | |
| 5935 | - $text = ''; | |
| 5936 | - try { | |
| 5937 | - if (function_exists('mxchat_load_pdf_parser')) { | |
| 5938 | - mxchat_load_pdf_parser(); | |
| 5939 | - } | |
| 5940 | - if (!class_exists('\\Smalot\\PdfParser\\Parser')) { | |
| 5941 | - return ''; | |
| 5942 | - } | |
| 5943 | - $parser = new \Smalot\PdfParser\Parser(); | |
| 5944 | - $pdf = $parser->parseFile($pdf_path); | |
| 5945 | - $pages = $pdf->getPages(); | |
| 5946 | - $page_texts = array(); | |
| 5947 | - $acf_page_num = 0; | |
| 5948 | - foreach ($pages as $page) { | |
| 5949 | - $acf_page_num++; | |
| 5950 | - $page_text = ''; | |
| 5951 | - try { | |
| 5952 | - $page_text = $page->getText(); | |
| 5953 | - } catch (\Exception $e) { | |
| 5954 | - $page_text = ''; | |
| 5955 | - } | |
| 5956 | - if (!empty($page_text)) { | |
| 5957 | - $page_text = MxChat_Utils::normalize_pdf_rtl($page_text, 'acf_pdf attachment ' . $attachment_id . ' page ' . $acf_page_num); | |
| 5958 | - $page_texts[] = $page_text; | |
| 5959 | - } | |
| 5960 | - } | |
| 5961 | - $text = trim(implode("\n\n", $page_texts)); | |
| 5962 | - } catch (\Exception $e) { | |
| 5963 | - error_log('[mxchat] ACF PDF extraction failed for attachment ' . $attachment_id . ': ' . $e->getMessage()); | |
| 5964 | - return ''; | |
| 5965 | - } catch (\Throwable $e) { | |
| 5966 | - error_log('[mxchat] ACF PDF extraction error for attachment ' . $attachment_id . ': ' . $e->getMessage()); | |
| 5967 | - return ''; | |
| 5968 | - } | |
| 5969 | - | |
| 5970 | - // Cap per-PDF text to avoid blowing up the embedding payload on enormous PDFs. | |
| 5971 | - // The chunker downstream will still split this into multiple vectors. | |
| 5972 | - $max_len = (int) apply_filters('mxchat_acf_pdf_text_max_length', 50000); | |
| 5973 | - if ($max_len > 0 && strlen($text) > $max_len) { | |
| 5974 | - $text = substr($text, 0, $max_len); | |
| 5975 | - } | |
| 5976 | - | |
| 5977 | - update_post_meta($attachment_id, $cache_meta_key, array( | |
| 5978 | - 'mtime' => (int) $mtime, | |
| 5979 | - 'text' => $text, | |
| 5980 | - )); | |
| 5981 | - | |
| 5982 | - return $text; | |
| 5983 | -} | |
| 5984 | - | |
| 5985 | -/** | |
| 5986 | 5079 | * Handle ACF save - fires after ACF fields are saved |
| 5987 | 5080 | * This ensures ACF field data is available when syncing to knowledge base |
| 5988 | 5081 | */ |
| 5989 | 5082 | public function mxchat_handle_acf_save($post_id) { |
| @@ -6053,13 +5146,8 @@ | ||
| 6053 | 5146 | $this->mxchat_handle_post_update($post_id, $post, true); |
| 6054 | 5147 | } |
| 6055 | 5148 | |
| 6056 | 5149 | public function mxchat_handle_post_update($post_id, $post, $update) { |
| 6057 | - // The in-flight-update marker has done its job the moment post_updated runs; drop it | |
| 6058 | - // before any early return so it can never outlive its own save (a failed $wpdb->update | |
| 6059 | - // inside wp_insert_post returns after pre_post_update but before the transition). | |
| 6060 | - unset($this->pending_post_update[$post_id]); | |
| 6061 | - | |
| 6062 | 5150 | // Basic validation checks |
| 6063 | 5151 | if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) { |
| 6064 | 5152 | return; |
| 6065 | 5153 | } |
| @@ -6096,35 +5184,40 @@ | ||
| 6096 | 5184 | // If the post was previously published but is now not published, remove from knowledge base |
| 6097 | 5185 | if ($previous_status === 'publish' && $post->post_status !== 'publish') { |
| 6098 | 5186 | // Use the stored URL from when it was published, or fall back to current permalink |
| 6099 | 5187 | $source_url = $previous_url ?: get_permalink($post_id); |
| 5188 | + | |
| 5189 | + if ($source_url) { | |
| 5190 | + // Check if Pinecone is enabled | |
| 5191 | + $pinecone_options = get_option('mxchat_pinecone_addon_options', array()); | |
| 5192 | + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1'; | |
| 6100 | 5193 | |
| 6101 | - // mxchat_handle_status_transition already deleted for this post earlier in this | |
| 6102 | - // request (it fires first inside wp_insert_post); skip the redundant round-trip. | |
| 6103 | - if ($source_url && empty($this->transition_deleted_posts[$post_id])) { | |
| 6104 | - // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks) | |
| 6105 | - MxChat_Utils::delete_chunks_for_url($source_url, 'default'); | |
| 5194 | + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) { | |
| 5195 | + // Delete from Pinecone | |
| 5196 | + $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options); | |
| 5197 | + } else { | |
| 5198 | + // Delete from WordPress DB | |
| 5199 | + global $wpdb; | |
| 5200 | + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content'; | |
| 5201 | + | |
| 5202 | + $result = $wpdb->delete( | |
| 5203 | + $table_name, | |
| 5204 | + array('source_url' => $source_url), | |
| 5205 | + array('%s') | |
| 5206 | + ); | |
| 5207 | + } | |
| 6106 | 5208 | } |
| 6107 | - | |
| 5209 | + | |
| 6108 | 5210 | // Clean up the transients and exit early |
| 6109 | 5211 | delete_transient($previous_status_key); |
| 6110 | 5212 | delete_transient($previous_url_key); |
| 6111 | 5213 | return; |
| 6112 | 5214 | } |
| 6113 | - | |
| 6114 | - // Slug/permalink rename while still published: delete the old vectors before upserting new ones. | |
| 6115 | - // Without this, md5(old_url) vectors (base + chunks) would be orphaned under the stale URL. | |
| 6116 | - if ($post->post_status === 'publish' && !empty($previous_url)) { | |
| 6117 | - $current_url = get_permalink($post_id); | |
| 6118 | - if ($current_url && $current_url !== $previous_url) { | |
| 6119 | - MxChat_Utils::delete_chunks_for_url($previous_url, 'default'); | |
| 6120 | - } | |
| 6121 | - } | |
| 6122 | - | |
| 5215 | + | |
| 6123 | 5216 | // Store the current status for next time (if this is an update) |
| 6124 | 5217 | if ($update) { |
| 6125 | 5218 | set_transient($previous_status_key, $post->post_status, DAY_IN_SECONDS); |
| 6126 | - | |
| 5219 | + | |
| 6127 | 5220 | // If the post is currently published, also store its URL |
| 6128 | 5221 | if ($post->post_status === 'publish') { |
| 6129 | 5222 | $current_url = get_permalink($post_id); |
| 6130 | 5223 | set_transient($previous_url_key, $current_url, DAY_IN_SECONDS); |
| @@ -6130,104 +5223,31 @@ | ||
| 6130 | 5223 | set_transient($previous_url_key, $current_url, DAY_IN_SECONDS); |
| 6131 | 5224 | } |
| 6132 | 5225 | } |
| 6133 | 5226 | |
| 6134 | - // Only process currently published content for adding/updating. | |
| 6135 | - // transition_indexed_posts: mxchat_handle_status_transition's arrival edge may have | |
| 6136 | - // already indexed this post earlier in this request (editor publishes fire | |
| 6137 | - // transition_post_status first, then post_updated) — skip the duplicate embed. | |
| 6138 | - // Consume-once: the flag is cleared when honoured, so a LATER save of the same | |
| 6139 | - // post in one long-running process (WP-CLI scripts, importers) re-indexes normally. | |
| 5227 | + // Only process currently published content for adding/updating | |
| 6140 | 5228 | if ($post->post_status === 'publish') { |
| 6141 | - if (!empty($this->transition_indexed_posts[$post_id])) { | |
| 6142 | - unset($this->transition_indexed_posts[$post_id]); | |
| 6143 | - } else { | |
| 6144 | - $this->mxchat_index_published_post($post_id, $post); | |
| 6145 | - } | |
| 6146 | - } | |
| 6147 | - | |
| 6148 | - // Clean up the stored previous status if not used above | |
| 6149 | - if ($previous_status !== 'publish' || $post->post_status === 'publish') { | |
| 6150 | - delete_transient($previous_status_key); | |
| 6151 | - delete_transient($previous_url_key); | |
| 6152 | - } | |
| 6153 | -} | |
| 6154 | - | |
| 6155 | -/** | |
| 6156 | - * Index a published post into the knowledge base: preprocessing filter, content | |
| 6157 | - * assembly (title/excerpt/body), WooCommerce product enrichment, job_listing meta, | |
| 6158 | - * ACF fields (+ optional PDF extraction), whitelisted custom meta, embedding and | |
| 6159 | - * upsert, then tag-based role restriction. | |
| 6160 | - * | |
| 6161 | - * Shared by the post_updated auto-sync path (mxchat_handle_post_update) and the | |
| 6162 | - * transition_post_status arrival edge (mxchat_handle_status_transition), so | |
| 6163 | - * scheduled publishes (wp_publish_post) and direct status=publish inserts index | |
| 6164 | - * identically to editor saves (plan 3055e1). Pure extraction of the former | |
| 6165 | - * publish branch — body indentation retained to keep the diff reviewable. | |
| 6166 | - */ | |
| 6167 | -private function mxchat_index_published_post($post_id, $post) { | |
| 6168 | - $post_type = $post->post_type; | |
| 6169 | - | |
| 6170 | 5229 | // Get the source URL |
| 6171 | 5230 | $source_url = get_permalink($post_id); |
| 5231 | + | |
| 5232 | + // Get content with proper formatting (matching ajax_mxchat_process_selected_content) | |
| 5233 | + $title = get_the_title($post_id); | |
| 5234 | + $content = get_post_field('post_content', $post_id); | |
| 5235 | + $excerpt = get_post_field('post_excerpt', $post_id); | |
| 6172 | 5236 | |
| 6173 | - // A draft published programmatically (wp_publish_post) can reach this | |
| 6174 | - // point with an EMPTY post_name — wp_insert_post skips slug generation | |
| 6175 | - // for draft/pending — and get_permalink() then resolves to the bare | |
| 6176 | - // site root. A knowledge row keyed to the homepage cites the wrong URL | |
| 6177 | - // and answers homepage questions with this post's body, so refuse to | |
| 6178 | - // write it; the post indexes correctly on its next save, once the slug | |
| 6179 | - // exists. The empty-post_name test is what keeps a legitimate static | |
| 6180 | - // front page (which has a slug but a root permalink) indexable. | |
| 6181 | - // (Plan d138c4.) | |
| 6182 | - if ('' === $post->post_name | |
| 6183 | - && untrailingslashit($source_url) === untrailingslashit(home_url())) { | |
| 6184 | - return; | |
| 6185 | - } | |
| 6186 | - | |
| 6187 | - /** | |
| 6188 | - * Allow developers to modify post data before processing into the knowledge base. | |
| 6189 | - * Same filter and signature as the manual bulk-import path | |
| 6190 | - * (ajax_mxchat_process_selected_content), so a callback registered once covers | |
| 6191 | - * every indexing route. Purely additive — zero behaviour change when unhooked. | |
| 6192 | - * Auto-sync runs under the 'default' bot context, matching the rest of this | |
| 6193 | - * function. | |
| 6194 | - * | |
| 6195 | - * @param WP_Post $post The post about to be indexed. | |
| 6196 | - * @param string $bot_id Bot context ('default' on auto-sync). | |
| 6197 | - */ | |
| 6198 | - $post = apply_filters('mxchat_before_process_post', $post, 'default'); | |
| 6199 | - if (!($post instanceof WP_Post)) { | |
| 6200 | - $post = get_post($post_id); // defend against a bad callback return | |
| 6201 | - } | |
| 6202 | - | |
| 6203 | - // Get content with proper formatting (matching ajax_mxchat_process_selected_content), | |
| 6204 | - // reading from the FILTERED post object — not re-fetched by ID, which would discard it | |
| 6205 | - // Raw post_title, NOT get_the_title(): the_title applies wptexturize + | |
| 6206 | - // convert_chars (curly quotes and em-dashes become HTML entities in the | |
| 6207 | - // embedded string) and prepends the "Protected:" / "Private:" display | |
| 6208 | - // chrome. The knowledge base stores facts, not display strings — and the | |
| 6209 | - // bulk-import path has always read the raw title, so this is also what | |
| 6210 | - // makes the two paths agree. | |
| 6211 | - $title = $this->mxchat_decode_entities_for_indexing($post->post_title); | |
| 6212 | - $content = get_post_field('post_content', $post); | |
| 6213 | - $excerpt = get_post_field('post_excerpt', $post); | |
| 6214 | - | |
| 6215 | 5237 | // Remove shortcode tags but preserve content inside them |
| 6216 | 5238 | $content = $this->strip_shortcode_tags_preserve_content($content); |
| 6217 | 5239 | $excerpt = $this->strip_shortcode_tags_preserve_content($excerpt); |
| 6218 | 5240 | |
| 6219 | 5241 | // Strip tags but preserve structure (don't use 'the_content' filter as it may re-add shortcodes) |
| 6220 | - // Entity decode at output time, matching the bulk-import path (d2c92e). | |
| 6221 | - $content = $this->mxchat_decode_entities_for_indexing(wp_strip_all_tags($content)); | |
| 5242 | + $content = wp_strip_all_tags($content); | |
| 6222 | 5243 | |
| 6223 | 5244 | // Combine title, short description (if exists), and content |
| 6224 | 5245 | $final_content = $title . "\n\n"; |
| 6225 | 5246 | |
| 6226 | 5247 | // Add short description if it exists (WooCommerce products use post_excerpt for short description) |
| 6227 | - // trim() only in the TEST — see the matching note on the bulk-import path. | |
| 6228 | - if (trim($excerpt) !== '') { | |
| 6229 | - $final_content .= "Short Description: " . $this->mxchat_decode_entities_for_indexing(wp_strip_all_tags($excerpt)) . "\n\n"; | |
| 5248 | + if (!empty($excerpt)) { | |
| 5249 | + $final_content .= "Short Description: " . wp_strip_all_tags($excerpt) . "\n\n"; | |
| 6230 | 5250 | } |
| 6231 | 5251 | |
| 6232 | 5252 | $final_content .= $content; |
| 6233 | 5253 | |
| @@ -6235,14 +5255,38 @@ | ||
| 6235 | 5255 | if ($post_type === 'product' && class_exists('WooCommerce')) { |
| 6236 | 5256 | $product = wc_get_product($post_id); |
| 6237 | 5257 | |
| 6238 | 5258 | if ($product) { |
| 5259 | + // Get pricing information | |
| 5260 | + $regular_price = $product->get_regular_price(); | |
| 5261 | + $sale_price = $product->get_sale_price(); | |
| 5262 | + $price = $product->get_price(); | |
| 6239 | 5263 | $sku = $product->get_sku(); |
| 6240 | 5264 | |
| 6241 | - // Add pricing information (base-currency pinned — see mxchat_product_price_lines) | |
| 5265 | + // Get currency symbol | |
| 5266 | + $currency_symbol = get_woocommerce_currency_symbol(); | |
| 5267 | + | |
| 5268 | + // Add pricing information | |
| 6242 | 5269 | $final_content .= "\n"; |
| 6243 | - $final_content .= $this->mxchat_product_price_lines($product); | |
| 5270 | + if (!empty($regular_price)) { | |
| 5271 | + $final_content .= "Price: " . $currency_symbol . $regular_price . "\n"; | |
| 5272 | + } elseif (!empty($price)) { | |
| 5273 | + $final_content .= "Price: " . $currency_symbol . $price . "\n"; | |
| 5274 | + } | |
| 6244 | 5275 | |
| 5276 | + if (!empty($sale_price) && $sale_price !== $regular_price) { | |
| 5277 | + $final_content .= "Sale Price: " . $currency_symbol . $sale_price . "\n"; | |
| 5278 | + } | |
| 5279 | + | |
| 5280 | + // Handle variable products - show price range | |
| 5281 | + if ($product->is_type('variable')) { | |
| 5282 | + $min_price = $product->get_variation_price('min'); | |
| 5283 | + $max_price = $product->get_variation_price('max'); | |
| 5284 | + if ($min_price !== $max_price) { | |
| 5285 | + $final_content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n"; | |
| 5286 | + } | |
| 5287 | + } | |
| 5288 | + | |
| 6245 | 5289 | if (!empty($sku)) { |
| 6246 | 5290 | $final_content .= "SKU: " . $sku . "\n"; |
| 6247 | 5291 | } |
| 6248 | 5292 | |
| @@ -6282,9 +5326,8 @@ | ||
| 6282 | 5326 | // ADD ACF FIELDS SUPPORT (matches ajax_mxchat_process_selected_content behavior) |
| 6283 | 5327 | $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id); |
| 6284 | 5328 | if (!empty($acf_fields)) { |
| 6285 | 5329 | $acf_content_parts = array(); |
| 6286 | - $pdf_attachment_ids = array(); | |
| 6287 | 5330 | |
| 6288 | 5331 | foreach ($acf_fields as $field_name => $field_value) { |
| 6289 | 5332 | $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id); |
| 6290 | 5333 | if (!empty($formatted_value)) { |
| @@ -6291,44 +5334,13 @@ | ||
| 6291 | 5334 | // Convert field name to readable label |
| 6292 | 5335 | $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name)); |
| 6293 | 5336 | $acf_content_parts[] = $field_label . ": " . $formatted_value; |
| 6294 | 5337 | } |
| 6295 | - | |
| 6296 | - $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids); | |
| 6297 | 5338 | } |
| 6298 | 5339 | |
| 6299 | 5340 | if (!empty($acf_content_parts)) { |
| 6300 | 5341 | $final_content .= "\n\n" . implode("\n", $acf_content_parts); |
| 6301 | 5342 | } |
| 6302 | - | |
| 6303 | - // Gate the auto-sync PDF-extraction loop behind an opt-in option. | |
| 6304 | - // Mirrors the per-batch checkbox the manual content selector has; the | |
| 6305 | - // 25 MB size cap lives in the shared extractor so it applies in both | |
| 6306 | - // paths regardless. Default OFF — re-parsing every ACF PDF on every | |
| 6307 | - // editor save is expensive and most sites don't want it. | |
| 6308 | - $autosync_extract_acf_pdfs = get_option('mxchat_auto_sync_acf_pdfs', '0') === '1'; | |
| 6309 | - if ($autosync_extract_acf_pdfs && !empty($pdf_attachment_ids)) { | |
| 6310 | - $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids))); | |
| 6311 | - $pdf_sections = array(); | |
| 6312 | - foreach ($pdf_attachment_ids as $att_id) { | |
| 6313 | - $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id); | |
| 6314 | - if (!empty($pdf_text)) { | |
| 6315 | - $pdf_title = get_the_title($att_id); | |
| 6316 | - $pdf_url = wp_get_attachment_url($att_id); | |
| 6317 | - $header = 'PDF Attachment'; | |
| 6318 | - if (!empty($pdf_title)) { | |
| 6319 | - $header .= ': ' . $pdf_title; | |
| 6320 | - } | |
| 6321 | - if (!empty($pdf_url)) { | |
| 6322 | - $header .= ' (' . $pdf_url . ')'; | |
| 6323 | - } | |
| 6324 | - $pdf_sections[] = $header . "\n" . $pdf_text; | |
| 6325 | - } | |
| 6326 | - } | |
| 6327 | - if (!empty($pdf_sections)) { | |
| 6328 | - $final_content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections); | |
| 6329 | - } | |
| 6330 | - } | |
| 6331 | 5343 | } |
| 6332 | 5344 | |
| 6333 | 5345 | // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields) |
| 6334 | 5346 | $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id); |
| @@ -6345,22 +5357,28 @@ | ||
| 6345 | 5357 | $final_content .= "\n\n" . implode("\n", $meta_content_parts); |
| 6346 | 5358 | } |
| 6347 | 5359 | } |
| 6348 | 5360 | |
| 6349 | - // Embedding decision — custom-provider-aware. Gating on a cloud API key | |
| 6350 | - // here silently killed auto-sync on keyless custom-embeddings sites, | |
| 6351 | - // because generate_embedding() routes custom FIRST and never needs the | |
| 6352 | - // key (plan cbd5fd). Silent-return shape preserved. | |
| 6353 | - $preflight = MxChat_Utils::embedding_preflight(get_option('mxchat_options')); | |
| 6354 | - if (!$preflight['ok']) { | |
| 5361 | + // Get API key with proper model detection | |
| 5362 | + $options = get_option('mxchat_options'); | |
| 5363 | + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002'; | |
| 5364 | + | |
| 5365 | + if (strpos($selected_model, 'voyage') === 0) { | |
| 5366 | + $api_key = $options['voyage_api_key'] ?? ''; | |
| 5367 | + } elseif (strpos($selected_model, 'gemini-embedding') === 0) { | |
| 5368 | + $api_key = $options['gemini_api_key'] ?? ''; | |
| 5369 | + } else { | |
| 5370 | + $api_key = $options['api_key'] ?? ''; | |
| 5371 | + } | |
| 5372 | + | |
| 5373 | + if (empty($api_key)) { | |
| 6355 | 5374 | return; |
| 6356 | 5375 | } |
| 6357 | - $api_key = $preflight['api_key']; | |
| 6358 | - | |
| 5376 | + | |
| 6359 | 5377 | // Use the centralized utility function for storage |
| 6360 | 5378 | $result = MxChat_Utils::submit_content_to_db( |
| 6361 | - $final_content, | |
| 6362 | - $source_url, | |
| 5379 | + $final_content, | |
| 5380 | + $source_url, | |
| 6363 | 5381 | $api_key, |
| 6364 | 5382 | md5($source_url) // Vector ID for Pinecone |
| 6365 | 5383 | ); |
| 6366 | 5384 | |
| @@ -6367,8 +5385,15 @@ | ||
| 6367 | 5385 | // After successful storage, apply role restriction based on tags |
| 6368 | 5386 | if (!is_wp_error($result)) { |
| 6369 | 5387 | $this->apply_role_restriction_to_post($post_id, $source_url); |
| 6370 | 5388 | } |
| 5389 | + } | |
| 5390 | + | |
| 5391 | + // Clean up the stored previous status if not used above | |
| 5392 | + if ($previous_status !== 'publish' || $post->post_status === 'publish') { | |
| 5393 | + delete_transient($previous_status_key); | |
| 5394 | + delete_transient($previous_url_key); | |
| 5395 | + } | |
| 6371 | 5396 | } |
| 6372 | 5397 | |
| 6373 | 5398 | /** |
| 6374 | 5399 | * Store the post status and URL before update to detect status transitions |
| @@ -6374,12 +5399,8 @@ | ||
| 6374 | 5399 | * Store the post status and URL before update to detect status transitions |
| 6375 | 5400 | * This runs before the post is actually updated in the database |
| 6376 | 5401 | */ |
| 6377 | 5402 | public function mxchat_store_pre_update_status($post_id, $data) { |
| 6378 | - // Core is inside wp_insert_post's update branch, so a post_updated WILL fire later | |
| 6379 | - // this request and can consume the arrival-edge guard (plan a664f3). | |
| 6380 | - $this->pending_post_update[$post_id] = true; | |
| 6381 | - | |
| 6382 | 5403 | // Get the current post from database (before update) |
| 6383 | 5404 | $current_post = get_post($post_id); |
| 6384 | 5405 | |
| 6385 | 5406 | if ($current_post) { |
| @@ -6395,407 +5416,8 @@ | ||
| 6395 | 5416 | } |
| 6396 | 5417 | } |
| 6397 | 5418 | } |
| 6398 | 5419 | |
| 6399 | -/** | |
| 6400 | - * Whether auto-sync is enabled for a post type (mirrors the checks used by the | |
| 6401 | - * update/delete handlers; kept as one helper so new call sites cannot drift). | |
| 6402 | - */ | |
| 6403 | -private function mxchat_is_auto_sync_enabled($post_type) { | |
| 6404 | - if ($post_type === 'post') { | |
| 6405 | - return get_option('mxchat_auto_sync_posts') === '1'; | |
| 6406 | - } | |
| 6407 | - if ($post_type === 'page') { | |
| 6408 | - return get_option('mxchat_auto_sync_pages') === '1'; | |
| 6409 | - } | |
| 6410 | - return get_option('mxchat_auto_sync_' . $post_type) === '1'; | |
| 6411 | -} | |
| 6412 | - | |
| 6413 | -/** | |
| 6414 | - * Remove a post's vectors the moment it leaves 'publish', using the authoritative | |
| 6415 | - * old status core passes to transition_post_status — no transient involved (plan 816fb1). | |
| 6416 | - * | |
| 6417 | - * Covers status changes that never route through wp_update_post (scheduled-expiry | |
| 6418 | - * plugins and others that flip post_status directly and call wp_transition_post_status), | |
| 6419 | - * where neither pre_post_update nor post_updated fires and the old detection missed. | |
| 6420 | - */ | |
| 6421 | -public function mxchat_handle_status_transition($new_status, $old_status, $post) { | |
| 6422 | - if (!($post instanceof WP_Post) || wp_is_post_revision($post->ID)) { | |
| 6423 | - return; | |
| 6424 | - } | |
| 6425 | - | |
| 6426 | - // Arrival edge (plan 3055e1): a post BECOMING published is indexed here, because | |
| 6427 | - // wp_publish_post() — the path scheduled posts take via check_and_publish_future_post — | |
| 6428 | - // and direct wp_insert_post(status=publish) creates never fire post_updated, so the | |
| 6429 | - // auto-sync ADD path alone misses them. Editor publishes also pass through here; | |
| 6430 | - // the transition_indexed_posts guard keeps mxchat_handle_post_update from embedding | |
| 6431 | - // a second time in the same request. | |
| 6432 | - if ($new_status === 'publish' && $old_status !== 'publish') { | |
| 6433 | - if ($this->mxchat_is_auto_sync_enabled($post->post_type)) { | |
| 6434 | - $this->mxchat_index_published_post($post->ID, $post); | |
| 6435 | - | |
| 6436 | - // Arm the double-fire guard ONLY when a post_updated is actually coming to | |
| 6437 | - // consume it (plan a664f3). Two publish paths never fire post_updated at all: | |
| 6438 | - // a direct wp_insert_post(status=publish) create, and wp_publish_post() — the | |
| 6439 | - // call check_and_publish_future_post() makes for scheduled posts. Arming the | |
| 6440 | - // guard unconditionally left it set with nothing to consume it, so the NEXT | |
| 6441 | - // update of that post was swallowed entirely: zero embed calls, no knowledge | |
| 6442 | - // -base row, silently. Consume-once on this side too, so a guard can never | |
| 6443 | - // outlive the single save it was armed for. | |
| 6444 | - if (!empty($this->pending_post_update[$post->ID])) { | |
| 6445 | - unset($this->pending_post_update[$post->ID]); | |
| 6446 | - $this->transition_indexed_posts[$post->ID] = true; | |
| 6447 | - } | |
| 6448 | - } | |
| 6449 | - return; | |
| 6450 | - } | |
| 6451 | - | |
| 6452 | - // Only the publish -> not-publish edge matters here. | |
| 6453 | - if ($old_status !== 'publish' || $new_status === 'publish') { | |
| 6454 | - return; | |
| 6455 | - } | |
| 6456 | - // Trash is handled by mxchat_handle_post_delete (wp_trash_post) with pre-trash URL | |
| 6457 | - // resolution; skip to avoid a second network round-trip per trash. | |
| 6458 | - if ($new_status === 'trash') { | |
| 6459 | - return; | |
| 6460 | - } | |
| 6461 | - if (!$this->mxchat_is_auto_sync_enabled($post->post_type)) { | |
| 6462 | - return; | |
| 6463 | - } | |
| 6464 | - | |
| 6465 | - $urls = array(); | |
| 6466 | - | |
| 6467 | - // The DB may already hold the new status when this fires, so get_permalink() on the | |
| 6468 | - // live post could build a draft-style URL whose md5 misses the stored vector IDs. | |
| 6469 | - // Reconstruct the published permalink from a clone instead. | |
| 6470 | - $published_clone = clone $post; | |
| 6471 | - $published_clone->post_status = 'publish'; | |
| 6472 | - $published_url = get_permalink($published_clone); | |
| 6473 | - if ($published_url) { | |
| 6474 | - $urls[] = $published_url; | |
| 6475 | - } | |
| 6476 | - | |
| 6477 | - // Honour the pre-update capture when present (covers a slug change in the same save). | |
| 6478 | - $previous_url = get_transient('mxchat_prev_url_' . $post->ID); | |
| 6479 | - if (!empty($previous_url)) { | |
| 6480 | - $urls[] = $previous_url; | |
| 6481 | - } | |
| 6482 | - | |
| 6483 | - foreach (array_unique($urls) as $url) { | |
| 6484 | - MxChat_Utils::delete_chunks_for_url($url, 'default'); | |
| 6485 | - } | |
| 6486 | - | |
| 6487 | - if (!empty($urls)) { | |
| 6488 | - $this->transition_deleted_posts[$post->ID] = true; | |
| 6489 | - } | |
| 6490 | -} | |
| 6491 | - | |
| 6492 | -/** | |
| 6493 | - * WP-CLI: remove knowledge-base entries left behind by posts that were unpublished, | |
| 6494 | - * trashed, or made private before the transition_post_status handler existed. | |
| 6495 | - * | |
| 6496 | - * Walks every auto-synced post type's non-published posts, reconstructs each one's | |
| 6497 | - * published-era permalink, and deletes its vectors (routes to Pinecone or the WP table). | |
| 6498 | - * Deletion is idempotent, so never-indexed posts are a cheap no-op. | |
| 6499 | - * | |
| 6500 | - * ## OPTIONS | |
| 6501 | - * | |
| 6502 | - * [--dry-run] | |
| 6503 | - * : Report what would be removed without deleting anything. | |
| 6504 | - * | |
| 6505 | - * ## EXAMPLES | |
| 6506 | - * | |
| 6507 | - * wp mxchat prune-unpublished --dry-run | |
| 6508 | - * wp mxchat prune-unpublished | |
| 6509 | - */ | |
| 6510 | -public function cli_prune_unpublished($args, $assoc_args) { | |
| 6511 | - global $wpdb; | |
| 6512 | - $dry_run = !empty($assoc_args['dry-run']); | |
| 6513 | - $table = $wpdb->prefix . 'mxchat_system_prompt_content'; | |
| 6514 | - | |
| 6515 | - $candidate_types = array_merge(array('post', 'page'), array_values(get_post_types(array('_builtin' => false), 'names'))); | |
| 6516 | - $synced_types = array(); | |
| 6517 | - foreach ($candidate_types as $type) { | |
| 6518 | - if ($this->mxchat_is_auto_sync_enabled($type)) { | |
| 6519 | - $synced_types[] = $type; | |
| 6520 | - } | |
| 6521 | - } | |
| 6522 | - if (empty($synced_types)) { | |
| 6523 | - WP_CLI::success('No post types have auto-sync enabled; nothing to prune.'); | |
| 6524 | - return; | |
| 6525 | - } | |
| 6526 | - | |
| 6527 | - $scanned = 0; | |
| 6528 | - $pruned = 0; | |
| 6529 | - $paged = 1; | |
| 6530 | - do { | |
| 6531 | - $query = new WP_Query(array( | |
| 6532 | - 'post_type' => $synced_types, | |
| 6533 | - 'post_status' => array('draft', 'pending', 'private', 'future', 'trash'), | |
| 6534 | - 'posts_per_page' => 100, | |
| 6535 | - 'paged' => $paged, | |
| 6536 | - 'fields' => 'ids', | |
| 6537 | - )); | |
| 6538 | - foreach ($query->posts as $post_id) { | |
| 6539 | - $post = get_post($post_id); | |
| 6540 | - if (!$post) { | |
| 6541 | - continue; | |
| 6542 | - } | |
| 6543 | - $scanned++; | |
| 6544 | - | |
| 6545 | - // Rebuild the permalink the post had while published: publish-status clone, | |
| 6546 | - // with wp_trash_post's __trashed slug suffix stripped for trashed posts. | |
| 6547 | - $clone = clone $post; | |
| 6548 | - $clone->post_status = 'publish'; | |
| 6549 | - if (substr($clone->post_name, -9) === '__trashed') { | |
| 6550 | - $clone->post_name = substr($clone->post_name, 0, -9); | |
| 6551 | - } | |
| 6552 | - $url = get_permalink($clone); | |
| 6553 | - if (!$url) { | |
| 6554 | - continue; | |
| 6555 | - } | |
| 6556 | - | |
| 6557 | - // Local-table row count is exact in WordPress-DB mode; in Pinecone mode it | |
| 6558 | - // reads 0 but the delete below still routes to Pinecone and is idempotent. | |
| 6559 | - $local_rows = (int) $wpdb->get_var($wpdb->prepare( | |
| 6560 | - "SELECT COUNT(*) FROM {$table} WHERE source_url = %s", $url | |
| 6561 | - )); | |
| 6562 | - | |
| 6563 | - if ($dry_run) { | |
| 6564 | - if ($local_rows > 0) { | |
| 6565 | - WP_CLI::log(sprintf('Would remove %d row(s): %s (post %d, %s)', $local_rows, $url, $post_id, $post->post_status)); | |
| 6566 | - $pruned += $local_rows; | |
| 6567 | - } | |
| 6568 | - continue; | |
| 6569 | - } | |
| 6570 | - | |
| 6571 | - MxChat_Utils::delete_chunks_for_url($url, 'default'); | |
| 6572 | - if ($local_rows > 0) { | |
| 6573 | - WP_CLI::log(sprintf('Removed %d row(s): %s (post %d, %s)', $local_rows, $url, $post_id, $post->post_status)); | |
| 6574 | - $pruned += $local_rows; | |
| 6575 | - } | |
| 6576 | - } | |
| 6577 | - $more = $paged < $query->max_num_pages; | |
| 6578 | - $paged++; | |
| 6579 | - } while ($more); | |
| 6580 | - | |
| 6581 | - WP_CLI::success(sprintf( | |
| 6582 | - '%s %d local knowledge row(s) across %d non-published post(s) scanned.%s', | |
| 6583 | - $dry_run ? 'Would remove' : 'Removed', | |
| 6584 | - $pruned, | |
| 6585 | - $scanned, | |
| 6586 | - ' (Pinecone-mode deletions are not counted locally.)' | |
| 6587 | - )); | |
| 6588 | -} | |
| 6589 | - | |
| 6590 | -/** | |
| 6591 | - * WP-CLI: repair knowledge-base rows whose PDF text was imported in visual | |
| 6592 | - * (reversed) order before the RTL normalizer existed. 32bf9e fixed new | |
| 6593 | - * imports only; this fixes rows already in the table without the customer | |
| 6594 | - * having to re-source and re-upload the original PDFs (plan d1e6f7). | |
| 6595 | - * | |
| 6596 | - * Detection reuses MxChat_Utils::normalize_pdf_rtl() on the stored text: a | |
| 6597 | - * row is a candidate exactly when the normalizer would change it, so the | |
| 6598 | - * import-time heuristic and the repair heuristic can never disagree. | |
| 6599 | - * Repaired rows are RE-EMBEDDED — the stored vector was computed over | |
| 6600 | - * reversed text and is as broken as the text — so a wet run calls the | |
| 6601 | - * embedding provider once per repaired row on the site's API key. Runs | |
| 6602 | - * beyond 25 rows therefore require --yes. | |
| 6603 | - * | |
| 6604 | - * Scope notes: | |
| 6605 | - * - Scans the WordPress knowledge table. Pinecone-mode entries live in | |
| 6606 | - * Pinecone, not this table, and are not scanned; if a scanned row's bot | |
| 6607 | - * ALSO has Pinecone enabled (hybrid drift), the repaired entry is | |
| 6608 | - * re-submitted through the normal import path so the md5-keyed Pinecone | |
| 6609 | - * vector is replaced too. | |
| 6610 | - * - Knowledge rows do not carry a bot id; --bot only selects whose | |
| 6611 | - * embedding configuration (model + key) is used for re-embedding. | |
| 6612 | - * - The mxchat_pdf_rtl_normalize filter is honoured: a site that disabled | |
| 6613 | - * normalization gets detections of zero, not surprise rewrites. | |
| 6614 | - * - The metadata header the PDF importer stores before the text separator | |
| 6615 | - * is preserved byte-identical; only the text segment is repaired. | |
| 6616 | - * | |
| 6617 | - * ## OPTIONS | |
| 6618 | - * | |
| 6619 | - * [--dry-run] | |
| 6620 | - * : List the rows that would be repaired without changing anything. | |
| 6621 | - * | |
| 6622 | - * [--bot=<id>] | |
| 6623 | - * : Embedding configuration to use for re-embedding. Default: default. | |
| 6624 | - * | |
| 6625 | - * [--all-content] | |
| 6626 | - * : Scan every row containing right-to-left text, not just rows with PDF | |
| 6627 | - * provenance (a page anchor in the source URL, or pdf content type). | |
| 6628 | - * | |
| 6629 | - * [--yes] | |
| 6630 | - * : Proceed even when more than 25 rows need re-embedding (API cost gate). | |
| 6631 | - * | |
| 6632 | - * ## EXAMPLES | |
| 6633 | - * | |
| 6634 | - * wp mxchat rtl-repair --dry-run | |
| 6635 | - * wp mxchat rtl-repair | |
| 6636 | - * wp mxchat rtl-repair --all-content --yes | |
| 6637 | - */ | |
| 6638 | -public function cli_rtl_repair($args, $assoc_args) { | |
| 6639 | - global $wpdb; | |
| 6640 | - $dry_run = !empty($assoc_args['dry-run']); | |
| 6641 | - $all = !empty($assoc_args['all-content']); | |
| 6642 | - $yes = !empty($assoc_args['yes']); | |
| 6643 | - $bot_id = isset($assoc_args['bot']) ? sanitize_key($assoc_args['bot']) : 'default'; | |
| 6644 | - $table = $wpdb->prefix . 'mxchat_system_prompt_content'; | |
| 6645 | - | |
| 6646 | - // Detection pass — no API calls. Walk the table in id batches so a large | |
| 6647 | - // knowledge base never loads at once. | |
| 6648 | - $rtl_re = '/[\x{0590}-\x{05FF}\x{0600}-\x{06FF}\x{0750}-\x{077F}\x{FB50}-\x{FDFF}\x{FE70}-\x{FEFF}]/u'; | |
| 6649 | - $candidates = array(); | |
| 6650 | - $scanned = 0; | |
| 6651 | - $last_id = 0; | |
| 6652 | - do { | |
| 6653 | - if ($all) { | |
| 6654 | - $rows = $wpdb->get_results($wpdb->prepare( | |
| 6655 | - "SELECT id, article_content, source_url, content_type FROM {$table} | |
| 6656 | - WHERE id > %d ORDER BY id ASC LIMIT 200", | |
| 6657 | - $last_id | |
| 6658 | - )); | |
| 6659 | - } else { | |
| 6660 | - $rows = $wpdb->get_results($wpdb->prepare( | |
| 6661 | - "SELECT id, article_content, source_url, content_type FROM {$table} | |
| 6662 | - WHERE id > %d AND (source_url LIKE %s OR content_type = 'pdf') | |
| 6663 | - ORDER BY id ASC LIMIT 200", | |
| 6664 | - $last_id, | |
| 6665 | - '%' . $wpdb->esc_like('#page=') . '%' | |
| 6666 | - )); | |
| 6667 | - } | |
| 6668 | - foreach ($rows as $row) { | |
| 6669 | - $last_id = (int) $row->id; | |
| 6670 | - $scanned++; | |
| 6671 | - $content = (string) $row->article_content; | |
| 6672 | - if (!preg_match($rtl_re, $content)) { | |
| 6673 | - continue; | |
| 6674 | - } | |
| 6675 | - list($header, $text) = $this->mxchat_rtl_repair_split($content); | |
| 6676 | - $normalized = MxChat_Utils::normalize_pdf_rtl($text, 'rtl-repair row ' . $row->id); | |
| 6677 | - if (is_string($normalized) && $normalized !== $text) { | |
| 6678 | - $candidates[] = array( | |
| 6679 | - 'id' => (int) $row->id, | |
| 6680 | - 'source_url' => (string) $row->source_url, | |
| 6681 | - 'content_type' => (string) $row->content_type, | |
| 6682 | - 'new_content' => $header . $normalized, | |
| 6683 | - ); | |
| 6684 | - } | |
| 6685 | - } | |
| 6686 | - } while (count($rows) === 200); | |
| 6687 | - | |
| 6688 | - WP_CLI::log(sprintf('Scanned %d row(s); %d stored in reversed (visual) order.', $scanned, count($candidates))); | |
| 6689 | - if (empty($candidates)) { | |
| 6690 | - WP_CLI::success('No reversed RTL rows found — nothing to repair.'); | |
| 6691 | - return; | |
| 6692 | - } | |
| 6693 | - | |
| 6694 | - foreach ($candidates as $c) { | |
| 6695 | - WP_CLI::log(sprintf('%s row %d %s', $dry_run ? 'Would repair' : 'Will repair', $c['id'], $c['source_url'])); | |
| 6696 | - } | |
| 6697 | - if ($dry_run) { | |
| 6698 | - WP_CLI::success(sprintf('Dry run: %d row(s) would be repaired and re-embedded. Run without --dry-run to apply.', count($candidates))); | |
| 6699 | - return; | |
| 6700 | - } | |
| 6701 | - | |
| 6702 | - // Cost gate: re-embedding spends the customer's API budget. | |
| 6703 | - WP_CLI::log(sprintf('Re-embedding will call the embedding provider once per row — %d call(s) on this site\'s API key.', count($candidates))); | |
| 6704 | - if (count($candidates) > 25 && !$yes) { | |
| 6705 | - WP_CLI::error(sprintf('%d rows need re-embedding (more than 25). Re-run with --yes to confirm the API cost. No rows were changed.', count($candidates))); | |
| 6706 | - } | |
| 6707 | - | |
| 6708 | - $bot_options = $this->get_bot_options($bot_id); | |
| 6709 | - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options'); | |
| 6710 | - $preflight = MxChat_Utils::embedding_preflight($options); | |
| 6711 | - if (!$preflight['ok']) { | |
| 6712 | - WP_CLI::error('Embedding configuration problem: ' . $preflight['reason']); | |
| 6713 | - } | |
| 6714 | - $api_key = $preflight['api_key']; | |
| 6715 | - | |
| 6716 | - $pinecone_hybrid = $this->mxchat_rtl_repair_pinecone_enabled($bot_id); | |
| 6717 | - $repaired = 0; | |
| 6718 | - $failed = 0; | |
| 6719 | - foreach ($candidates as $c) { | |
| 6720 | - $vector = MxChat_Utils::regenerate_embedding($c['new_content'], $api_key, $bot_id); | |
| 6721 | - if (!is_array($vector)) { | |
| 6722 | - $failed++; | |
| 6723 | - $reason = is_wp_error($vector) ? $vector->get_error_message() : 'embedding request failed'; | |
| 6724 | - // Text and vector must stay consistent: never write repaired text | |
| 6725 | - // beside the stale reversed-text vector. | |
| 6726 | - WP_CLI::warning(sprintf('Row %d NOT repaired — %s. Row left unchanged.', $c['id'], $reason)); | |
| 6727 | - continue; | |
| 6728 | - } | |
| 6729 | - $wpdb->update( | |
| 6730 | - $table, | |
| 6731 | - array( | |
| 6732 | - 'article_content' => $c['new_content'], | |
| 6733 | - 'embedding_vector' => maybe_serialize($vector), | |
| 6734 | - ), | |
| 6735 | - array('id' => $c['id']), | |
| 6736 | - array('%s', '%s'), | |
| 6737 | - array('%d') | |
| 6738 | - ); | |
| 6739 | - $repaired++; | |
| 6740 | - if (class_exists('MxChat_Admin')) { | |
| 6741 | - MxChat_Admin::mxchat_log_debug('pdf_rtl_repaired', 'Stored KB row restored to logical order and re-embedded', array( | |
| 6742 | - 'row_id' => $c['id'], | |
| 6743 | - 'source_url' => $c['source_url'], | |
| 6744 | - 'bot' => $bot_id, | |
| 6745 | - )); | |
| 6746 | - } | |
| 6747 | - // Hybrid drift: the bot indexes into Pinecone but this row sat in the | |
| 6748 | - // WP table — push the repaired entry through the normal import path so | |
| 6749 | - // the md5(source_url)-keyed Pinecone vector is replaced as well. | |
| 6750 | - if ($pinecone_hybrid) { | |
| 6751 | - MxChat_Utils::submit_content_to_db( | |
| 6752 | - $c['new_content'], | |
| 6753 | - $c['source_url'], | |
| 6754 | - $api_key, | |
| 6755 | - null, | |
| 6756 | - $bot_id, | |
| 6757 | - $c['content_type'] !== '' ? $c['content_type'] : 'pdf' | |
| 6758 | - ); | |
| 6759 | - } | |
| 6760 | - } | |
| 6761 | - | |
| 6762 | - WP_CLI::success(sprintf('Repaired + re-embedded %d row(s); %d failed; %d scanned.', $repaired, $failed, $scanned)); | |
| 6763 | -} | |
| 6764 | - | |
| 6765 | -/** | |
| 6766 | - * Split a stored KB row into (metadata header incl. separator, text segment). | |
| 6767 | - * The PDF importer stores wp_json_encode($metadata) . "\n---\n" . $text — | |
| 6768 | - * repair must touch only the text and keep the header byte-identical. | |
| 6769 | - */ | |
| 6770 | -private function mxchat_rtl_repair_split($content) { | |
| 6771 | - $sep = "\n---\n"; | |
| 6772 | - $pos = strpos($content, $sep); | |
| 6773 | - if ($pos !== false && $pos > 0 && $content[0] === '{') { | |
| 6774 | - $maybe_json = substr($content, 0, $pos); | |
| 6775 | - if (json_decode($maybe_json) !== null) { | |
| 6776 | - return array(substr($content, 0, $pos + strlen($sep)), substr($content, $pos + strlen($sep))); | |
| 6777 | - } | |
| 6778 | - } | |
| 6779 | - return array('', $content); | |
| 6780 | -} | |
| 6781 | - | |
| 6782 | -/** | |
| 6783 | - * Mirror of MxChat_Utils::is_pinecone_enabled_for_bot() (private there) for | |
| 6784 | - * the repair CLI's hybrid-drift check. | |
| 6785 | - */ | |
| 6786 | -private function mxchat_rtl_repair_pinecone_enabled($bot_id) { | |
| 6787 | - if ($bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager')) { | |
| 6788 | - $cfg = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id); | |
| 6789 | - if (!empty($cfg)) { | |
| 6790 | - return !empty($cfg['use_pinecone']) && !empty($cfg['api_key']) && !empty($cfg['host']); | |
| 6791 | - } | |
| 6792 | - } | |
| 6793 | - $po = get_option('mxchat_pinecone_addon_options'); | |
| 6794 | - return !empty($po['mxchat_use_pinecone']) && $po['mxchat_use_pinecone'] !== '0' | |
| 6795 | - && !empty($po['mxchat_pinecone_api_key']) && !empty($po['mxchat_pinecone_host']); | |
| 6796 | -} | |
| 6797 | - | |
| 6798 | 5420 | public function mxchat_handle_post_delete($post_id) { |
| 6799 | 5421 | // Get post data before it's deleted |
| 6800 | 5422 | $post = get_post($post_id); |
| 6801 | 5423 | |
| @@ -6825,14 +5447,12 @@ | ||
| 6825 | 5447 | if (!$should_sync) { |
| 6826 | 5448 | return; |
| 6827 | 5449 | } |
| 6828 | 5450 | |
| 6829 | - // Resolve the pre-trash URL. wp_trash_post renames the slug with "__trashed" before firing | |
| 6830 | - // this hook, so get_permalink() here would return the trashed URL and md5() would miss the | |
| 6831 | - // real vector IDs stored under the original URL. | |
| 6832 | - $source_url = $this->mxchat_resolve_pre_trash_url($post_id); | |
| 5451 | + // Get the URL before post is deleted | |
| 5452 | + $source_url = get_permalink($post_id); | |
| 6833 | 5453 | if (!$source_url) { |
| 6834 | - //error_log('MXChat: Failed to resolve source URL for post ' . $post_id); | |
| 5454 | + //error_log('MXChat: Failed to get permalink for post ' . $post_id); | |
| 6835 | 5455 | return; |
| 6836 | 5456 | } |
| 6837 | 5457 | |
| 6838 | 5458 | // Use chunk-aware deletion (handles both chunked and non-chunked content) |
| @@ -6840,36 +5460,56 @@ | ||
| 6840 | 5460 | |
| 6841 | 5461 | if (is_wp_error($delete_result)) { |
| 6842 | 5462 | //error_log('MXChat: Chunk-aware deletion failed for URL: ' . $source_url . ' - ' . $delete_result->get_error_message()); |
| 6843 | 5463 | } |
| 5464 | +} | |
| 6844 | 5465 | |
| 6845 | - delete_transient('mxchat_prev_url_' . $post_id); | |
| 6846 | - delete_transient('mxchat_prev_status_' . $post_id); | |
| 6847 | -} | |
| 6848 | 5466 | |
| 6849 | -/** | |
| 6850 | - * Resolve the source URL for a post being trashed/deleted. | |
| 6851 | - * | |
| 6852 | - * Why: wp_trash_post appends "__trashed" to the slug before the wp_trash_post action fires, so | |
| 6853 | - * get_permalink() returns a URL whose md5() won't match the vector IDs stored in Pinecone or | |
| 6854 | - * the source_url rows in the WP DB. Prefer the URL captured by mxchat_store_pre_update_status | |
| 6855 | - * (runs on pre_post_update, before the rename); fall back to stripping the __trashed suffix. | |
| 6856 | - */ | |
| 6857 | -private function mxchat_resolve_pre_trash_url($post_id) { | |
| 6858 | - $previous_url = get_transient('mxchat_prev_url_' . $post_id); | |
| 6859 | - if (!empty($previous_url)) { | |
| 6860 | - return $previous_url; | |
| 6861 | - } | |
| 5467 | + /** | |
| 5468 | + * Deletes data from Pinecone using a source URL | |
| 5469 | + */ | |
| 5470 | + public function mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options) { | |
| 5471 | + $host = $pinecone_options['mxchat_pinecone_host'] ?? ''; | |
| 5472 | + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? ''; | |
| 6862 | 5473 | |
| 6863 | - $current = get_permalink($post_id); | |
| 6864 | - if (!$current) { | |
| 6865 | - return ''; | |
| 6866 | - } | |
| 6867 | - return preg_replace('#__trashed(/?)$#', '$1', $current); | |
| 6868 | -} | |
| 5474 | + if (empty($host) || empty($api_key)) { | |
| 5475 | + //error_log('MXChat: Pinecone deletion failed - missing configuration'); | |
| 5476 | + return false; | |
| 5477 | + } | |
| 6869 | 5478 | |
| 5479 | + $api_endpoint = "https://{$host}/vectors/delete"; | |
| 5480 | + $vector_id = md5($source_url); | |
| 6870 | 5481 | |
| 5482 | + $request_body = array( | |
| 5483 | + 'ids' => array($vector_id) | |
| 5484 | + ); | |
| 6871 | 5485 | |
| 5486 | + $response = wp_remote_post($api_endpoint, array( | |
| 5487 | + 'headers' => array( | |
| 5488 | + 'Api-Key' => $api_key, | |
| 5489 | + 'accept' => 'application/json', | |
| 5490 | + 'content-type' => 'application/json' | |
| 5491 | + ), | |
| 5492 | + 'body' => wp_json_encode($request_body), | |
| 5493 | + 'timeout' => 30 | |
| 5494 | + )); | |
| 5495 | + | |
| 5496 | + if (is_wp_error($response)) { | |
| 5497 | + //error_log('MXChat: Pinecone deletion error - ' . $response->get_error_message()); | |
| 5498 | + return false; | |
| 5499 | + } | |
| 5500 | + | |
| 5501 | + $response_code = wp_remote_retrieve_response_code($response); | |
| 5502 | + if ($response_code !== 200) { | |
| 5503 | + //error_log('MXChat: Pinecone deletion failed with status ' . $response_code); | |
| 5504 | + return false; | |
| 5505 | + } | |
| 5506 | + | |
| 5507 | + return true; | |
| 5508 | + } | |
| 5509 | + | |
| 5510 | + | |
| 5511 | + | |
| 6872 | 5512 | public function mxchat_handle_product_change($post_id, $post, $update) { |
| 6873 | 5513 | if ($post->post_type !== 'product') { |
| 6874 | 5514 | return; |
| 6875 | 5515 | } |
| @@ -6899,10 +5539,16 @@ | ||
| 6899 | 5539 | // Build product content |
| 6900 | 5540 | $title = $product->get_name(); |
| 6901 | 5541 | $description = $product->get_description(); |
| 6902 | 5542 | $short_description = $product->get_short_description(); |
| 5543 | + $regular_price = $product->get_regular_price(); | |
| 5544 | + $sale_price = $product->get_sale_price(); | |
| 5545 | + $price = $product->get_price(); | |
| 6903 | 5546 | $sku = $product->get_sku(); |
| 6904 | 5547 | |
| 5548 | + // Get currency symbol | |
| 5549 | + $currency_symbol = get_woocommerce_currency_symbol(); | |
| 5550 | + | |
| 6905 | 5551 | // Format content consistently |
| 6906 | 5552 | $content = $title . "\n\n"; |
| 6907 | 5553 | |
| 6908 | 5554 | if (!empty($short_description)) { |
| @@ -6912,11 +5558,28 @@ | ||
| 6912 | 5558 | if (!empty($description)) { |
| 6913 | 5559 | $content .= wp_strip_all_tags($description) . "\n\n"; |
| 6914 | 5560 | } |
| 6915 | 5561 | |
| 6916 | - // Add pricing information (base-currency pinned — see mxchat_product_price_lines) | |
| 6917 | - $content .= $this->mxchat_product_price_lines($product); | |
| 5562 | + // Add pricing information | |
| 5563 | + if (!empty($regular_price)) { | |
| 5564 | + $content .= "Price: " . $currency_symbol . $regular_price . "\n"; | |
| 5565 | + } elseif (!empty($price)) { | |
| 5566 | + $content .= "Price: " . $currency_symbol . $price . "\n"; | |
| 5567 | + } | |
| 6918 | 5568 | |
| 5569 | + if (!empty($sale_price) && $sale_price !== $regular_price) { | |
| 5570 | + $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n"; | |
| 5571 | + } | |
| 5572 | + | |
| 5573 | + // Handle variable products - show price range | |
| 5574 | + if ($product->is_type('variable')) { | |
| 5575 | + $min_price = $product->get_variation_price('min'); | |
| 5576 | + $max_price = $product->get_variation_price('max'); | |
| 5577 | + if ($min_price !== $max_price) { | |
| 5578 | + $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n"; | |
| 5579 | + } | |
| 5580 | + } | |
| 5581 | + | |
| 6919 | 5582 | if (!empty($sku)) { |
| 6920 | 5583 | $content .= "SKU: " . $sku . "\n"; |
| 6921 | 5584 | } |
| 6922 | 5585 | |
| @@ -6958,16 +5621,24 @@ | ||
| 6958 | 5621 | } |
| 6959 | 5622 | } |
| 6960 | 5623 | } |
| 6961 | 5624 | |
| 6962 | - // Embedding decision — custom-provider-aware (plan cbd5fd); silent-return | |
| 6963 | - // shape preserved. | |
| 6964 | - $preflight = MxChat_Utils::embedding_preflight(get_option('mxchat_options')); | |
| 6965 | - if (!$preflight['ok']) { | |
| 6966 | - //error_log('MxChat Auto-sync: embedding pre-flight failed: ' . $preflight['reason']); | |
| 5625 | + // Get API key with proper model detection | |
| 5626 | + $options = get_option('mxchat_options'); | |
| 5627 | + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002'; | |
| 5628 | + | |
| 5629 | + if (strpos($selected_model, 'voyage') === 0) { | |
| 5630 | + $api_key = $options['voyage_api_key'] ?? ''; | |
| 5631 | + } elseif (strpos($selected_model, 'gemini-embedding') === 0) { | |
| 5632 | + $api_key = $options['gemini_api_key'] ?? ''; | |
| 5633 | + } else { | |
| 5634 | + $api_key = $options['api_key'] ?? ''; | |
| 5635 | + } | |
| 5636 | + | |
| 5637 | + if (empty($api_key)) { | |
| 5638 | + //error_log('MxChat Auto-sync: No API key configured for embedding model'); | |
| 6967 | 5639 | return; |
| 6968 | 5640 | } |
| 6969 | - $api_key = $preflight['api_key']; | |
| 6970 | 5641 | |
| 6971 | 5642 | // Use the centralized utility function for storage |
| 6972 | 5643 | $result = MxChat_Utils::submit_content_to_db( |
| 6973 | 5644 | $content, |
| @@ -6990,18 +5661,28 @@ | ||
| 6990 | 5661 | if (get_post_type($post_id) !== 'product') { |
| 6991 | 5662 | return; |
| 6992 | 5663 | } |
| 6993 | 5664 | |
| 6994 | - $source_url = $this->mxchat_resolve_pre_trash_url($post_id); | |
| 6995 | - if (!$source_url) { | |
| 6996 | - return; | |
| 6997 | - } | |
| 5665 | + $source_url = get_permalink($post_id); | |
| 6998 | 5666 | |
| 6999 | - // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks) | |
| 7000 | - MxChat_Utils::delete_chunks_for_url($source_url, 'default'); | |
| 5667 | + // Check if Pinecone is enabled | |
| 5668 | + $pinecone_options = get_option('mxchat_pinecone_addon_options', array()); | |
| 5669 | + $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1'; | |
| 7001 | 5670 | |
| 7002 | - delete_transient('mxchat_prev_url_' . $post_id); | |
| 7003 | - delete_transient('mxchat_prev_status_' . $post_id); | |
| 5671 | + if ($use_pinecone && !empty($pinecone_options['mxchat_pinecone_api_key'])) { | |
| 5672 | + // Delete from Pinecone | |
| 5673 | + $this->mxchat_delete_from_pinecone_by_url($source_url, $pinecone_options); | |
| 5674 | + } else { | |
| 5675 | + // Delete from WordPress DB | |
| 5676 | + global $wpdb; | |
| 5677 | + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content'; | |
| 5678 | + | |
| 5679 | + $wpdb->delete( | |
| 5680 | + $table_name, | |
| 5681 | + array('source_url' => $source_url), | |
| 5682 | + array('%s') | |
| 5683 | + ); | |
| 5684 | + } | |
| 7004 | 5685 | } |
| 7005 | 5686 | |
| 7006 | 5687 | /** |
| 7007 | 5688 | * Handle individual Pinecone content deletion |
| @@ -7043,12 +5724,11 @@ | ||
| 7043 | 5724 | |
| 7044 | 5725 | // Delete from Pinecone |
| 7045 | 5726 | $pinecone_manager = MxChat_Pinecone_Manager::get_instance(); |
| 7046 | 5727 | $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id( |
| 7047 | - $vector_id, | |
| 7048 | - $pinecone_options['mxchat_pinecone_api_key'], | |
| 7049 | - $pinecone_options['mxchat_pinecone_host'], | |
| 7050 | - $pinecone_options['mxchat_pinecone_namespace'] ?? '' | |
| 5728 | + $vector_id, | |
| 5729 | + $pinecone_options['mxchat_pinecone_api_key'], | |
| 5730 | + $pinecone_options['mxchat_pinecone_host'] | |
| 7051 | 5731 | ); |
| 7052 | 5732 | |
| 7053 | 5733 | if ($result['success']) { |
| 7054 | 5734 | // No cache clearing needed since we removed caching |
| @@ -7099,14 +5779,13 @@ | ||
| 7099 | 5779 | wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id); |
| 7100 | 5780 | exit; |
| 7101 | 5781 | } |
| 7102 | 5782 | |
| 7103 | - // Delete from the correct Pinecone index and namespace | |
| 5783 | + // Delete from the correct Pinecone index | |
| 7104 | 5784 | $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id( |
| 7105 | - $vector_id, | |
| 7106 | - $pinecone_options['mxchat_pinecone_api_key'], | |
| 7107 | - $pinecone_options['mxchat_pinecone_host'], | |
| 7108 | - $pinecone_options['mxchat_pinecone_namespace'] ?? '' | |
| 5785 | + $vector_id, | |
| 5786 | + $pinecone_options['mxchat_pinecone_api_key'], | |
| 5787 | + $pinecone_options['mxchat_pinecone_host'] | |
| 7109 | 5788 | ); |
| 7110 | 5789 | |
| 7111 | 5790 | if ($result['success']) { |
| 7112 | 5791 | // No cache clearing needed since we removed caching |
| @@ -7374,9 +6053,8 @@ | ||
| 7374 | 6053 | $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1'; |
| 7375 | 6054 | |
| 7376 | 6055 | $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? ''; |
| 7377 | 6056 | $host = $pinecone_options['mxchat_pinecone_host'] ?? ''; |
| 7378 | - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? ''; | |
| 7379 | 6057 | |
| 7380 | 6058 | // ============================================= |
| 7381 | 6059 | // PHASE 1: Collect all Pinecone vector IDs |
| 7382 | 6060 | // and separate WordPress entries |
| @@ -7409,11 +6087,8 @@ | ||
| 7409 | 6087 | $base_vector_id = md5($source_url); |
| 7410 | 6088 | $all_vector_ids[] = $base_vector_id; |
| 7411 | 6089 | |
| 7412 | 6090 | $list_url = 'https://' . $host . '/vectors/list?prefix=' . urlencode($base_vector_id . '_chunk_') . '&limit=100'; |
| 7413 | - if (!empty($namespace)) { | |
| 7414 | - $list_url .= '&namespace=' . rawurlencode($namespace); | |
| 7415 | - } | |
| 7416 | 6091 | $list_response = wp_remote_get($list_url, array( |
| 7417 | 6092 | 'headers' => array( |
| 7418 | 6093 | 'Api-Key' => $api_key, |
| 7419 | 6094 | 'accept' => 'application/json' |
| @@ -7448,12 +6123,8 @@ | ||
| 7448 | 6123 | $pinecone_success = true; |
| 7449 | 6124 | $batches = array_chunk($all_vector_ids, 100); |
| 7450 | 6125 | |
| 7451 | 6126 | foreach ($batches as $batch) { |
| 7452 | - $delete_body = array('ids' => $batch); | |
| 7453 | - if (!empty($namespace)) { | |
| 7454 | - $delete_body['namespace'] = $namespace; | |
| 7455 | - } | |
| 7456 | 6127 | $delete_response = wp_remote_post("https://{$host}/vectors/delete", array( |
| 7457 | 6128 | 'headers' => array( |
| 7458 | 6129 | 'Api-Key' => $api_key, |
| 7459 | 6130 | 'accept' => 'application/json', |
| @@ -7458,9 +6129,9 @@ | ||
| 7458 | 6129 | 'Api-Key' => $api_key, |
| 7459 | 6130 | 'accept' => 'application/json', |
| 7460 | 6131 | 'content-type' => 'application/json' |
| 7461 | 6132 | ), |
| 7462 | - 'body' => wp_json_encode($delete_body), | |
| 6133 | + 'body' => wp_json_encode(array('ids' => $batch)), | |
| 7463 | 6134 | 'timeout' => 60 |
| 7464 | 6135 | )); |
| 7465 | 6136 | |
| 7466 | 6137 | if (is_wp_error($delete_response)) { |
| @@ -7715,16 +6386,16 @@ | ||
| 7715 | 6386 | wp_send_json_error('Unauthorized access'); |
| 7716 | 6387 | exit; |
| 7717 | 6388 | } |
| 7718 | 6389 | |
| 7719 | - $tag_input = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : ''; | |
| 6390 | + $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : ''; | |
| 7720 | 6391 | $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public'; |
| 7721 | - | |
| 7722 | - if (empty($tag_input)) { | |
| 7723 | - wp_send_json_error('Please enter a tag name or slug'); | |
| 6392 | + | |
| 6393 | + if (empty($tag_slug)) { | |
| 6394 | + wp_send_json_error('Tag slug is required'); | |
| 7724 | 6395 | exit; |
| 7725 | 6396 | } |
| 7726 | - | |
| 6397 | + | |
| 7727 | 6398 | // Validate role restriction |
| 7728 | 6399 | $valid_roles = array_keys($this->mxchat_get_role_options()); |
| 7729 | 6400 | if (!in_array($role_restriction, $valid_roles)) { |
| 7730 | 6401 | wp_send_json_error('Invalid role restriction'); |
| @@ -7729,27 +6400,16 @@ | ||
| 7729 | 6400 | if (!in_array($role_restriction, $valid_roles)) { |
| 7730 | 6401 | wp_send_json_error('Invalid role restriction'); |
| 7731 | 6402 | exit; |
| 7732 | 6403 | } |
| 7733 | - | |
| 7734 | - // Resolve the tag by slug first, then fall back to its display name, so users can | |
| 7735 | - // enter either "premium-content" or "Premium Content". (plan b8bcf5 — the field is | |
| 7736 | - // labeled by name but previously validated by slug only, producing the confusing | |
| 7737 | - // "Tag does not exist in WordPress" error when a real tag's name was typed.) | |
| 7738 | - $term = get_term_by('slug', $tag_input, 'post_tag'); | |
| 6404 | + | |
| 6405 | + // Check if tag exists in WordPress | |
| 6406 | + $term = get_term_by('slug', $tag_slug, 'post_tag'); | |
| 7739 | 6407 | if (!$term) { |
| 7740 | - $term = get_term_by('name', $tag_input, 'post_tag'); | |
| 7741 | - } | |
| 7742 | - if (!$term) { | |
| 7743 | - 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.'); | |
| 6408 | + wp_send_json_error('Tag does not exist in WordPress'); | |
| 7744 | 6409 | exit; |
| 7745 | 6410 | } |
| 7746 | - | |
| 7747 | - // Always key the mapping by the RESOLVED slug — apply_role_restriction_to_post() | |
| 7748 | - // compares against each post's tag slugs, so the stored key must be a slug, | |
| 7749 | - // never the raw (possibly display-name) input. | |
| 7750 | - $tag_slug = $term->slug; | |
| 7751 | - | |
| 6411 | + | |
| 7752 | 6412 | // Get existing mappings |
| 7753 | 6413 | $mappings = get_option('mxchat_tag_role_mappings', array()); |
| 7754 | 6414 | |
| 7755 | 6415 | // Check if mapping already exists |
| @@ -8574,19 +7234,25 @@ | ||
| 8574 | 7234 | if (empty($url)) { |
| 8575 | 7235 | return new WP_Error('invalid_url', 'URL is empty'); |
| 8576 | 7236 | } |
| 8577 | 7237 | |
| 8578 | - // Get bot-specific embedding decision early (needed for both paths) — | |
| 8579 | - // custom-provider-aware (plan cbd5fd). Error code preserved. | |
| 7238 | + // Get bot-specific API key early (needed for both paths) | |
| 8580 | 7239 | $bot_options = $this->get_bot_options($bot_id); |
| 8581 | 7240 | $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options'); |
| 7241 | + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002'; | |
| 8582 | 7242 | |
| 8583 | - $preflight = MxChat_Utils::embedding_preflight($options); | |
| 8584 | - if (!$preflight['ok']) { | |
| 8585 | - return new WP_Error('no_api_key', $preflight['reason'] . ' (bot: ' . $bot_id . ')'); | |
| 7243 | + if (strpos($selected_model, 'voyage') === 0) { | |
| 7244 | + $api_key = $options['voyage_api_key'] ?? ''; | |
| 7245 | + } elseif (strpos($selected_model, 'gemini-embedding') === 0) { | |
| 7246 | + $api_key = $options['gemini_api_key'] ?? ''; | |
| 7247 | + } else { | |
| 7248 | + $api_key = $options['api_key'] ?? ''; | |
| 8586 | 7249 | } |
| 8587 | - $api_key = $preflight['api_key']; | |
| 8588 | 7250 | |
| 7251 | + if (empty($api_key)) { | |
| 7252 | + return new WP_Error('no_api_key', 'API key not configured for bot: ' . $bot_id); | |
| 7253 | + } | |
| 7254 | + | |
| 8589 | 7255 | // Check if this is a WooCommerce product URL and WooCommerce is active |
| 8590 | 7256 | $is_product_url = (strpos($url, '/product/') !== false || strpos($url, '/shop/') !== false); |
| 8591 | 7257 | $content_type = $is_product_url ? 'product' : 'url'; |
| 8592 | 7258 | |
| @@ -8613,9 +7279,9 @@ | ||
| 8613 | 7279 | $is_likely_pdf = (strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION)) === 'pdf'); |
| 8614 | 7280 | $response = wp_remote_get($url, array( |
| 8615 | 7281 | 'timeout' => $is_likely_pdf ? 120 : 30, |
| 8616 | 7282 | 'redirection' => 5, |
| 8617 | - 'user-agent' => mxchat_ingest_user_agent(), | |
| 7283 | + 'user-agent' => 'MxChat/1.0' | |
| 8618 | 7284 | )); |
| 8619 | 7285 | |
| 8620 | 7286 | if (is_wp_error($response)) { |
| 8621 | 7287 | return $response; |
| @@ -8780,9 +7446,8 @@ | ||
| 8780 | 7446 | |
| 8781 | 7447 | for ($i = 0; $i < $total_pages; $i++) { |
| 8782 | 7448 | $page_num = $i + 1; |
| 8783 | 7449 | $text = $pages[$i]->getText(); |
| 8784 | - $text = MxChat_Utils::normalize_pdf_rtl($text, 'kb_pdf_import page ' . $page_num); | |
| 8785 | 7450 | if (empty($text)) { |
| 8786 | 7451 | $skipped_pages[] = 'Page ' . $page_num . ': No text could be extracted — page may contain only images, links, or non-standard encoding'; |
| 8787 | 7452 | continue; |
| 8788 | 7453 | } |
| @@ -8875,8 +7540,16 @@ | ||
| 8875 | 7540 | $description = $product->get_description(); |
| 8876 | 7541 | $short_description = $product->get_short_description(); |
| 8877 | 7542 | $sku = $product->get_sku(); |
| 8878 | 7543 | |
| 7544 | + // Get pricing information | |
| 7545 | + $regular_price = $product->get_regular_price(); | |
| 7546 | + $sale_price = $product->get_sale_price(); | |
| 7547 | + $price = $product->get_price(); // Current active price | |
| 7548 | + | |
| 7549 | + // Get currency symbol | |
| 7550 | + $currency_symbol = get_woocommerce_currency_symbol(); | |
| 7551 | + | |
| 8879 | 7552 | // Format content |
| 8880 | 7553 | $content = $title . "\n\n"; |
| 8881 | 7554 | |
| 8882 | 7555 | if (!empty($short_description)) { |
| @@ -8886,11 +7559,28 @@ | ||
| 8886 | 7559 | if (!empty($description)) { |
| 8887 | 7560 | $content .= wp_strip_all_tags($description) . "\n\n"; |
| 8888 | 7561 | } |
| 8889 | 7562 | |
| 8890 | - // Add pricing information (base-currency pinned — see mxchat_product_price_lines) | |
| 8891 | - $content .= $this->mxchat_product_price_lines($product); | |
| 7563 | + // Add pricing information | |
| 7564 | + if (!empty($regular_price)) { | |
| 7565 | + $content .= "Price: " . $currency_symbol . $regular_price . "\n"; | |
| 7566 | + } elseif (!empty($price)) { | |
| 7567 | + $content .= "Price: " . $currency_symbol . $price . "\n"; | |
| 7568 | + } | |
| 8892 | 7569 | |
| 7570 | + if (!empty($sale_price) && $sale_price !== $regular_price) { | |
| 7571 | + $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n"; | |
| 7572 | + } | |
| 7573 | + | |
| 7574 | + // Handle variable products - show price range | |
| 7575 | + if ($product->is_type('variable')) { | |
| 7576 | + $min_price = $product->get_variation_price('min'); | |
| 7577 | + $max_price = $product->get_variation_price('max'); | |
| 7578 | + if ($min_price !== $max_price) { | |
| 7579 | + $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n"; | |
| 7580 | + } | |
| 7581 | + } | |
| 7582 | + | |
| 8893 | 7583 | if (!empty($sku)) { |
| 8894 | 7584 | $content .= "SKU: " . $sku . "\n"; |
| 8895 | 7585 | } |
| 8896 | 7586 | |
| @@ -8962,9 +7652,8 @@ | ||
| 8962 | 7652 | return new WP_Error('page_not_found', 'Page ' . $page_number . ' not found in PDF'); |
| 8963 | 7653 | } |
| 8964 | 7654 | |
| 8965 | 7655 | $text = $pages[$page_number - 1]->getText(); |
| 8966 | - $text = MxChat_Utils::normalize_pdf_rtl($text, 'kb_pdf_page page ' . $page_number); | |
| 8967 | 7656 | |
| 8968 | 7657 | if (empty($text)) { |
| 8969 | 7658 | return new WP_Error('empty_page', 'Page ' . $page_number . ': No text could be extracted — page may contain only images, links, or non-standard encoding'); |
| 8970 | 7659 | } |
| @@ -8985,18 +7674,24 @@ | ||
| 8985 | 7674 | |
| 8986 | 7675 | $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized; |
| 8987 | 7676 | $page_url = esc_url($pdf_url . "#page=" . $page_number); |
| 8988 | 7677 | |
| 8989 | - // Get bot-specific embedding decision — custom-provider-aware | |
| 8990 | - // (plan cbd5fd). Error code preserved. | |
| 7678 | + // Get bot-specific API key | |
| 8991 | 7679 | $bot_options = $this->get_bot_options($bot_id); |
| 8992 | 7680 | $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options'); |
| 8993 | - | |
| 8994 | - $preflight = MxChat_Utils::embedding_preflight($options); | |
| 8995 | - if (!$preflight['ok']) { | |
| 8996 | - return new WP_Error('no_api_key', $preflight['reason'] . ' (bot: ' . $bot_id . ')'); | |
| 7681 | + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002'; | |
| 7682 | + | |
| 7683 | + if (strpos($selected_model, 'voyage') === 0) { | |
| 7684 | + $api_key = $options['voyage_api_key'] ?? ''; | |
| 7685 | + } elseif (strpos($selected_model, 'gemini-embedding') === 0) { | |
| 7686 | + $api_key = $options['gemini_api_key'] ?? ''; | |
| 7687 | + } else { | |
| 7688 | + $api_key = $options['api_key'] ?? ''; | |
| 8997 | 7689 | } |
| 8998 | - $api_key = $preflight['api_key']; | |
| 7690 | + | |
| 7691 | + if (empty($api_key)) { | |
| 7692 | + return new WP_Error('no_api_key', 'API key not configured for bot: ' . $bot_id); | |
| 7693 | + } | |
| 8999 | 7694 | |
| 9000 | 7695 | // Submit to database - UPDATED 2.5.6: Added content_type 'pdf' |
| 9001 | 7696 | $result = MxChat_Utils::submit_content_to_db( |
| 9002 | 7697 | $content_with_metadata, |