PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.1.8
MxChat – AI Chatbot & Content Generation for WordPress v3.1.8
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
← All changes | admin/class-knowledge-manager.php +387 -1692 3.2.193.1.8 View file →
@@ -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 `&amp;` 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;amp;` is a
1715 - * legitimate literal `&amp;` 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 - // `&amp;` 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,