PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / trunk
MxChat – AI Chatbot & Content Generation for WordPress vtrunk
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 +2288 -686 3.2.2trunk View file →
@@ -9,11 +9,27 @@
9 9 exit; // Exit if accessed directly
10 10 }
11 11
12 12 class MxChat_Knowledge_Manager {
13 -
13 +
14 14 private $options;
15 -
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 +
16 32 /**
17 33 * Constructor - Register hooks for content processing
18 34 */
19 35 public function __construct() {
@@ -31,8 +47,10 @@
31 47 // Admin post handlers for form submissions
32 48 add_action('admin_post_mxchat_submit_content', array($this, 'mxchat_handle_content_submission'));
33 49 add_action('admin_post_mxchat_submit_sitemap', array($this, 'mxchat_handle_sitemap_submission'));
34 50 add_action('admin_post_mxchat_submit_pdf_file', array($this, 'mxchat_handle_pdf_file_submission'));
51 + add_action('admin_post_mxchat_submit_document_file', array($this, 'mxchat_handle_document_file_submission'));
52 + add_action('admin_post_mxchat_submit_youtube', array($this, 'mxchat_handle_youtube_submission'));
35 53 add_action('admin_post_mxchat_stop_processing', array($this, 'mxchat_stop_processing'));
36 54
37 55 // AJAX handlers for real-time processing and status updates
38 56 add_action('wp_ajax_mxchat_get_status_updates', array($this, 'mxchat_ajax_get_status_updates'));
@@ -58,8 +76,9 @@
58 76 add_action('wp_ajax_mxchat_refresh_pinecone_entries', array($this, 'ajax_mxchat_refresh_pinecone_entries'));
59 77 add_action('wp_ajax_mxchat_paginate_entries', array($this, 'ajax_mxchat_paginate_entries'));
60 78 add_action('wp_ajax_mxchat_get_entry_content', array($this, 'ajax_mxchat_get_entry_content'));
61 79 add_action('wp_ajax_mxchat_save_entry_content', array($this, 'ajax_mxchat_save_entry_content'));
80 + add_action('wp_ajax_mxchat_inspect_entry', array($this, 'ajax_mxchat_inspect_entry'));
62 81
63 82 // WordPress post management hooks
64 83 add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
65 84 add_action('post_updated', array($this, 'mxchat_handle_post_update'), 10, 3);
@@ -64,13 +83,27 @@
64 83 add_action('pre_post_update', array($this, 'mxchat_store_pre_update_status'), 10, 2);
65 84 add_action('post_updated', array($this, 'mxchat_handle_post_update'), 10, 3);
66 85 add_action('before_delete_post', array($this, 'mxchat_handle_post_delete'));
67 86 add_action('wp_trash_post', array($this, 'mxchat_handle_post_delete'));
87 + // Authoritative unpublish detection: core hands this hook the REAL previous status, so
88 + // removal no longer depends on the mxchat_prev_status_* transients (evictable by persistent
89 + // object caches, never written by paths that bypass wp_update_post, e.g. plugins flipping
90 + // post_status directly and calling wp_transition_post_status themselves).
91 + add_action('transition_post_status', array($this, 'mxchat_handle_status_transition'), 10, 3);
68 92
69 93 // ACF hook - fires AFTER ACF fields are saved, ensuring ACF data is available
70 94 // Priority 20 to run after ACF's own save (which runs at priority 10)
71 95 add_action('acf/save_post', array($this, 'mxchat_handle_acf_save'), 20);
72 96
97 + // One-time cleanup for vectors orphaned by unpublishes that predate the
98 + // transition_post_status handler (plan 816fb1): wp mxchat prune-unpublished
99 + if (defined('WP_CLI') && WP_CLI) {
100 + WP_CLI::add_command('mxchat prune-unpublished', array($this, 'cli_prune_unpublished'));
101 + // In-place repair for RTL KB rows imported in visual order before the
102 + // 32bf9e normalizer existed: wp mxchat rtl-repair (plan d1e6f7)
103 + WP_CLI::add_command('mxchat rtl-repair', array($this, 'cli_rtl_repair'));
104 + }
105 +
73 106 add_action('wp_ajax_mxchat_mark_queue_complete', array($this, 'ajax_mxchat_mark_queue_complete'));
74 107
75 108 // WooCommerce product hooks (if WooCommerce is active)
76 109 if (class_exists('WooCommerce')) {
@@ -118,26 +151,17 @@
118 151
119 152 // Get bot-specific options and API key
120 153 $bot_options = $this->get_bot_options($bot_id);
121 154 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
122 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
123 -
124 - if (strpos($selected_model, 'voyage') === 0) {
125 - $api_key = $options['voyage_api_key'] ?? '';
126 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
127 - $api_key = $options['gemini_api_key'] ?? '';
128 - } else {
129 - $api_key = $options['api_key'] ?? '';
130 - }
131 -
132 - if (empty($api_key)) {
133 - set_transient('mxchat_admin_notice_error',
134 - esc_html__('API key is not configured. Please add your API key in the settings before submitting content.', 'mxchat'),
135 - 30
136 - );
155 +
156 + // Custom-provider-aware decision; keyless custom sites must pass (plan cbd5fd).
157 + $preflight = MxChat_Utils::embedding_preflight($options);
158 + if (!$preflight['ok']) {
159 + set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30);
137 160 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
138 161 exit;
139 162 }
163 + $api_key = $preflight['api_key'];
140 164
141 165 // Use centralized utility function with bot_id
142 166 $result = MxChat_Utils::submit_content_to_db($article_content, $article_url, $api_key, null, $bot_id);
143 167
@@ -156,8 +180,238 @@
156 180 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
157 181 exit;
158 182 }
159 183
184 +/**
185 + * Handle the "YouTube" KB import source (admin-post form submission).
186 + *
187 + * Per-video description mode:
188 + * - auto: fetch oEmbed metadata (reliable) + best-effort captions transcript.
189 + * If no usable transcript, index the metadata anyway, tell the admin,
190 + * and bounce back with the manual box pre-filled (never fail silently).
191 + * - manual: the admin's own description is what gets indexed; metadata rides along.
192 + *
193 + * The row is stored with content_type 'youtube' and source_url = the canonical
194 + * watch URL, so re-importing the same video UPDATES the entry (source_url
195 + * duplicate handling in MxChat_Utils::store_in_wordpress_db) — that is also the
196 + * "augment a metadata-only entry" path.
197 + */
198 +public function mxchat_handle_youtube_submission() {
199 + if (!isset($_POST['submit_youtube']) || !current_user_can('manage_options')) {
200 + wp_die(esc_html__('Unauthorized access', 'mxchat'));
201 + }
202 +
203 + check_admin_referer('mxchat_submit_youtube_action', 'mxchat_submit_youtube_nonce');
204 +
205 + $redirect_url = admin_url('admin.php?page=mxchat-prompts');
206 +
207 + $youtube_url = isset($_POST['youtube_url']) ? esc_url_raw(wp_unslash($_POST['youtube_url'])) : '';
208 + $video_id = MxChat_Utils::parse_youtube_id($youtube_url);
209 +
210 + if (empty($video_id)) {
211 + set_transient('mxchat_admin_notice_error',
212 + esc_html__('That does not look like a link to a single YouTube video. Please paste a watch, youtu.be, or Shorts URL.', 'mxchat'),
213 + 30
214 + );
215 + wp_safe_redirect(esc_url($redirect_url));
216 + exit;
217 + }
218 +
219 + $canonical_url = 'https://www.youtube.com/watch?v=' . $video_id;
220 +
221 + $description_mode = (isset($_POST['youtube_description_mode']) && $_POST['youtube_description_mode'] === 'manual') ? 'manual' : 'auto';
222 + $manual_description = isset($_POST['youtube_description']) ? trim(wp_kses_post(wp_unslash($_POST['youtube_description']))) : '';
223 +
224 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
225 +
226 + // Resolve the embedding decision exactly like the sibling handlers —
227 + // custom-provider-aware (plan cbd5fd).
228 + $bot_options = $this->get_bot_options($bot_id);
229 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
230 +
231 + $preflight = MxChat_Utils::embedding_preflight($options);
232 + if (!$preflight['ok']) {
233 + set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30);
234 + wp_safe_redirect(esc_url($redirect_url));
235 + exit;
236 + }
237 + $api_key = $preflight['api_key'];
238 +
239 + // Metadata is fetched in BOTH modes — it is the reliable half of auto, and in
240 + // manual mode it enriches the indexed text with the real title/channel.
241 + $meta = $this->mxchat_fetch_youtube_oembed($video_id);
242 + $video_title = isset($meta['title']) ? sanitize_text_field($meta['title']) : '';
243 + $video_channel = isset($meta['author_name']) ? sanitize_text_field($meta['author_name']) : '';
244 +
245 + $header_lines = 'YouTube Video: ' . ($video_title !== '' ? $video_title : $canonical_url) . "\n";
246 + if ($video_channel !== '') {
247 + $header_lines .= 'Channel: ' . $video_channel . "\n";
248 + }
249 + $header_lines .= 'URL: ' . $canonical_url . "\n\n";
250 +
251 + $transcript_missing = false;
252 +
253 + if ($description_mode === 'manual') {
254 + if ($manual_description === '') {
255 + set_transient('mxchat_admin_notice_error',
256 + esc_html__('Please write a description for the video, or switch to auto-fetch.', 'mxchat'),
257 + 30
258 + );
259 + wp_safe_redirect(esc_url($redirect_url));
260 + exit;
261 + }
262 + $indexed_text = $header_lines . $manual_description;
263 + } else {
264 + $transcript = $this->mxchat_fetch_youtube_transcript($video_id);
265 +
266 + if (strlen($transcript) >= 200) {
267 + $indexed_text = $header_lines . $transcript;
268 + } else {
269 + // Graceful fallback: captions disabled / blocked / no speech. Auto
270 + // reliably gets metadata; it does NOT guarantee a transcript.
271 + $transcript_missing = true;
272 +
273 + if ($video_title === '' && $video_channel === '') {
274 + // Both halves failed — nothing meaningful to index.
275 + set_transient('mxchat_admin_notice_error',
276 + 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'),
277 + 30
278 + );
279 + wp_safe_redirect(esc_url($redirect_url));
280 + exit;
281 + }
282 +
283 + $indexed_text = $header_lines . sprintf(
284 + /* translators: 1: video title, 2: channel name */
285 + __('A YouTube video titled "%1$s" from the channel %2$s.', 'mxchat'),
286 + $video_title !== '' ? $video_title : $canonical_url,
287 + $video_channel !== '' ? $video_channel : 'YouTube'
288 + );
289 + }
290 + }
291 +
292 + $result = MxChat_Utils::submit_content_to_db($indexed_text, $canonical_url, $api_key, null, $bot_id, 'youtube');
293 +
294 + if (is_wp_error($result)) {
295 + set_transient('mxchat_admin_notice_error',
296 + esc_html__('Error storing video in the knowledge base: ', 'mxchat') . $result->get_error_message(),
297 + 30
298 + );
299 + wp_safe_redirect(esc_url($redirect_url));
300 + exit;
301 + }
302 +
303 + if ($transcript_missing) {
304 + set_transient('mxchat_admin_notice_success',
305 + 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'),
306 + 30
307 + );
308 + // Bounce back with prefill args so the page reopens the YouTube form in
309 + // manual mode with the URL + fetched title ready to augment.
310 + $redirect_url = add_query_arg(array(
311 + 'mxchat_yt_prefill' => '1',
312 + 'yt_url' => rawurlencode($canonical_url),
313 + 'yt_title' => rawurlencode($video_title),
314 + ), $redirect_url);
315 + } else {
316 + set_transient('mxchat_admin_notice_success',
317 + esc_html__('YouTube video successfully added to the knowledge base!', 'mxchat'),
318 + 30
319 + );
320 + }
321 +
322 + wp_safe_redirect(esc_url_raw($redirect_url));
323 + exit;
324 +}
325 +
326 +/**
327 + * Fetch YouTube oEmbed metadata for a video (no API key required).
328 + * Returns the decoded array (title, author_name, thumbnail_url, ...) or array().
329 + */
330 +private function mxchat_fetch_youtube_oembed($video_id) {
331 + $oembed_url = 'https://www.youtube.com/oembed?url=' . rawurlencode('https://www.youtube.com/watch?v=' . $video_id) . '&format=json';
332 + $response = wp_remote_get($oembed_url, array('timeout' => 15));
333 + if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
334 + return array();
335 + }
336 + $data = json_decode(wp_remote_retrieve_body($response), true);
337 + return is_array($data) ? $data : array();
338 +}
339 +
340 +/**
341 + * Best-effort captions transcript for a video. Deliberately ISOLATED: this uses
342 + * YouTube's unofficial timedtext route (the caption track list embedded in the
343 + * watch page), which YouTube has broken before and will break again. Every
344 + * failure mode returns '' so a break degrades to the metadata-only import path
345 + * instead of erroring the whole submission. Do not let anything in here throw.
346 + */
347 +private function mxchat_fetch_youtube_transcript($video_id) {
348 + $watch_url = 'https://www.youtube.com/watch?v=' . $video_id . '&hl=en';
349 +
350 + // First try the honest ingest UA; some responses omit the player config for
351 + // bot UAs, so retry once with a browser UA before giving up.
352 + $user_agents = array(
353 + mxchat_ingest_user_agent(),
354 + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
355 + );
356 +
357 + $tracks = array();
358 + foreach ($user_agents as $ua) {
359 + $response = wp_remote_get($watch_url, array(
360 + 'timeout' => 20,
361 + 'user-agent' => $ua,
362 + ));
363 + if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
364 + continue;
365 + }
366 + $body = wp_remote_retrieve_body($response);
367 + if (!is_string($body) || $body === '' || !preg_match('/"captionTracks":(\[.*?\])(?=,")/s', $body, $m)) {
368 + continue;
369 + }
370 + $decoded = json_decode($m[1], true);
371 + if (is_array($decoded) && !empty($decoded)) {
372 + $tracks = $decoded;
373 + break;
374 + }
375 + }
376 +
377 + if (empty($tracks)) {
378 + return '';
379 + }
380 +
381 + // Prefer an English track, else take the first offered.
382 + $chosen = null;
383 + foreach ($tracks as $track) {
384 + if (isset($track['languageCode']) && strpos($track['languageCode'], 'en') === 0) {
385 + $chosen = $track;
386 + break;
387 + }
388 + }
389 + if ($chosen === null) {
390 + $chosen = $tracks[0];
391 + }
392 + if (empty($chosen['baseUrl']) || !is_string($chosen['baseUrl'])) {
393 + return '';
394 + }
395 +
396 + $timedtext = wp_remote_get($chosen['baseUrl'], array('timeout' => 20));
397 + if (is_wp_error($timedtext) || wp_remote_retrieve_response_code($timedtext) !== 200) {
398 + return '';
399 + }
400 + $xml = wp_remote_retrieve_body($timedtext);
401 + if (!is_string($xml) || strpos($xml, '<text') === false) {
402 + return '';
403 + }
404 +
405 + // <text start=".." dur="..">caption</text> — strip tags, decode the
406 + // double-encoded entities timedtext ships, collapse whitespace.
407 + $text = preg_replace('/<[^>]+>/', ' ', $xml);
408 + $text = html_entity_decode(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
409 + $text = trim(preg_replace('/\s+/u', ' ', $text));
410 +
411 + return $text;
412 +}
413 +
160 414 public function mxchat_is_pdf_url($url, $response) {
161 415 $content_type = wp_remote_retrieve_header($response, 'content-type');
162 416 $file_extension = strtolower(pathinfo($url, PATHINFO_EXTENSION));
163 417
@@ -403,8 +657,160 @@
403 657 exit;
404 658 }
405 659
406 660 /**
661 + * Handle direct document upload (.docx / .txt / .md) from the knowledge base
662 + * page (plan 0485e5). Unlike PDF Upload there is no per-page queue: the text
663 + * extracts in one pass and routes through submit_content_to_db, whose chunker
664 + * takes over for long content. The uploaded file is read from the PHP temp
665 + * file and never persisted — only its extracted text enters the KB.
666 + *
667 + * Source identity matches PDF Upload's scheme: upload://<filename>, stable
668 + * across re-uploads so a re-import REPLACES (delete_chunks_for_url + upsert
669 + * per identity) instead of duplicating.
670 + */
671 +public function mxchat_handle_document_file_submission() {
672 + if (!isset($_POST['submit_document_file']) || !current_user_can('manage_options')) {
673 + wp_die(esc_html__('Unauthorized access', 'mxchat'));
674 + }
675 +
676 + check_admin_referer('mxchat_submit_document_file_action', 'mxchat_submit_document_file_nonce');
677 +
678 + $redirect_url = admin_url('admin.php?page=mxchat-prompts');
679 +
680 + if (empty($_FILES['document_file']) || $_FILES['document_file']['error'] !== UPLOAD_ERR_OK) {
681 + $error_code = isset($_FILES['document_file']['error']) ? $_FILES['document_file']['error'] : UPLOAD_ERR_NO_FILE;
682 + $error_messages = array(
683 + UPLOAD_ERR_INI_SIZE => __('The uploaded file exceeds the server upload_max_filesize limit.', 'mxchat'),
684 + UPLOAD_ERR_FORM_SIZE => __('The uploaded file exceeds the form MAX_FILE_SIZE limit.', 'mxchat'),
685 + UPLOAD_ERR_PARTIAL => __('The file was only partially uploaded.', 'mxchat'),
686 + UPLOAD_ERR_NO_FILE => __('No file was uploaded. Please select a document.', 'mxchat'),
687 + UPLOAD_ERR_NO_TMP_DIR => __('Server missing temporary folder.', 'mxchat'),
688 + UPLOAD_ERR_CANT_WRITE => __('Server failed to write file to disk.', 'mxchat'),
689 + );
690 + $error_msg = isset($error_messages[$error_code]) ? $error_messages[$error_code] : __('Unknown upload error.', 'mxchat');
691 + set_transient('mxchat_admin_notice_error', $error_msg, 30);
692 + wp_safe_redirect(esc_url($redirect_url));
693 + exit;
694 + }
695 +
696 + $file = $_FILES['document_file'];
697 + $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
698 +
699 + $finfo = finfo_open(FILEINFO_MIME_TYPE);
700 + $mime_type = finfo_file($finfo, $file['tmp_name']);
701 + finfo_close($finfo);
702 +
703 + // Per-extension MIME expectations. finfo commonly reports .docx as
704 + // application/zip (it IS a Zip container) and .md as plain text.
705 + $mime_ok = false;
706 + if ($ext === 'docx') {
707 + $mime_ok = in_array($mime_type, array(
708 + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
709 + 'application/zip',
710 + ), true);
711 + } elseif ($ext === 'txt' || $ext === 'md') {
712 + $mime_ok = (strpos((string) $mime_type, 'text/') === 0);
713 + }
714 +
715 + if (!$mime_ok) {
716 + set_transient('mxchat_admin_notice_error',
717 + esc_html__('Invalid or unreadable document. Accepted types: .docx, .txt, .md.', 'mxchat'),
718 + 30
719 + );
720 + wp_safe_redirect(esc_url($redirect_url));
721 + exit;
722 + }
723 +
724 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
725 + $original_filename = sanitize_file_name($file['name']);
726 +
727 + // ---- Extract text (ONE extractor for .docx — the word handler's) ----
728 + if ($ext === 'docx') {
729 + $text = MXChat_Word_Handler::extract_docx_text($file['tmp_name']);
730 + if ($text === false) {
731 + set_transient('mxchat_admin_notice_error',
732 + esc_html__('The .docx file could not be read. It may be corrupt, empty, or not a real Word document.', 'mxchat'),
733 + 30
734 + );
735 + wp_safe_redirect(esc_url($redirect_url));
736 + exit;
737 + }
738 + } else {
739 + // .txt / .md read as-is. Markdown keeps its syntax on purpose —
740 + // headings are useful retrieval signal.
741 + $text = (string) file_get_contents($file['tmp_name']);
742 + $text = wp_check_invalid_utf8($text);
743 + $text = trim($text);
744 + }
745 +
746 + if ($text === '') {
747 + set_transient('mxchat_admin_notice_error',
748 + esc_html__('The uploaded document contains no readable text.', 'mxchat'),
749 + 30
750 + );
751 + wp_safe_redirect(esc_url($redirect_url));
752 + exit;
753 + }
754 +
755 + // Size cap — same pdf_max_pages setting the PDF/toolbar paths use, but
756 + // estimated by CHARACTERS (~2500/page): the .docx cleaner collapses all
757 + // newlines to spaces, so a paragraph count reads 1 for any Word file.
758 + // Processing is synchronous — an unbounded document risks a timeout.
759 + $options = get_option('mxchat_options', array());
760 + $max_pages = isset($options['pdf_max_pages']) ? intval($options['pdf_max_pages']) : 69;
761 + $estimated_pages = (int) ceil(strlen($text) / 2500);
762 + if ($estimated_pages > $max_pages) {
763 + set_transient('mxchat_admin_notice_error',
764 + sprintf(
765 + esc_html__('The document is too large (about %1$d pages; the limit is %2$d). Split it into smaller files, or raise the PDF max pages setting.', 'mxchat'),
766 + $estimated_pages,
767 + $max_pages
768 + ),
769 + 30
770 + );
771 + wp_safe_redirect(esc_url($redirect_url));
772 + exit;
773 + }
774 +
775 + // Embedding API key — bot-aware, same shape as the direct-content handler.
776 + $api_key = '';
777 + if ($bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager')) {
778 + $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
779 + $api_key = $bot_options['api_key'] ?? '';
780 + }
781 + if (empty($api_key)) {
782 + $api_key = $options['api_key'] ?? '';
783 + }
784 +
785 + // Stable identity — PDF Upload's scheme. A re-upload of the same filename
786 + // replaces: clear old chunks first (covers a doc shrinking below the chunk
787 + // threshold, where the single-vector path would not clean them), then
788 + // submit — the chunked path re-deletes harmlessly.
789 + $source_label = 'upload://' . $original_filename;
790 + MxChat_Utils::delete_chunks_for_url($source_label, $bot_id);
791 + $result = MxChat_Utils::submit_content_to_db($text, $source_label, $api_key, null, $bot_id, 'document');
792 +
793 + if (is_wp_error($result)) {
794 + set_transient('mxchat_admin_notice_error',
795 + esc_html__('Failed to import the document: ', 'mxchat') . esc_html($result->get_error_message()),
796 + 30
797 + );
798 + } else {
799 + set_transient('mxchat_admin_notice_success',
800 + sprintf(
801 + esc_html__('Document "%s" imported into the knowledge base.', 'mxchat'),
802 + esc_html($original_filename)
803 + ),
804 + 30
805 + );
806 + }
807 +
808 + wp_safe_redirect(esc_url($redirect_url));
809 + exit;
810 +}
811 +
812 +/**
407 813 * Validate PDF and count pages with multiple parser attempts
408 814 */
409 815 private function mxchat_validate_and_count_pdf_pages($pdf_path) {
410 816 // Method 1: Try with Smalot PDF Parser (your current method)
@@ -627,8 +1033,25 @@
627 1033 /**
628 1034 * AJAX: Get full content for editing — reassembles chunks if needed.
629 1035 * Works for both WordPress DB and Pinecone entries.
630 1036 */
1037 +/**
1038 + * Sanitize a knowledge entry's source_url from an AJAX request WITHOUT destroying
1039 + * its identity. sanitize_text_field() strips percent-encoded octets (%20, %D7%A9…),
1040 + * so a percent-encoded URL — every non-ASCII permalink — would md5 to a DIFFERENT
1041 + * id than the one it was stored under: reads miss the entry and saves write an
1042 + * orphan copy while the original keeps its stale text. URLs get esc_url_raw
1043 + * (identity-preserving, matches what import stored); non-URL keys (mxchat://,
1044 + * _ungrouped_) keep the old sanitizer.
1045 + */
1046 +private function sanitize_entry_source_url( $raw ) {
1047 + $raw = trim( (string) $raw );
1048 + if ( preg_match( '#^https?://#i', $raw ) ) {
1049 + return esc_url_raw( $raw );
1050 + }
1051 + return sanitize_text_field( $raw );
1052 +}
1053 +
631 1054 public function ajax_mxchat_get_entry_content() {
632 1055 check_ajax_referer('mxchat_edit_entry_nonce', 'nonce');
633 1056
634 1057 if ( ! current_user_can('manage_options') ) {
@@ -634,16 +1057,19 @@
634 1057 if ( ! current_user_can('manage_options') ) {
635 1058 wp_send_json_error( array( 'message' => 'Permission denied.' ) );
636 1059 }
637 1060
638 - $source_url = isset($_POST['source_url']) ? sanitize_text_field( wp_unslash($_POST['source_url']) ) : '';
1061 + $source_url = $this->sanitize_entry_source_url( isset($_POST['source_url']) ? wp_unslash($_POST['source_url']) : '' );
639 1062 $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
640 1063 $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
641 1064 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
642 1065
643 1066 if ( $data_source === 'pinecone' ) {
1067 + // Pinecone ids are strings (md5 hashes, manual_* ids) — absint() would
1068 + // destroy them, so re-read the raw value for this branch only.
1069 + $vector_id = isset($_POST['entry_id']) ? sanitize_text_field( wp_unslash($_POST['entry_id']) ) : '';
644 1070 // Pinecone: fetch vectors by source_url, reassemble chunks
645 - $content = $this->get_pinecone_entry_content( $source_url, $entry_id, $bot_id );
1071 + $content = $this->get_pinecone_entry_content( $source_url, $vector_id, $bot_id );
646 1072 } else {
647 1073 // WordPress DB
648 1074 $content = $this->get_wordpress_entry_content( $source_url, $entry_id );
649 1075 }
@@ -742,44 +1168,56 @@
742 1168 if ( empty($host) || empty($api_key) ) {
743 1169 return new WP_Error( 'pinecone_config', 'Pinecone not configured.' );
744 1170 }
745 1171
746 - // List vectors with the source_url prefix
747 - $base_id = md5( $source_url );
748 - $vector_ids = array( $base_id );
1172 + // Manual entries carry no source_url (their vector id is a minted manual_* string,
1173 + // not md5 of anything the row can hand us) — fetch the exact vector instead.
1174 + // '_ungrouped_' is the table view's synthetic display key for such rows.
1175 + if ( ( empty($source_url) || strpos($source_url, '_ungrouped_') === 0 ) && ! empty($entry_id) && is_string($entry_id) ) {
1176 + $vector_ids = array( $entry_id );
1177 + } else {
1178 + // List vectors with the source_url prefix
1179 + $base_id = md5( $source_url );
1180 + $vector_ids = array( $base_id );
749 1181
750 - // Find chunk vectors
751 - $list_url = "https://{$host}/vectors/list";
752 - $list_body = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 );
753 - if ( ! empty($namespace) ) {
754 - $list_body['namespace'] = $namespace;
755 - }
1182 + // Find chunk vectors
1183 + // NOTE: Pinecone's /vectors/list is a GET endpoint with query parameters.
1184 + // A POST is answered 200-with-an-empty-body, which reads as "no vectors".
1185 + $list_url = "https://{$host}/vectors/list";
1186 + $list_params = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 );
1187 + if ( ! empty($namespace) ) {
1188 + $list_params['namespace'] = $namespace;
1189 + }
756 1190
757 - $list_resp = wp_remote_post( $list_url, array(
758 - 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ),
759 - 'body' => wp_json_encode( $list_body ),
760 - 'timeout' => 15,
761 - ) );
1191 + $list_resp = wp_remote_get( $list_url . '?' . http_build_query( $list_params ), array(
1192 + 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
1193 + 'timeout' => 15,
1194 + ) );
762 1195
763 - if ( ! is_wp_error($list_resp) ) {
764 - $list_data = json_decode( wp_remote_retrieve_body($list_resp), true );
765 - if ( ! empty($list_data['vectors']) ) {
766 - foreach ( $list_data['vectors'] as $v ) {
767 - $vector_ids[] = $v['id'];
1196 + if ( ! is_wp_error($list_resp) ) {
1197 + $list_data = json_decode( wp_remote_retrieve_body($list_resp), true );
1198 + if ( ! empty($list_data['vectors']) ) {
1199 + foreach ( $list_data['vectors'] as $v ) {
1200 + $vector_ids[] = $v['id'];
1201 + }
768 1202 }
769 1203 }
770 1204 }
771 1205
772 1206 // Fetch vectors with metadata
773 - $fetch_url = "https://{$host}/vectors/fetch";
774 - $fetch_body = array( 'ids' => $vector_ids );
1207 + // NOTE: /vectors/fetch is a GET endpoint too, and Pinecone expects the ids
1208 + // repeated (ids=a&ids=b) — http_build_query would emit ids[0]=a, so build
1209 + // the query string explicitly.
1210 + $fetch_query = array();
1211 + foreach ( $vector_ids as $fetch_vid ) {
1212 + $fetch_query[] = 'ids=' . rawurlencode( $fetch_vid );
1213 + }
775 1214 if ( ! empty($namespace) ) {
776 - $fetch_body['namespace'] = $namespace;
1215 + $fetch_query[] = 'namespace=' . rawurlencode( $namespace );
777 1216 }
778 1217
779 - $fetch_resp = wp_remote_post( $fetch_url, array(
780 - 'headers' => array( 'Api-Key' => $api_key, 'Content-Type' => 'application/json' ),
781 - 'body' => wp_json_encode( $fetch_body ),
1218 + $fetch_resp = wp_remote_get( "https://{$host}/vectors/fetch?" . implode( '&', $fetch_query ), array(
1219 + 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
782 1220 'timeout' => 15,
783 1221 ) );
784 1222
785 1223 if ( is_wp_error($fetch_resp) ) {
@@ -814,8 +1252,235 @@
814 1252 );
815 1253 }
816 1254
817 1255 /**
1256 + * AJAX: Inspect a knowledge entry — returns the per-chunk stored text + metadata
1257 + * WITHOUT collapsing it, so a site owner can see exactly what was indexed for an
1258 + * entry (plan-mxchat-20260628-d8cb4b). READ-ONLY: never re-embeds or mutates.
1259 + */
1260 +public function ajax_mxchat_inspect_entry() {
1261 + check_ajax_referer('mxchat_inspect_entry_nonce', 'nonce');
1262 +
1263 + if ( ! current_user_can('manage_options') ) {
1264 + wp_send_json_error( array( 'message' => esc_html__('Permission denied.', 'mxchat') ) );
1265 + }
1266 +
1267 + $source_url = isset($_POST['source_url']) ? sanitize_text_field( wp_unslash($_POST['source_url']) ) : '';
1268 + $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
1269 + $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
1270 + $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1271 +
1272 + if ( $data_source === 'pinecone' ) {
1273 + $result = $this->inspect_pinecone_entry( $source_url, $entry_id, $bot_id );
1274 + } else {
1275 + $result = $this->inspect_wordpress_entry( $source_url, $entry_id );
1276 + }
1277 +
1278 + if ( is_wp_error( $result ) ) {
1279 + wp_send_json_error( array( 'message' => $result->get_error_message() ) );
1280 + }
1281 +
1282 + wp_send_json_success( $result );
1283 +}
1284 +
1285 +/**
1286 + * Read-only inspector for WordPress-DB entries. Mirrors get_wordpress_entry_content()
1287 + * but returns each STORED chunk's exact text + length (no implode), plus the assembled
1288 + * embedded text. This shows what is actually in the index, not a re-derivation from the post.
1289 + */
1290 +private function inspect_wordpress_entry( $source_url, $entry_id ) {
1291 + global $wpdb;
1292 + $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1293 +
1294 + $rows = array();
1295 +
1296 + // Group by the real stored source_url — this INCLUDES "mxchat://" manual
1297 + // Direct Content entries (the spec's manual-entry case), which share one
1298 + // source_url across their chunk rows. Only the synthetic "_ungrouped_<id>"
1299 + // display key (invented by the table view for rows with no source_url) is
1300 + // excluded; those fall through to the entry_id lookup below.
1301 + if ( ! empty( $source_url ) && strpos( $source_url, '_ungrouped_' ) !== 0 ) {
1302 + $rows = $wpdb->get_results( $wpdb->prepare(
1303 + "SELECT id, article_content, source_url, content_type FROM {$table} WHERE source_url = %s ORDER BY id ASC",
1304 + $source_url
1305 + ) );
1306 + }
1307 +
1308 + // Fallback / manual "Direct Content" entries: fetch the single row by id.
1309 + if ( empty( $rows ) && $entry_id > 0 ) {
1310 + $row = $wpdb->get_row( $wpdb->prepare(
1311 + "SELECT id, article_content, source_url, content_type FROM {$table} WHERE id = %d",
1312 + $entry_id
1313 + ) );
1314 + if ( $row ) {
1315 + $rows = array( $row );
1316 + }
1317 + }
1318 +
1319 + if ( empty( $rows ) ) {
1320 + return new WP_Error( 'not_found', esc_html__('Entry not found in the local knowledge database.', 'mxchat') );
1321 + }
1322 +
1323 + $chunks = array();
1324 + $content_type = '';
1325 + foreach ( $rows as $row ) {
1326 + $parsed = MxChat_Chunker::parse_stored_chunk( $row->article_content );
1327 + $text = isset( $parsed['text'] ) ? $parsed['text'] : '';
1328 + $index = isset( $parsed['metadata']['chunk_index'] ) ? intval( $parsed['metadata']['chunk_index'] ) : count( $chunks );
1329 + $content_type = $row->content_type;
1330 + $chunks[] = array(
1331 + 'index' => $index,
1332 + 'text' => $text,
1333 + 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ),
1334 + 'row_id' => intval( $row->id ),
1335 + );
1336 + }
1337 +
1338 + usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } );
1339 +
1340 + $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) );
1341 +
1342 + return array(
1343 + 'store' => 'wordpress',
1344 + 'source_url' => $source_url,
1345 + 'content_type' => $content_type,
1346 + 'is_chunked' => count( $chunks ) > 1,
1347 + 'chunk_count' => count( $chunks ),
1348 + 'assembled' => $assembled,
1349 + 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ),
1350 + 'chunks' => array_values( $chunks ),
1351 + // WP-DB storage carries no separate vector metadata; surface that fact
1352 + // rather than letting the owner guess (the spec's taxonomy question).
1353 + 'metadata' => array(),
1354 + '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'),
1355 + );
1356 +}
1357 +
1358 +/**
1359 + * Read-only inspector for Pinecone entries. Mirrors get_pinecone_entry_content()
1360 + * but keeps each vector's text + metadata instead of imploding, so the owner can
1361 + * confirm exactly which metadata fields (text/source_url/type/last_updated/created_at/bot_id)
1362 + * are present per chunk. READ-ONLY.
1363 + */
1364 +private function inspect_pinecone_entry( $source_url, $entry_id, $bot_id ) {
1365 + if ( ! class_exists('MxChat_Pinecone_Manager') ) {
1366 + return new WP_Error( 'pinecone_unavailable', esc_html__('Pinecone manager not available.', 'mxchat') );
1367 + }
1368 +
1369 + if ( $bot_id === 'default' || ! class_exists('MxChat_Multi_Bot_Manager') ) {
1370 + $pinecone_options = get_option('mxchat_pinecone_addon_options');
1371 + $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1372 + $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1373 + $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1374 + } else {
1375 + $bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1376 + $api_key = $bot_config['api_key'] ?? '';
1377 + $host = $bot_config['host'] ?? '';
1378 + $namespace = $bot_config['namespace'] ?? '';
1379 + }
1380 +
1381 + if ( empty($host) || empty($api_key) ) {
1382 + return new WP_Error( 'pinecone_config', esc_html__('Pinecone not configured.', 'mxchat') );
1383 + }
1384 +
1385 + $base_id = md5( $source_url );
1386 + $vector_ids = array( $base_id );
1387 +
1388 + // NOTE: Pinecone's /vectors/list is a GET endpoint with query parameters.
1389 + // A POST is answered 200-with-an-empty-body, which reads as "no vectors".
1390 + $list_url = "https://{$host}/vectors/list";
1391 + $list_params = array( 'prefix' => $base_id . '_chunk_', 'limit' => 100 );
1392 + if ( ! empty($namespace) ) {
1393 + $list_params['namespace'] = $namespace;
1394 + }
1395 +
1396 + $list_resp = wp_remote_get( $list_url . '?' . http_build_query( $list_params ), array(
1397 + 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
1398 + 'timeout' => 15,
1399 + ) );
1400 +
1401 + if ( ! is_wp_error($list_resp) ) {
1402 + $list_data = json_decode( wp_remote_retrieve_body($list_resp), true );
1403 + if ( ! empty($list_data['vectors']) ) {
1404 + foreach ( $list_data['vectors'] as $v ) {
1405 + $vector_ids[] = $v['id'];
1406 + }
1407 + }
1408 + }
1409 +
1410 + // NOTE: /vectors/fetch is a GET endpoint too, and Pinecone expects the ids
1411 + // repeated (ids=a&ids=b) — http_build_query would emit ids[0]=a, so build
1412 + // the query string explicitly.
1413 + $fetch_query = array();
1414 + foreach ( $vector_ids as $fetch_vid ) {
1415 + $fetch_query[] = 'ids=' . rawurlencode( $fetch_vid );
1416 + }
1417 + if ( ! empty($namespace) ) {
1418 + $fetch_query[] = 'namespace=' . rawurlencode( $namespace );
1419 + }
1420 +
1421 + $fetch_resp = wp_remote_get( "https://{$host}/vectors/fetch?" . implode( '&', $fetch_query ), array(
1422 + 'headers' => array( 'Api-Key' => $api_key, 'accept' => 'application/json' ),
1423 + 'timeout' => 15,
1424 + ) );
1425 +
1426 + if ( is_wp_error($fetch_resp) ) {
1427 + return new WP_Error( 'pinecone_fetch', esc_html__('Failed to fetch from Pinecone.', 'mxchat') );
1428 + }
1429 +
1430 + $fetch_data = json_decode( wp_remote_retrieve_body($fetch_resp), true );
1431 + $vectors = $fetch_data['vectors'] ?? array();
1432 +
1433 + if ( empty($vectors) ) {
1434 + return new WP_Error( 'not_found', esc_html__('Entry not found in Pinecone.', 'mxchat') );
1435 + }
1436 +
1437 + // Whitelisted metadata fields the spec calls out — shown so devs can confirm
1438 + // what is (and is NOT) stored per vector.
1439 + $meta_fields = array( 'text', 'source_url', 'type', 'last_updated', 'created_at', 'bot_id', 'chunk_index', 'total_chunks' );
1440 + $chunks = array();
1441 + $content_type = '';
1442 + foreach ( $vectors as $vid => $vector ) {
1443 + $meta = isset($vector['metadata']) && is_array($vector['metadata']) ? $vector['metadata'] : array();
1444 + $text = $meta['text'] ?? '';
1445 + $index = isset($meta['chunk_index']) ? intval($meta['chunk_index']) : count($chunks);
1446 + $content_type = $meta['type'] ?? $content_type;
1447 +
1448 + $clean_meta = array();
1449 + foreach ( $meta_fields as $field ) {
1450 + if ( array_key_exists( $field, $meta ) && $field !== 'text' ) {
1451 + $clean_meta[ $field ] = is_scalar( $meta[ $field ] ) ? (string) $meta[ $field ] : wp_json_encode( $meta[ $field ] );
1452 + }
1453 + }
1454 +
1455 + $chunks[] = array(
1456 + 'index' => $index,
1457 + 'text' => $text,
1458 + 'length' => function_exists('mb_strlen') ? mb_strlen( $text ) : strlen( $text ),
1459 + 'vector_id' => (string) $vid,
1460 + 'metadata' => $clean_meta,
1461 + );
1462 + }
1463 +
1464 + usort( $chunks, function( $a, $b ) { return $a['index'] - $b['index']; } );
1465 +
1466 + $assembled = implode( "\n\n", wp_list_pluck( $chunks, 'text' ) );
1467 +
1468 + return array(
1469 + 'store' => 'pinecone',
1470 + 'source_url' => $source_url,
1471 + 'content_type' => $content_type,
1472 + 'is_chunked' => count( $chunks ) > 1,
1473 + 'chunk_count' => count( $chunks ),
1474 + 'assembled' => $assembled,
1475 + 'assembled_length' => function_exists('mb_strlen') ? mb_strlen( $assembled ) : strlen( $assembled ),
1476 + 'chunks' => array_values( $chunks ),
1477 + 'metadata' => array(),
1478 + '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'),
1479 + );
1480 +}
1481 +
1482 +/**
818 1483 * AJAX: Save edited content — re-chunks and re-embeds as needed.
819 1484 * Works for both WordPress DB and Pinecone entries.
820 1485 */
821 1486 public function ajax_mxchat_save_entry_content() {
@@ -824,9 +1489,9 @@
824 1489 if ( ! current_user_can('manage_options') ) {
825 1490 wp_send_json_error( array( 'message' => 'Permission denied.' ) );
826 1491 }
827 1492
828 - $source_url = isset($_POST['source_url']) ? sanitize_text_field( wp_unslash($_POST['source_url']) ) : '';
1493 + $source_url = $this->sanitize_entry_source_url( isset($_POST['source_url']) ? wp_unslash($_POST['source_url']) : '' );
829 1494 $entry_id = isset($_POST['entry_id']) ? absint($_POST['entry_id']) : 0;
830 1495 $content = isset($_POST['content']) ? wp_kses_post( wp_unslash($_POST['content']) ) : '';
831 1496 $data_source = isset($_POST['data_source']) ? sanitize_key($_POST['data_source']) : 'wordpress';
832 1497 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
@@ -847,38 +1512,74 @@
847 1512 if ( empty($api_key) ) {
848 1513 $api_key = $options['api_key'] ?? '';
849 1514 }
850 1515
851 - global $wpdb;
852 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1516 + if ( $data_source === 'pinecone' ) {
1517 + // Pinecone branch. The WP-DB manual-entry delete below must never run here:
1518 + // Pinecone ids are strings, and absint() on a digit-leading md5 hash would
1519 + // yield a real (unrelated) WP row id.
1520 + $raw_vector_id = isset($_POST['entry_id']) ? sanitize_text_field( wp_unslash($_POST['entry_id']) ) : '';
1521 + $is_manual_single = empty($source_url) || strpos($source_url, '_ungrouped_') === 0;
1522 + $is_manual_chunked = strpos($source_url, 'mxchat://') === 0;
853 1523
854 - // If source_url is empty but we have an entry_id, look it up
855 - if ( empty($source_url) && $entry_id > 0 && $data_source === 'wordpress' ) {
856 - $row = $wpdb->get_row( $wpdb->prepare( "SELECT source_url FROM {$table} WHERE id = %d", $entry_id ) );
857 - if ( $row && ! empty($row->source_url) ) {
858 - $source_url = $row->source_url;
1524 + if ( $is_manual_single || $is_manual_chunked ) {
1525 + // Manual content: remove the old vectors first, then store as fresh manual
1526 + // content — submit_content_to_db mints a new unique identity (manual_* id
1527 + // for a single vector, an mxchat:// chunk prefix if it now chunks).
1528 + if ( $is_manual_chunked ) {
1529 + // Minted identity: base + chunk vectors share the md5(mxchat://...) prefix.
1530 + MxChat_Utils::delete_chunks_for_url( $source_url, $bot_id );
1531 + } elseif ( ! empty($raw_vector_id) && class_exists('MxChat_Pinecone_Manager') ) {
1532 + $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
1533 + $pinecone_options = $pinecone_manager->mxchat_get_bot_pinecone_options( $bot_id );
1534 + if ( ! empty($pinecone_options['mxchat_pinecone_api_key']) && ! empty($pinecone_options['mxchat_pinecone_host']) ) {
1535 + $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
1536 + $raw_vector_id,
1537 + $pinecone_options['mxchat_pinecone_api_key'],
1538 + $pinecone_options['mxchat_pinecone_host'],
1539 + $pinecone_options['mxchat_pinecone_namespace'] ?? ''
1540 + );
1541 + }
1542 + }
1543 + $result = MxChat_Utils::submit_content_to_db( $content, '', $api_key, null, $bot_id, $content_type );
1544 + } else {
1545 + // URL-sourced entry: identity is md5(source_url). submit_content_to_db
1546 + // handles delete-old-chunks → re-chunk → re-embed → store, and sweeps
1547 + // stale chunk vectors when the content now fits in a single vector.
1548 + $result = MxChat_Utils::submit_content_to_db( $content, $source_url, $api_key, md5($source_url), $bot_id, $content_type );
859 1549 }
860 - }
1550 + } else {
1551 + global $wpdb;
1552 + $table = $wpdb->prefix . 'mxchat_system_prompt_content';
861 1553
862 - // For manual entries (no source_url or mxchat:// prefix), delete the old entry by ID first
863 - // so submit_content_to_db creates a replacement instead of a duplicate
864 - // Also treat legacy mxchat.ai source URLs as manual — old bug assigned the site URL to manual entries
865 - $is_legacy_manual = !empty($source_url) && strpos($source_url, 'mxchat.ai') !== false && strpos($source_url, 'mxchat://') !== 0;
866 - if ( $entry_id > 0 && (empty($source_url) || strpos($source_url, 'mxchat://') === 0 || $is_legacy_manual) ) {
867 - $wpdb->delete( $table, array( 'id' => $entry_id ), array( '%d' ) );
868 - // Clear legacy URL so submit_content_to_db generates a unique mxchat:// identifier
869 - // instead of reusing the shared URL (which would mass-delete other entries with the same URL)
870 - if ( $is_legacy_manual ) {
871 - $source_url = '';
1554 + // If source_url is empty but we have an entry_id, look it up
1555 + if ( empty($source_url) && $entry_id > 0 ) {
1556 + $row = $wpdb->get_row( $wpdb->prepare( "SELECT source_url FROM {$table} WHERE id = %d", $entry_id ) );
1557 + if ( $row && ! empty($row->source_url) ) {
1558 + $source_url = $row->source_url;
1559 + }
872 1560 }
873 - }
874 1561
875 - // Use the existing submit_content_to_db which handles chunking, Pinecone, and WP DB
876 - $vector_id = ! empty($source_url) ? md5($source_url) : md5('mxchat_manual_' . $entry_id);
1562 + // For manual entries (no source_url or mxchat:// prefix), delete the old entry by ID first
1563 + // so submit_content_to_db creates a replacement instead of a duplicate
1564 + // Also treat legacy mxchat.ai source URLs as manual — old bug assigned the site URL to manual entries
1565 + $is_legacy_manual = !empty($source_url) && strpos($source_url, 'mxchat.ai') !== false && strpos($source_url, 'mxchat://') !== 0;
1566 + if ( $entry_id > 0 && (empty($source_url) || strpos($source_url, 'mxchat://') === 0 || $is_legacy_manual) ) {
1567 + $wpdb->delete( $table, array( 'id' => $entry_id ), array( '%d' ) );
1568 + // Clear legacy URL so submit_content_to_db generates a unique mxchat:// identifier
1569 + // instead of reusing the shared URL (which would mass-delete other entries with the same URL)
1570 + if ( $is_legacy_manual ) {
1571 + $source_url = '';
1572 + }
1573 + }
877 1574
878 - // submit_content_to_db already handles: delete old chunks → re-chunk → re-embed → store
879 - $result = MxChat_Utils::submit_content_to_db( $content, $source_url, $api_key, $vector_id, $bot_id, $content_type );
1575 + // Use the existing submit_content_to_db which handles chunking, Pinecone, and WP DB
1576 + $vector_id = ! empty($source_url) ? md5($source_url) : md5('mxchat_manual_' . $entry_id);
880 1577
1578 + // submit_content_to_db already handles: delete old chunks → re-chunk → re-embed → store
1579 + $result = MxChat_Utils::submit_content_to_db( $content, $source_url, $api_key, $vector_id, $bot_id, $content_type );
1580 + }
1581 +
881 1582 if ( is_wp_error($result) ) {
882 1583 wp_send_json_error( array( 'message' => $result->get_error_message() ) );
883 1584 }
884 1585
@@ -964,42 +1665,34 @@
964 1665
965 1666 // Get bot_id from form submission
966 1667 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
967 1668
968 - // Get bot-specific options and validate API key
1669 + // Get bot-specific options and validate the embedding decision —
1670 + // custom-provider-aware (plan cbd5fd).
969 1671 $bot_options = $this->get_bot_options($bot_id);
970 1672 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
971 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
972 -
973 - if (strpos($selected_model, 'voyage') === 0) {
974 - $api_key = $options['voyage_api_key'] ?? '';
975 - $provider_name = 'Voyage AI';
976 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
977 - $api_key = $options['gemini_api_key'] ?? '';
978 - $provider_name = 'Google Gemini';
979 - } else {
980 - $api_key = $options['api_key'] ?? '';
981 - $provider_name = 'OpenAI';
982 - }
983 -
984 - if (empty($api_key)) {
985 - $error_message = sprintf(
986 - esc_html__('%s API key is not configured. Please add your API key in the settings before submitting content.', 'mxchat'),
987 - $provider_name
988 - );
989 - set_transient('mxchat_admin_notice_error', $error_message, 30);
1673 +
1674 + $preflight = MxChat_Utils::embedding_preflight($options);
1675 + if (!$preflight['ok']) {
1676 + set_transient('mxchat_admin_notice_error', esc_html($preflight['reason']), 30);
990 1677 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
991 1678 exit;
992 1679 }
1680 + $api_key = $preflight['api_key'];
993 1681
994 - // Fetch URL — use browser-like headers so servers with bot protection don't block us
1682 + // Fetch URL — send an honest, versioned MXChat crawler UA (not a spoofed
1683 + // browser). Stale browser UAs are exactly what WAFs like SiteGround's
1684 + // ModSecurity flag as scrapers, 403-ing the fetch (including PDFs served
1685 + // from the site's own media library, which route through this same call).
1686 + // See mxchat_ingest_user_agent(). Accept is kept for content negotiation;
1687 + // the browser-only Accept-Language fingerprint is dropped so it stays
1688 + // coherent with a bot identity.
995 1689 $response = wp_remote_get($submitted_url, array(
996 1690 'timeout' => 30,
997 1691 'sslverify' => false,
998 - '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',
1692 + 'user-agent' => mxchat_ingest_user_agent(),
999 1693 'headers' => array(
1000 1694 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
1001 - 'Accept-Language' => 'en-US,en;q=0.9',
1002 1695 ),
1003 1696 ));
1004 1697
1005 1698 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
@@ -1070,12 +1763,22 @@
1070 1763 esc_html__('Sitemap queued for processing. Processing will start automatically.', 'mxchat'),
1071 1764 30
1072 1765 );
1073 1766 } else {
1074 - set_transient('mxchat_admin_notice_error',
1075 - esc_html__('Failed to queue sitemap processing. Please check the status below for details.', 'mxchat'),
1076 - 30
1077 - );
1767 + // Surface the reason the handler already computed (embedding pre-flight,
1768 + // empty sitemap, queue failure). The old message pointed at the status
1769 + // area, which is empty on this path — nothing was ever queued.
1770 + if (is_string($result) && $result !== '') {
1771 + set_transient('mxchat_admin_notice_error',
1772 + esc_html__('Failed to queue sitemap processing: ', 'mxchat') . esc_html($result),
1773 + 30
1774 + );
1775 + } else {
1776 + set_transient('mxchat_admin_notice_error',
1777 + esc_html__('Failed to queue sitemap processing.', 'mxchat'),
1778 + 30
1779 + );
1780 + }
1078 1781 }
1079 1782
1080 1783 wp_safe_redirect(esc_url(admin_url('admin.php?page=mxchat-prompts')));
1081 1784 exit;
@@ -1218,8 +1921,140 @@
1218 1921 *
1219 1922 * @param string $content The content containing shortcodes
1220 1923 * @return string Content with shortcode tags removed but inner content preserved
1221 1924 */
1925 +/**
1926 + * Single-pass HTML entity decode for text entering the knowledge base.
1927 + * The corpus should hold what a human reads: a stored `&amp;` consumes
1928 + * extra tokens, distorts the vector away from the form a visitor's
1929 + * question uses, and can be quoted back verbatim in an answer.
1930 + * Deliberately NOT looped to a fixed point — a stored `&amp;amp;` is a
1931 + * legitimate literal `&amp;` and must not collapse further (data loss).
1932 + * UTF-8 charset keeps multibyte (CJK/RTL) text untouched. Both assembly
1933 + * paths call this at their output points so the treatment cannot drift.
1934 + * (Plan d2c92e.)
1935 + */
1936 +private function mxchat_decode_entities_for_indexing($text) {
1937 + return html_entity_decode((string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
1938 +}
1939 +
1940 +/**
1941 + * Price lines for a product's indexed text, pinned to the store's BASE currency.
1942 + *
1943 + * The four product assembly paths each used to call get_woocommerce_currency_symbol()
1944 + * with no argument, which resolves the currency active on the CURRENT request.
1945 + * Multi-currency plugins (CURCY, WOOCS, Aelia, WPML Multicurrency) filter that per
1946 + * request, so whichever currency the store happened to be serving when an import ran
1947 + * was frozen into every product it indexed. The amounts have the mirror problem: the
1948 + * woocommerce_product_get_* filters convert prices in the 'view' context but not in
1949 + * 'edit', so a converted amount could be paired with an unconverted symbol and produce
1950 + * a price that is not merely wrong but incoherent.
1951 + *
1952 + * Base currency option + 'edit' context makes both halves agree and makes the output
1953 + * independent of when the import ran. The currency CODE is emitted alongside the symbol
1954 + * so the model cannot read a bare "$" as USD on a store whose plugin swapped symbols.
1955 + * (Plan 7403ec.)
1956 + */
1957 +private function mxchat_product_price_lines($product) {
1958 + if (!is_object($product) || !method_exists($product, 'get_regular_price')) {
1959 + return '';
1960 + }
1961 +
1962 + $currency = get_option('woocommerce_currency');
1963 + $currency = is_string($currency) ? trim($currency) : '';
1964 + $symbol = ($currency !== '')
1965 + ? get_woocommerce_currency_symbol($currency)
1966 + : get_woocommerce_currency_symbol();
1967 + $symbol = $this->mxchat_decode_entities_for_indexing($symbol);
1968 +
1969 + $regular_price = $product->get_regular_price('edit');
1970 + $sale_price = $product->get_sale_price('edit');
1971 + $price = $product->get_price('edit');
1972 +
1973 + $lines = '';
1974 +
1975 + if (!empty($regular_price)) {
1976 + $lines .= "Price: " . $this->mxchat_format_indexed_price($regular_price, $currency, $symbol) . "\n";
1977 + } elseif (!empty($price)) {
1978 + $lines .= "Price: " . $this->mxchat_format_indexed_price($price, $currency, $symbol) . "\n";
1979 + }
1980 +
1981 + if (!empty($sale_price) && $sale_price !== $regular_price) {
1982 + $lines .= "Sale Price: " . $this->mxchat_format_indexed_price($sale_price, $currency, $symbol) . "\n";
1983 + }
1984 +
1985 + if ($product->is_type('variable')) {
1986 + list($min_price, $max_price) = $this->mxchat_variation_price_range($product);
1987 + if ($min_price !== null && $max_price !== null && (float) $min_price !== (float) $max_price) {
1988 + $lines .= "Price Range: " . $this->mxchat_format_indexed_price($min_price, $currency, $symbol)
1989 + . " - " . $this->mxchat_format_indexed_price($max_price, $currency, $symbol) . "\n";
1990 + }
1991 + }
1992 +
1993 + return $lines;
1994 +}
1995 +
1996 +/**
1997 + * One indexed price amount, labelled with its currency code.
1998 + *
1999 + * "INR 1299.00 (Rs.1299.00)" — the code is what the model should reason from; the symbol
2000 + * is kept so a quoted price still reads naturally. Falls back to the old symbol-only
2001 + * shape when WooCommerce has no base currency configured, and drops the parenthetical
2002 + * when the symbol is absent or IS the code (several currencies have no distinct glyph).
2003 + */
2004 +private function mxchat_format_indexed_price($amount, $currency, $symbol) {
2005 + $amount = (string) $amount;
2006 +
2007 + if ($currency === '') {
2008 + return $symbol . $amount;
2009 + }
2010 +
2011 + if ($symbol === '' || $symbol === $currency) {
2012 + return $currency . ' ' . $amount;
2013 + }
2014 +
2015 + return $currency . ' ' . $amount . ' (' . $symbol . $amount . ')';
2016 +}
2017 +
2018 +/**
2019 + * Min/max variation price read from the variations themselves in 'edit' context.
2020 + *
2021 + * get_variation_price() reads WooCommerce's display price cache, which multi-currency
2022 + * plugins populate with converted values — the same defect the rest of this helper
2023 + * exists to remove. Returns raw stored strings (not floats) so the indexed text keeps
2024 + * the store's own price formatting, and (null, null) when no variation carries a price.
2025 + */
2026 +private function mxchat_variation_price_range($product) {
2027 + $min_raw = null;
2028 + $max_raw = null;
2029 + $min_val = null;
2030 + $max_val = null;
2031 +
2032 + $children = method_exists($product, 'get_children') ? $product->get_children() : array();
2033 +
2034 + foreach ($children as $child_id) {
2035 + $variation = wc_get_product($child_id);
2036 + if (!$variation) {
2037 + continue;
2038 + }
2039 + $raw = $variation->get_price('edit');
2040 + if ($raw === '' || $raw === null) {
2041 + continue;
2042 + }
2043 + $val = (float) $raw;
2044 + if ($min_val === null || $val < $min_val) {
2045 + $min_val = $val;
2046 + $min_raw = $raw;
2047 + }
2048 + if ($max_val === null || $val > $max_val) {
2049 + $max_val = $val;
2050 + $max_raw = $raw;
2051 + }
2052 + }
2053 +
2054 + return array($min_raw, $max_raw);
2055 +}
2056 +
1222 2057 private function strip_shortcode_tags_preserve_content($content) {
1223 2058 // Single-pass regex removes all shortcode brackets: [tag], [tag attr="val"], [/tag], [tag /]
1224 2059 // Content between tags is inherently preserved since only brackets are targeted
1225 2060 $result = preg_replace('/\[\/?\w[\w-]*[^\]]*\]/', '', $content);
@@ -1263,24 +2098,37 @@
1263 2098
1264 2099 // Ensure valid UTF-8 encoding
1265 2100 $content = wp_check_invalid_utf8($content);
1266 2101
1267 - // Remove any extremely long strings without spaces (often garbage)
1268 - $content = preg_replace('/\S{300,}/', ' ', $content);
1269 -
1270 - // Replace problematic characters that often cause database issues
1271 - $content = preg_replace('/[\x{10000}-\x{10FFFF}]/u', '', $content); // Remove emoji and other high Unicode characters
1272 -
1273 - // Replace any remaining potentially problematic characters with spaces
1274 - // BUT preserve newlines by temporarily replacing them
1275 - $content = str_replace("\n", "NEWLINE_PLACEHOLDER", $content);
1276 - $content = preg_replace('/[^\p{L}\p{N}\p{P}\p{Z}\p{Sm}]/u', ' ', $content);
1277 - $content = str_replace("NEWLINE_PLACEHOLDER", "\n", $content);
1278 -
1279 - // Limit to reasonable length if needed
2102 + // Remove extremely long runs with no whitespace (base64 blobs, minified JS).
2103 + // Counts CHARACTERS (/u), and never strips a run containing characters from a
2104 + // script written without spaces — Japanese, Chinese, Thai, Khmer, Lao, Myanmar —
2105 + // where a normal paragraph is legitimately one unbroken run.
2106 + $content = preg_replace_callback('/\S{300,}/u', function ($m) {
2107 + return preg_match('/[\p{Han}\p{Hiragana}\p{Katakana}\p{Thai}\p{Khmer}\p{Lao}\p{Myanmar}]/u', $m[0]) ? $m[0] : ' ';
2108 + }, $content);
2109 +
2110 + // Remove emoji/symbol blocks only — not the whole supplementary plane, which
2111 + // also holds CJK Extension B ideographs used in real Chinese/Japanese names.
2112 + // A ZWJ (U+200D) BETWEEN stripped pictographs is consumed with them, so a
2113 + // family sequence like 👨‍👩‍👧 leaves no invisible zero-width residue behind
2114 + // (the joiner between NON-emoji characters — Hindi conjuncts — is untouched).
2115 + $content = preg_replace('/[\x{1F000}-\x{1F0FF}\x{1F300}-\x{1FAFF}](?:\x{200D}[\x{1F000}-\x{1F0FF}\x{1F300}-\x{1FAFF}])*/u', '', $content);
2116 +
2117 + // There is deliberately NO catch-all character allowlist here (plan 209e57;
2118 + // one existed until 3.2.20). Every genuinely dangerous byte is already gone:
2119 + // control characters, null bytes, invalid UTF-8 and the emoji blocks are all
2120 + // stripped above. The allowlist's only remaining effect was to damage scripts
2121 + // nobody thought to enumerate — Unicode Cf (Format) was missing, so it
2122 + // replaced the zero-width joiner/non-joiner with spaces and silently split
2123 + // Persian words (می‌روم → می روم) and broke Hindi conjuncts (क्‍ष → क् ष).
2124 + // Do not add one back; the failure mode of an allowlist is exactly this.
2125 +
2126 + // Limit to reasonable length if needed (byte limit — MySQL TEXT is byte-sized,
2127 + // but cut on a character boundary so a multibyte char is never split mid-sequence)
1280 2128 $max_length = 65000; // Just under MySQL TEXT field limit
1281 2129 if (strlen($content) > $max_length) {
1282 - $content = substr($content, 0, $max_length);
2130 + $content = mb_strcut($content, 0, $max_length, 'UTF-8');
1283 2131 }
1284 2132
1285 2133 //error_log('[MXCHAT-SANITIZE] Sanitized content preview: ' . substr($content, 0, 500) . '...');
1286 2134 return $content;
@@ -2118,10 +2966,19 @@
2118 2966 <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual Content', 'mxchat'); ?></span>
2119 2967 <?php endif; ?>
2120 2968 </td>
2121 2969 <td class="mxchat-actions-cell" style="padding: 12px 16px; white-space: nowrap;">
2122 - <?php if ($data_source !== 'pinecone') : ?>
2123 2970 <button type="button"
2971 + class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-inspect-entry-btn"
2972 + data-source-url="<?php echo esc_attr($source_url); ?>"
2973 + data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2974 + data-data-source="<?php echo esc_attr($data_source); ?>"
2975 + data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
2976 + data-nonce="<?php echo wp_create_nonce('mxchat_inspect_entry_nonce'); ?>"
2977 + title="<?php esc_attr_e('View indexed content', 'mxchat'); ?>">
2978 + <span class="dashicons dashicons-visibility" style="font-size: 14px;"></span>
2979 + </button>
2980 + <button type="button"
2124 2981 class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
2125 2982 data-source-url="<?php echo esc_attr($source_url); ?>"
2126 2983 data-entry-id="<?php echo esc_attr($first_prompt->id); ?>"
2127 2984 data-data-source="<?php echo esc_attr($data_source); ?>"
@@ -2129,9 +2986,8 @@
2129 2986 data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
2130 2987 title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
2131 2988 <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
2132 2989 </button>
2133 - <?php endif; ?>
2134 2990 <button type="button"
2135 2991 class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-group"
2136 2992 data-source-url="<?php echo esc_attr($source_url); ?>"
2137 2993 data-chunk-count="<?php echo esc_attr($chunk_count); ?>"
@@ -2263,9 +3119,29 @@
2263 3119 <?php else : ?>
2264 3120 <span style="color: var(--mxch-text-muted);"><?php esc_html_e('Manual', 'mxchat'); ?></span>
2265 3121 <?php endif; ?>
2266 3122 </td>
2267 - <td style="padding: 12px 16px;">
3123 + <td style="padding: 12px 16px; white-space: nowrap;">
3124 + <button type="button"
3125 + class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-inspect-entry-btn"
3126 + data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
3127 + data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3128 + data-data-source="<?php echo esc_attr($data_source); ?>"
3129 + data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3130 + data-nonce="<?php echo wp_create_nonce('mxchat_inspect_entry_nonce'); ?>"
3131 + title="<?php esc_attr_e('View indexed content', 'mxchat'); ?>">
3132 + <span class="dashicons dashicons-visibility" style="font-size: 14px;"></span>
3133 + </button>
3134 + <button type="button"
3135 + class="mxch-btn mxch-btn-ghost mxch-btn-sm mxchat-edit-entry-btn"
3136 + data-source-url="<?php echo esc_attr($prompt->source_url ?? ''); ?>"
3137 + data-entry-id="<?php echo esc_attr($prompt->id); ?>"
3138 + data-data-source="<?php echo esc_attr($data_source); ?>"
3139 + data-bot-id="<?php echo esc_attr($current_bot_id); ?>"
3140 + data-nonce="<?php echo wp_create_nonce('mxchat_edit_entry_nonce'); ?>"
3141 + title="<?php esc_attr_e('Edit content', 'mxchat'); ?>">
3142 + <span class="dashicons dashicons-edit" style="font-size: 14px;"></span>
3143 + </button>
2268 3144 <button type="button" class="mxch-btn mxch-btn-ghost mxch-btn-sm delete-button-ajax" data-vector-id="<?php echo esc_attr($prompt->id); ?>" data-bot-id="<?php echo esc_attr($current_bot_id); ?>" data-nonce="<?php echo wp_create_nonce('mxchat_delete_pinecone_prompt_nonce'); ?>" style="color: var(--mxch-error);">
2269 3145 <span class="dashicons dashicons-trash" style="font-size: 14px;"></span>
2270 3146 </button>
2271 3147 </td>
@@ -2838,9 +3714,9 @@
2838 3714 $response = wp_remote_head($url, array(
2839 3715 'timeout' => 10,
2840 3716 'sslverify' => false,
2841 3717 'redirection' => 1,
2842 - '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',
3718 + 'user-agent' => mxchat_ingest_user_agent(),
2843 3719 ));
2844 3720
2845 3721 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
2846 3722 // Found a sitemap index - parse it to get sub-sitemaps
@@ -2900,12 +3776,11 @@
2900 3776
2901 3777 $response = wp_remote_get($url, array(
2902 3778 'timeout' => 30,
2903 3779 'sslverify' => false,
2904 - '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',
3780 + 'user-agent' => mxchat_ingest_user_agent(),
2905 3781 'headers' => array(
2906 3782 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
2907 - 'Accept-Language' => 'en-US,en;q=0.9',
2908 3783 ),
2909 3784 ));
2910 3785
2911 3786 if (is_wp_error($response)) {
@@ -2959,12 +3834,11 @@
2959 3834 private function get_sitemap_url_count($url) {
2960 3835 $response = wp_remote_get($url, array(
2961 3836 'timeout' => 30,
2962 3837 'sslverify' => false,
2963 - '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',
3838 + 'user-agent' => mxchat_ingest_user_agent(),
2964 3839 'headers' => array(
2965 3840 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
2966 - 'Accept-Language' => 'en-US,en;q=0.9',
2967 3841 ),
2968 3842 ));
2969 3843
2970 3844 if (is_wp_error($response)) {
@@ -2990,9 +3864,9 @@
2990 3864
2991 3865 $response = wp_remote_get($robots_url, array(
2992 3866 'timeout' => 15,
2993 3867 'sslverify' => false,
2994 - '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',
3868 + 'user-agent' => mxchat_ingest_user_agent(),
2995 3869 ));
2996 3870
2997 3871 if (is_wp_error($response)) {
2998 3872 return $sitemaps;
@@ -3482,9 +4356,14 @@
3482 4356 }
3483 4357
3484 4358 // Get bot_id from request
3485 4359 $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
3486 -
4360 +
4361 + // ACF→PDF extraction is an install-level setting (Knowledge → ACF Fields,
4362 + // plan 11720c). The import modal shows a passive status line pointing
4363 + // there; the old per-batch checkbox and its remembered default are gone.
4364 + $extract_acf_pdfs = get_option('mxchat_acf_pdf_extraction', '0') === '1';
4365 +
3487 4366 // Process only ONE post at a time to avoid request size issues
3488 4367 $post_id = reset($post_ids);
3489 4368 $post = get_post($post_id);
3490 4369
@@ -3492,139 +4371,34 @@
3492 4371 wp_send_json_error('Post not found');
3493 4372 exit;
3494 4373 }
3495 4374
3496 - // Allow developers to modify post data before processing into knowledge base
4375 + /**
4376 + * Allow developers to modify post data before processing into the knowledge base.
4377 + * Applied on BOTH content-preparation paths (this manual bulk import and the
4378 + * auto-sync path in mxchat_handle_post_update) with the same signature, so a
4379 + * callback registered once covers every indexing route. Purely additive —
4380 + * zero behaviour change when unhooked.
4381 + *
4382 + * @param WP_Post $post The post about to be indexed.
4383 + * @param string $bot_id Bot context for this import.
4384 + */
3497 4385 $post = apply_filters('mxchat_before_process_post', $post, $bot_id);
3498 -
3499 - // Get content including title, short description (for WooCommerce), and main content
3500 - $content = $post->post_title . "\n\n";
3501 -
3502 - // Add short description if it exists (WooCommerce products use post_excerpt for short description)
3503 - if (!empty($post->post_excerpt)) {
3504 - // Remove shortcode tags but preserve content inside them
3505 - $clean_excerpt = $this->strip_shortcode_tags_preserve_content($post->post_excerpt);
3506 - $content .= "Short Description: " . wp_strip_all_tags($clean_excerpt) . "\n\n";
4386 + if (!($post instanceof WP_Post)) {
4387 + $post = get_post($post_id); // defend against a bad callback return
3507 4388 }
3508 4389
3509 - // Add main content - remove shortcode tags but preserve content inside them
3510 - $clean_content = $this->strip_shortcode_tags_preserve_content($post->post_content);
3511 - $content .= wp_strip_all_tags($clean_content);
4390 + // Assemble the indexable text via the shared post-kind assembler (a3d60c).
4391 + // Bulk import reads raw post fields, has always included product custom tabs,
4392 + // and passes the install-level ACF→PDF option.
4393 + $prepared = $this->mxchat_prepare_post_content_for_indexing($post_id, $post, array(
4394 + 'read_display' => false,
4395 + 'extract_acf_pdfs' => $extract_acf_pdfs,
4396 + 'include_product_tabs' => true,
4397 + ));
4398 + $content = $prepared['content'];
4399 + $pdf_extracted_count = $prepared['pdf_extracted_count'];
3512 4400
3513 - // ADD WOOCOMMERCE PRODUCT DATA (pricing, stock, categories, custom tabs)
3514 - if (get_post_type($post_id) === 'product' && class_exists('WooCommerce')) {
3515 - $product = wc_get_product($post_id);
3516 -
3517 - if ($product) {
3518 - // Get pricing information
3519 - $regular_price = $product->get_regular_price();
3520 - $sale_price = $product->get_sale_price();
3521 - $price = $product->get_price();
3522 - $sku = $product->get_sku();
3523 -
3524 - // Get currency symbol
3525 - $currency_symbol = get_woocommerce_currency_symbol();
3526 -
3527 - // Add pricing information
3528 - $content .= "\n";
3529 - if (!empty($regular_price)) {
3530 - $content .= "Price: " . $currency_symbol . $regular_price . "\n";
3531 - } elseif (!empty($price)) {
3532 - $content .= "Price: " . $currency_symbol . $price . "\n";
3533 - }
3534 -
3535 - if (!empty($sale_price) && $sale_price !== $regular_price) {
3536 - $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
3537 - }
3538 -
3539 - // Handle variable products - show price range
3540 - if ($product->is_type('variable')) {
3541 - $min_price = $product->get_variation_price('min');
3542 - $max_price = $product->get_variation_price('max');
3543 - if ($min_price !== $max_price) {
3544 - $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
3545 - }
3546 - }
3547 -
3548 - if (!empty($sku)) {
3549 - $content .= "SKU: " . $sku . "\n";
3550 - }
3551 -
3552 - // Get product categories
3553 - $categories = wp_get_post_terms($post_id, 'product_cat', array('fields' => 'names'));
3554 - if (!empty($categories) && !is_wp_error($categories)) {
3555 - $content .= "Categories: " . implode(', ', $categories) . "\n";
3556 - }
3557 - }
3558 -
3559 - // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
3560 - $custom_tabs = get_post_meta($post_id, 'yikes_woo_products_tabs', true);
3561 - if (!empty($custom_tabs) && is_array($custom_tabs)) {
3562 - foreach ($custom_tabs as $tab) {
3563 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
3564 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
3565 -
3566 - if (!empty($tab_title) && !empty($tab_content)) {
3567 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
3568 - }
3569 - }
3570 - }
3571 -
3572 - // Also check for reusable/saved tabs applied to this product
3573 - $applied_saved_tabs = get_post_meta($post_id, 'yikes_woo_reusable_products_tabs_applied', true);
3574 - if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
3575 - $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
3576 - if (!empty($saved_tabs) && is_array($saved_tabs)) {
3577 - foreach ($applied_saved_tabs as $saved_tab_id) {
3578 - if (isset($saved_tabs[$saved_tab_id])) {
3579 - $tab = $saved_tabs[$saved_tab_id];
3580 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
3581 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
3582 -
3583 - if (!empty($tab_title) && !empty($tab_content)) {
3584 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
3585 - }
3586 - }
3587 - }
3588 - }
3589 - }
3590 - }
3591 -
3592 - // ADD ACF FIELDS SUPPORT
3593 - $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
3594 - if (!empty($acf_fields)) {
3595 - $acf_content_parts = array();
3596 -
3597 - foreach ($acf_fields as $field_name => $field_value) {
3598 - $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
3599 -
3600 - if (!empty($formatted_value)) {
3601 - $field_label = ucwords(str_replace('_', ' ', $field_name));
3602 - $acf_content_parts[] = $field_label . ": " . $formatted_value;
3603 - }
3604 - }
3605 -
3606 - if (!empty($acf_content_parts)) {
3607 - $content .= "\n\n" . implode("\n", $acf_content_parts);
3608 - }
3609 - }
3610 -
3611 - // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
3612 - $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
3613 - if (!empty($custom_meta)) {
3614 - $meta_content_parts = array();
3615 -
3616 - foreach ($custom_meta as $meta_key => $meta_value) {
3617 - // Convert meta key to readable label
3618 - $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
3619 - $meta_content_parts[] = $meta_label . ": " . $meta_value;
3620 - }
3621 -
3622 - if (!empty($meta_content_parts)) {
3623 - $content .= "\n\n" . implode("\n", $meta_content_parts);
3624 - }
3625 - }
3626 -
3627 4401 // Debug logging for WordPress Import content
3628 4402 //error_log('[MXCHAT-WP-IMPORT-DEBUG] Post ID: ' . $post_id . ' Title: ' . $post->post_title);
3629 4403 //error_log('[MXCHAT-WP-IMPORT-DEBUG] Raw post_content length: ' . strlen($post->post_content));
3630 4404 //error_log('[MXCHAT-WP-IMPORT-DEBUG] Final content length: ' . strlen($content));
@@ -3631,29 +4405,19 @@
3631 4405 //error_log('[MXCHAT-WP-IMPORT-DEBUG] Content preview: ' . substr($content, 0, 300));
3632 4406
3633 4407 // Note: Removed 10,000 char limit - chunking now handles large content properly
3634 4408
3635 - // Get bot-specific API key
4409 + // Get bot-specific embedding decision — custom-provider-aware (plan cbd5fd)
3636 4410 $bot_options = $this->get_bot_options($bot_id);
3637 4411 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
3638 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
3639 -
3640 - if (strpos($selected_model, 'voyage') === 0) {
3641 - $api_key = $options['voyage_api_key'] ?? '';
3642 - $provider_name = 'Voyage AI';
3643 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
3644 - $api_key = $options['gemini_api_key'] ?? '';
3645 - $provider_name = 'Google Gemini';
3646 - } else {
3647 - $api_key = $options['api_key'] ?? '';
3648 - $provider_name = 'OpenAI';
3649 - }
3650 -
3651 - if (empty($api_key)) {
3652 - MxChat_Admin::mxchat_log_debug('api_error', $provider_name . ' API key not configured for knowledge processing');
3653 - wp_send_json_error($provider_name . ' API key not configured');
4412 +
4413 + $preflight = MxChat_Utils::embedding_preflight($options);
4414 + if (!$preflight['ok']) {
4415 + MxChat_Admin::mxchat_log_debug('api_error', $preflight['reason'] . ' (knowledge processing)');
4416 + wp_send_json_error($preflight['reason']);
3654 4417 exit;
3655 4418 }
4419 + $api_key = $preflight['api_key'];
3656 4420
3657 4421 $source_url = get_permalink($post_id);
3658 4422 $vector_id = md5($source_url); // Vector ID for Pinecone
3659 4423
@@ -3724,11 +4488,11 @@
3724 4488 // Automatically apply role restriction based on tags
3725 4489 $this->apply_role_restriction_to_post($post_id, $source_url);
3726 4490
3727 4491 $operation_type = $is_update ? 'update' : 'new';
3728 -
4492 +
3729 4493 // Count ACF fields for debugging
3730 - $acf_field_count = count($acf_fields);
4494 + $acf_field_count = $prepared['acf_fields_found'];
3731 4495
3732 4496 // Success response with minimal data
3733 4497 wp_send_json_success(array(
3734 4498 'message' => $operation_type === 'update' ? 'Content updated successfully' : 'Content processed successfully',
@@ -3736,8 +4500,9 @@
3736 4500 'title' => $post->post_title,
3737 4501 'operation_type' => $operation_type,
3738 4502 'vector_id' => $vector_id,
3739 4503 'acf_fields_found' => $acf_field_count,
4504 + 'pdf_extracted_count' => (int) $pdf_extracted_count,
3740 4505 'content_preview' => substr($content, 0, 100) . '...',
3741 4506 'bot_id' => $bot_id
3742 4507 ));
3743 4508 exit;
@@ -3807,9 +4572,9 @@
3807 4572 );
3808 4573 } else {
3809 4574 // Update WordPress DB
3810 4575 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
3811 -
4576 +
3812 4577 $wpdb->update(
3813 4578 $table_name,
3814 4579 array('role_restriction' => $highest_role),
3815 4580 array('source_url' => $source_url),
@@ -3816,8 +4581,15 @@
3816 4581 array('%s'),
3817 4582 array('%s')
3818 4583 );
3819 4584 }
4585 +
4586 + // The entry's restriction just changed — keep the OpenAI Vector Store
4587 + // mirror consistent: non-public pulls the file (file_search has no
4588 + // per-role filtering), public re-mirrors it (plan 15b5c6).
4589 + if (class_exists('MxChat_Vectorstore_Manager')) {
4590 + MxChat_Vectorstore_Manager::handle_role_change($source_url, 'default', $highest_role);
4591 + }
3820 4592 }
3821 4593
3822 4594 public function mxchat_get_public_post_types() {
3823 4595 // Get all public post types
@@ -3888,64 +4660,63 @@
3888 4660
3889 4661 return $pinecone_data;
3890 4662 }
3891 4663 public function mxchat_fetch_pinecone_vectors_by_ids($pinecone_options, $vector_ids) {
3892 - //error_log('=== DEBUG: Starting mxchat_fetch_pinecone_vectors_by_ids ===');
3893 -
3894 4664 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
3895 4665 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4666 + $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
3896 4667
3897 4668 if (empty($api_key) || empty($host) || empty($vector_ids)) {
3898 - //error_log('DEBUG: Missing parameters for fetch by IDs');
3899 4669 return array();
3900 4670 }
3901 4671
3902 4672 try {
3903 - $fetch_url = "https://{$host}/vectors/fetch";
3904 - //error_log('DEBUG: Fetch URL: ' . $fetch_url);
3905 - //error_log('DEBUG: Fetching ' . count($vector_ids) . ' vector IDs');
4673 + // NOTE (plan 793b82): /vectors/fetch is a GET endpoint with the ids
4674 + // repeated in the query string (ids=a&ids=b — http_build_query would
4675 + // emit ids[0]=a); the old POST here was answered 200-with-an-empty-body,
4676 + // which read as "nothing indexed". Chunked at 100 ids to stay well
4677 + // under the measured HTTP 414 URL-length boundary.
4678 + $vectors = array();
4679 + foreach (array_chunk(array_values($vector_ids), 100) as $chunk) {
4680 + $fetch_query = array();
4681 + foreach ($chunk as $fetch_vid) {
4682 + $fetch_query[] = 'ids=' . rawurlencode($fetch_vid);
4683 + }
4684 + if (!empty($namespace)) {
4685 + $fetch_query[] = 'namespace=' . rawurlencode($namespace);
4686 + }
3906 4687
3907 - // Pinecone fetch API allows fetching specific vectors by ID
3908 - $fetch_data = array(
3909 - 'ids' => array_values($vector_ids)
3910 - );
4688 + $response = wp_remote_get("https://{$host}/vectors/fetch?" . implode('&', $fetch_query), array(
4689 + 'headers' => array(
4690 + 'Api-Key' => $api_key,
4691 + 'accept' => 'application/json'
4692 + ),
4693 + 'timeout' => 30
4694 + ));
3911 4695
3912 - $response = wp_remote_post($fetch_url, array(
3913 - 'headers' => array(
3914 - 'Api-Key' => $api_key,
3915 - 'Content-Type' => 'application/json'
3916 - ),
3917 - 'body' => json_encode($fetch_data),
3918 - 'timeout' => 30
3919 - ));
4696 + if (is_wp_error($response)) {
4697 + error_log('MxChat Pinecone: mxchat_fetch_pinecone_vectors_by_ids GET failed: ' . $response->get_error_message());
4698 + continue;
4699 + }
3920 4700
3921 - if (is_wp_error($response)) {
3922 - //error_log('DEBUG: Fetch by IDs WP error: ' . $response->get_error_message());
3923 - return array();
3924 - }
4701 + if (wp_remote_retrieve_response_code($response) !== 200) {
4702 + error_log('MxChat Pinecone: mxchat_fetch_pinecone_vectors_by_ids GET returned HTTP ' . wp_remote_retrieve_response_code($response));
4703 + continue;
4704 + }
3925 4705
3926 - $response_code = wp_remote_retrieve_response_code($response);
3927 - //error_log('DEBUG: Fetch response code: ' . $response_code);
3928 -
3929 - if ($response_code !== 200) {
3930 - $error_body = wp_remote_retrieve_body($response);
3931 - //error_log('DEBUG: Fetch failed with body: ' . $error_body);
3932 - return array();
4706 + $data = json_decode(wp_remote_retrieve_body($response), true);
4707 + if (isset($data['vectors']) && is_array($data['vectors'])) {
4708 + $vectors += $data['vectors'];
4709 + }
3933 4710 }
3934 4711
3935 - $body = wp_remote_retrieve_body($response);
3936 - $data = json_decode($body, true);
3937 -
3938 - //error_log('DEBUG: Fetch response structure: ' . print_r(array_keys($data), true));
3939 -
3940 - if (!isset($data['vectors'])) {
3941 - //error_log('DEBUG: No vectors key in response');
4712 + if (empty($vectors)) {
3942 4713 return array();
3943 4714 }
3944 4715
3945 4716 $processed_data = array();
3946 4717
3947 - foreach ($data['vectors'] as $vector_id => $vector_data) {
4718 + foreach ($vectors as $vector_id => $vector_data) {
3948 4719 $metadata = $vector_data['metadata'] ?? array();
3949 4720 $source_url = $metadata['source_url'] ?? '';
3950 4721
3951 4722 if (!empty($source_url)) {
@@ -4016,8 +4787,11 @@
4016 4787 */
4017 4788 public function mxchat_scan_pinecone_for_processed_content($pinecone_options) {
4018 4789 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
4019 4790 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
4791 + // plan 793b82: this scan was namespace-blind — on a namespaced setup it
4792 + // surveyed the default namespace and reported the wrong content as indexed.
4793 + $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
4020 4794
4021 4795 if (empty($api_key) || empty($host)) {
4022 4796 return array();
4023 4797 }
@@ -4052,8 +4826,12 @@
4052 4826 'topK' => 10000,
4053 4827 'vector' => $random_vector
4054 4828 );
4055 4829
4830 + if (!empty($namespace)) {
4831 + $query_data['namespace'] = $namespace;
4832 + }
4833 +
4056 4834 $response = wp_remote_post($query_url, array(
4057 4835 'headers' => array(
4058 4836 'Api-Key' => $api_key,
4059 4837 'Content-Type' => 'application/json'
@@ -4066,9 +4844,9 @@
4066 4844 continue;
4067 4845 }
4068 4846
4069 4847 $response_code = wp_remote_retrieve_response_code($response);
4070 -
4848 +
4071 4849 if ($response_code !== 200) {
4072 4850 continue;
4073 4851 }
4074 4852
@@ -4152,9 +4930,20 @@
4152 4930
4153 4931 // Get bot-specific options
4154 4932 $bot_options = $this->get_bot_options($bot_id);
4155 4933 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
4156 -
4934 +
4935 + // Opt-in: when the custom provider is selected for embeddings, index through
4936 + // the same custom endpoint the query path uses so stored vectors and query
4937 + // vectors share a model. Returns the vector array on success, or an error
4938 + // string on failure (this function's existing failure contract).
4939 + if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
4940 + if (!class_exists('MxChat_Utils')) {
4941 + require_once dirname(__FILE__) . '/../includes/class-mxchat-utils.php';
4942 + }
4943 + return MxChat_Utils::generate_embedding_custom($text, $options);
4944 + }
4945 +
4157 4946 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
4158 4947 //error_log('[MXCHAT-EMBED] Selected embedding model for bot ' . $bot_id . ': ' . $selected_model);
4159 4948
4160 4949 // Determine provider and endpoint
@@ -4266,13 +5055,23 @@
4266 5055 $error_message = $error_json['error']['message'] ?? 'No message';
4267 5056 //error_log('[MXCHAT-EMBED] API Error Type: ' . $error_type);
4268 5057 //error_log('[MXCHAT-EMBED] API Error Message: ' . $error_message);
4269 5058
4270 - // Customize error message for common API errors
4271 - if ($error_type === 'invalid_request_error' && strpos($error_message, 'API key') !== false) {
4272 - $error_message = sprintf('Invalid %s API key for bot %s. Please check your API key in the bot settings.', $provider_name, $bot_id);
4273 - } elseif ($error_type === 'authentication_error') {
4274 - $error_message = sprintf('%s authentication failed for bot %s. Please verify your API key in the bot settings.', $provider_name, $bot_id);
5059 + // Keep the provider's own diagnostic — a restricted-key 401 names the
5060 + // exact missing scope, and replacing it with "check your API key" sent
5061 + // a customer to regenerate two keys (plan 46b596). Same shape as
5062 + // MxChat_Utils::embedding_failure_error() so both ingestion paths read
5063 + // identically. Key never appears in provider messages, but scrub anyway.
5064 + if ($error_type === 'invalid_request_error' || $error_type === 'authentication_error') {
5065 + if (is_string($api_key) && $api_key !== '') {
5066 + $error_message = str_replace($api_key, '[redacted]', $error_message);
5067 + }
5068 + $error_message = sprintf(
5069 + 'Embedding failed (%s, HTTP %d): %s',
5070 + $selected_model,
5071 + $http_code,
5072 + substr($error_message, 0, 300)
5073 + );
4275 5074 }
4276 5075
4277 5076 //error_log('[MXCHAT-EMBED] Returning error: ' . $error_message);
4278 5077 return $error_message;
@@ -4784,25 +5583,38 @@
4784 5583 /**
4785 5584 * Get all ACF fields for a specific post, excluding any fields the user has disabled
4786 5585 */
4787 5586 public function mxchat_get_acf_fields_for_post($post_id) {
4788 - if (!function_exists('get_fields')) {
5587 + if (!function_exists('get_field_objects')) {
4789 5588 return array();
4790 5589 }
4791 5590
4792 - $fields = get_fields($post_id);
4793 - if (!$fields || !is_array($fields)) {
5591 + // Field OBJECTS, not get_fields(): exclusion matches on the field KEY
5592 + // (unique per field) rather than the name (shared across groups — plan
5593 + // 30e81f). ACF's own get_fields() is implemented as get_field_objects()
5594 + // reduced to name => value, so the un-excluded reduction below is the
5595 + // identical shape and order the previous get_fields() call produced.
5596 + $field_objects = get_field_objects($post_id);
5597 + if (!$field_objects || !is_array($field_objects)) {
4794 5598 return array();
4795 5599 }
4796 5600
4797 - // Get excluded fields from settings
4798 5601 $excluded_fields = get_option('mxchat_acf_excluded_fields', array());
4799 - if (!empty($excluded_fields) && is_array($excluded_fields)) {
4800 - foreach ($excluded_fields as $excluded_field) {
4801 - if (isset($fields[$excluded_field])) {
4802 - unset($fields[$excluded_field]);
5602 + if (!is_array($excluded_fields)) {
5603 + $excluded_fields = array();
5604 + }
5605 +
5606 + $fields = array();
5607 + foreach ($field_objects as $field_name => $field_object) {
5608 + if (!empty($excluded_fields)) {
5609 + $field_key = isset($field_object['key']) ? $field_object['key'] : '';
5610 + // Legacy name entries stay honored: a stored name whose group was
5611 + // inactive at migration time still excludes every field wearing it.
5612 + if (in_array($field_key, $excluded_fields, true) || in_array($field_name, $excluded_fields, true)) {
5613 + continue;
4803 5614 }
4804 5615 }
5616 + $fields[$field_name] = isset($field_object['value']) ? $field_object['value'] : null;
4805 5617 }
4806 5618
4807 5619 return $fields;
4808 5620 }
@@ -4814,8 +5626,11 @@
4814 5626 if (!function_exists('acf_get_field_groups') || !function_exists('acf_get_fields')) {
4815 5627 return array();
4816 5628 }
4817 5629
5630 + // Keyed by GROUP KEY, not title (titles are not unique), and each field
5631 + // entry carries its ACF field key — the unique identifier every toggle,
5632 + // save, and index-time exclusion now runs on (plans 30e81f / bf57e0).
4818 5633 $all_fields = array();
4819 5634 $field_groups = acf_get_field_groups();
4820 5635
4821 5636 if (!empty($field_groups)) {
@@ -4821,16 +5636,21 @@
4821 5636 if (!empty($field_groups)) {
4822 5637 foreach ($field_groups as $group) {
4823 5638 $group_fields = acf_get_fields($group['key']);
4824 5639 if (!empty($group_fields)) {
4825 - $all_fields[$group['title']] = array();
5640 + $entry = array(
5641 + 'title' => $group['title'],
5642 + 'fields' => array(),
5643 + );
4826 5644 foreach ($group_fields as $field) {
4827 - $all_fields[$group['title']][] = array(
4828 - 'name' => $field['name'],
5645 + $entry['fields'][] = array(
5646 + 'key' => $field['key'],
5647 + 'name' => $field['name'],
4829 5648 'label' => $field['label'],
4830 - 'type' => $field['type']
5649 + 'type' => $field['type']
4831 5650 );
4832 5651 }
5652 + $all_fields[$group['key']] = $entry;
4833 5653 }
4834 5654 }
4835 5655 }
4836 5656
@@ -5072,8 +5892,200 @@
5072 5892 return implode(', ', array_filter($text_parts));
5073 5893 }
5074 5894
5075 5895 /**
5896 + * Walk an ACF field value tree and collect attachment IDs for any value that
5897 + * resolves to a PDF in the WordPress media library. Handles the three shapes
5898 + * ACF returns for File/Image/URL fields (array with ID+url, integer attachment ID,
5899 + * plain URL string), and recurses through repeater/group/flexible content.
5900 + *
5901 + * @param mixed $value The ACF field value (any depth)
5902 + * @param array $out Accumulator (passed by reference) for attachment IDs
5903 + * @param int $depth Recursion guard
5904 + */
5905 +private function mxchat_collect_pdf_attachment_ids_from_acf_value($value, &$out, $depth = 0) {
5906 + if ($depth > 6) {
5907 + return; // prevent runaway recursion on circular/very-deep structures
5908 + }
5909 +
5910 + if (empty($value)) {
5911 + return;
5912 + }
5913 +
5914 + // Array shapes: ACF File/Image return value=array; repeaters/groups are arrays of arrays
5915 + if (is_array($value)) {
5916 + // Direct File/Image-style array (has 'url' and usually 'ID' + 'mime_type')
5917 + $looks_like_attachment = isset($value['url']) || isset($value['ID']) || isset($value['id']);
5918 + if ($looks_like_attachment) {
5919 + $att_id = 0;
5920 + if (!empty($value['ID']) && is_numeric($value['ID'])) {
5921 + $att_id = (int) $value['ID'];
5922 + } elseif (!empty($value['id']) && is_numeric($value['id'])) {
5923 + $att_id = (int) $value['id'];
5924 + } elseif (!empty($value['url']) && is_string($value['url'])) {
5925 + $att_id = (int) attachment_url_to_postid($value['url']);
5926 + }
5927 +
5928 + $is_pdf = false;
5929 + if (!empty($value['mime_type']) && $value['mime_type'] === 'application/pdf') {
5930 + $is_pdf = true;
5931 + } elseif (!empty($value['subtype']) && strtolower((string) $value['subtype']) === 'pdf') {
5932 + $is_pdf = true;
5933 + } elseif (!empty($value['url']) && is_string($value['url']) && $this->mxchat_url_looks_like_pdf($value['url'])) {
5934 + $is_pdf = true;
5935 + } elseif ($att_id && get_post_mime_type($att_id) === 'application/pdf') {
5936 + $is_pdf = true;
5937 + }
5938 +
5939 + if ($is_pdf && $att_id && get_post_mime_type($att_id) === 'application/pdf') {
5940 + $out[] = $att_id;
5941 + }
5942 + // An array node that represents one attachment doesn't contain other
5943 + // attachments inside it — done with this branch.
5944 + return;
5945 + }
5946 +
5947 + // Recurse: repeater rows, flexible-content layouts, groups, etc.
5948 + foreach ($value as $sub) {
5949 + $this->mxchat_collect_pdf_attachment_ids_from_acf_value($sub, $out, $depth + 1);
5950 + }
5951 + return;
5952 + }
5953 +
5954 + // Plain numeric attachment ID (ACF File field set to "Return: ID")
5955 + if (is_numeric($value)) {
5956 + $att_id = (int) $value;
5957 + if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
5958 + $out[] = $att_id;
5959 + }
5960 + return;
5961 + }
5962 +
5963 + // Plain string — URL pointing at a PDF (ACF File field set to "Return: URL", or a custom URL/text field)
5964 + if (is_string($value)) {
5965 + $trimmed = trim($value);
5966 + if ($trimmed !== '' && $this->mxchat_url_looks_like_pdf($trimmed)) {
5967 + $att_id = (int) attachment_url_to_postid($trimmed);
5968 + if ($att_id > 0 && get_post_mime_type($att_id) === 'application/pdf') {
5969 + $out[] = $att_id;
5970 + }
5971 + }
5972 + return;
5973 + }
5974 +}
5975 +
5976 +/**
5977 + * Heuristic: does this URL/string look like a PDF reference?
5978 + * Tolerates query strings and fragments (#page=2).
5979 + */
5980 +private function mxchat_url_looks_like_pdf($url) {
5981 + if (!is_string($url) || $url === '') {
5982 + return false;
5983 + }
5984 + // Strip query + fragment before checking extension
5985 + $path = preg_replace('/[?#].*$/', '', $url);
5986 + return (bool) preg_match('/\.pdf$/i', $path);
5987 +}
5988 +
5989 +/**
5990 + * Extract text from a PDF attachment by ID using the bundled Smalot parser.
5991 + * Reads the file directly from disk via get_attached_file (no HTTP fetch).
5992 + * Result is cached on the attachment as post_meta keyed by file mtime so we
5993 + * only parse the same PDF once unless the file changes on disk.
5994 + *
5995 + * @param int $attachment_id
5996 + * @return string Extracted plain text, or '' on failure.
5997 + */
5998 +private function mxchat_extract_pdf_text_by_attachment_id($attachment_id) {
5999 + $attachment_id = (int) $attachment_id;
6000 + if ($attachment_id <= 0) {
6001 + return '';
6002 + }
6003 + if (get_post_mime_type($attachment_id) !== 'application/pdf') {
6004 + return '';
6005 + }
6006 +
6007 + $pdf_path = get_attached_file($attachment_id);
6008 + if (empty($pdf_path) || !file_exists($pdf_path) || !is_readable($pdf_path)) {
6009 + return '';
6010 + }
6011 +
6012 + // Raw-file size cap. Parsing very large PDFs can OOM the request; skip with a log entry
6013 + // and let the rest of the ACF content land in the KB. Filterable for users who need it bigger.
6014 + $default_max_bytes = 25 * 1024 * 1024;
6015 + $max_bytes = (int) apply_filters('mxchat_acf_pdf_max_bytes', $default_max_bytes, $attachment_id, $pdf_path);
6016 + if ($max_bytes > 0) {
6017 + $file_size = @filesize($pdf_path);
6018 + if ($file_size !== false && $file_size > $max_bytes) {
6019 + error_log(sprintf(
6020 + '[mxchat] ACF PDF skipped (over size cap): attachment %d "%s" %d bytes > cap %d',
6021 + $attachment_id,
6022 + basename($pdf_path),
6023 + $file_size,
6024 + $max_bytes
6025 + ));
6026 + return '';
6027 + }
6028 + }
6029 +
6030 + $mtime = @filemtime($pdf_path);
6031 + $cache_meta_key = '_mxchat_acf_pdf_text_v1';
6032 + $cached = get_post_meta($attachment_id, $cache_meta_key, true);
6033 + if (is_array($cached) && isset($cached['mtime'], $cached['text']) && (int) $cached['mtime'] === (int) $mtime) {
6034 + return (string) $cached['text'];
6035 + }
6036 +
6037 + $text = '';
6038 + try {
6039 + if (function_exists('mxchat_load_pdf_parser')) {
6040 + mxchat_load_pdf_parser();
6041 + }
6042 + if (!class_exists('\\Smalot\\PdfParser\\Parser')) {
6043 + return '';
6044 + }
6045 + $parser = new \Smalot\PdfParser\Parser();
6046 + $pdf = $parser->parseFile($pdf_path);
6047 + $pages = $pdf->getPages();
6048 + $page_texts = array();
6049 + $acf_page_num = 0;
6050 + foreach ($pages as $page) {
6051 + $acf_page_num++;
6052 + $page_text = '';
6053 + try {
6054 + $page_text = $page->getText();
6055 + } catch (\Exception $e) {
6056 + $page_text = '';
6057 + }
6058 + if (!empty($page_text)) {
6059 + $page_text = MxChat_Utils::normalize_pdf_rtl($page_text, 'acf_pdf attachment ' . $attachment_id . ' page ' . $acf_page_num);
6060 + $page_texts[] = $page_text;
6061 + }
6062 + }
6063 + $text = trim(implode("\n\n", $page_texts));
6064 + } catch (\Exception $e) {
6065 + error_log('[mxchat] ACF PDF extraction failed for attachment ' . $attachment_id . ': ' . $e->getMessage());
6066 + return '';
6067 + } catch (\Throwable $e) {
6068 + error_log('[mxchat] ACF PDF extraction error for attachment ' . $attachment_id . ': ' . $e->getMessage());
6069 + return '';
6070 + }
6071 +
6072 + // Cap per-PDF text to avoid blowing up the embedding payload on enormous PDFs.
6073 + // The chunker downstream will still split this into multiple vectors.
6074 + $max_len = (int) apply_filters('mxchat_acf_pdf_text_max_length', 50000);
6075 + if ($max_len > 0 && strlen($text) > $max_len) {
6076 + $text = substr($text, 0, $max_len);
6077 + }
6078 +
6079 + update_post_meta($attachment_id, $cache_meta_key, array(
6080 + 'mtime' => (int) $mtime,
6081 + 'text' => $text,
6082 + ));
6083 +
6084 + return $text;
6085 +}
6086 +
6087 +/**
5076 6088 * Handle ACF save - fires after ACF fields are saved
5077 6089 * This ensures ACF field data is available when syncing to knowledge base
5078 6090 */
5079 6091 public function mxchat_handle_acf_save($post_id) {
@@ -5143,8 +6155,13 @@
5143 6155 $this->mxchat_handle_post_update($post_id, $post, true);
5144 6156 }
5145 6157
5146 6158 public function mxchat_handle_post_update($post_id, $post, $update) {
6159 + // The in-flight-update marker has done its job the moment post_updated runs; drop it
6160 + // before any early return so it can never outlive its own save (a failed $wpdb->update
6161 + // inside wp_insert_post returns after pre_post_update but before the transition).
6162 + unset($this->pending_post_update[$post_id]);
6163 +
5147 6164 // Basic validation checks
5148 6165 if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE || wp_is_post_revision($post_id)) {
5149 6166 return;
5150 6167 }
@@ -5182,9 +6199,11 @@
5182 6199 if ($previous_status === 'publish' && $post->post_status !== 'publish') {
5183 6200 // Use the stored URL from when it was published, or fall back to current permalink
5184 6201 $source_url = $previous_url ?: get_permalink($post_id);
5185 6202
5186 - if ($source_url) {
6203 + // mxchat_handle_status_transition already deleted for this post earlier in this
6204 + // request (it fires first inside wp_insert_post); skip the redundant round-trip.
6205 + if ($source_url && empty($this->transition_deleted_posts[$post_id])) {
5187 6206 // Chunk-aware deletion (routes to Pinecone or WP DB and removes base + all chunks)
5188 6207 MxChat_Utils::delete_chunks_for_url($source_url, 'default');
5189 6208 }
5190 6209
@@ -5213,177 +6232,402 @@
5213 6232 set_transient($previous_url_key, $current_url, DAY_IN_SECONDS);
5214 6233 }
5215 6234 }
5216 6235
5217 - // Only process currently published content for adding/updating
6236 + // Only process currently published content for adding/updating.
6237 + // transition_indexed_posts: mxchat_handle_status_transition's arrival edge may have
6238 + // already indexed this post earlier in this request (editor publishes fire
6239 + // transition_post_status first, then post_updated) — skip the duplicate embed.
6240 + // Consume-once: the flag is cleared when honoured, so a LATER save of the same
6241 + // post in one long-running process (WP-CLI scripts, importers) re-indexes normally.
5218 6242 if ($post->post_status === 'publish') {
6243 + if (!empty($this->transition_indexed_posts[$post_id])) {
6244 + unset($this->transition_indexed_posts[$post_id]);
6245 + } else {
6246 + $this->mxchat_index_published_post($post_id, $post);
6247 + }
6248 + }
6249 +
6250 + // Clean up the stored previous status if not used above
6251 + if ($previous_status !== 'publish' || $post->post_status === 'publish') {
6252 + delete_transient($previous_status_key);
6253 + delete_transient($previous_url_key);
6254 + }
6255 +}
6256 +
6257 +/**
6258 + * Index a published post into the knowledge base: preprocessing filter, content
6259 + * assembly (title/excerpt/body), WooCommerce product enrichment, job_listing meta,
6260 + * ACF fields (+ optional PDF extraction), whitelisted custom meta, embedding and
6261 + * upsert, then tag-based role restriction.
6262 + *
6263 + * Shared by the post_updated auto-sync path (mxchat_handle_post_update) and the
6264 + * transition_post_status arrival edge (mxchat_handle_status_transition), so
6265 + * scheduled publishes (wp_publish_post) and direct status=publish inserts index
6266 + * identically to editor saves (plan 3055e1). Pure extraction of the former
6267 + * publish branch — body indentation retained to keep the diff reviewable.
6268 + */
6269 +private function mxchat_index_published_post($post_id, $post) {
6270 + $post_type = $post->post_type;
6271 +
6272 + // WooCommerce products are owned by the WC-object assembler (plan a3d60c):
6273 + // whenever WooCommerce is active AND the integration is enabled, every
6274 + // product save also fires save_post_product, which queues
6275 + // mxchat_store_product_embedding on shutdown — and that writer runs LAST,
6276 + // overwriting the same md5(permalink) row this path would write. Assembling
6277 + // and embedding the product here was pure duplicate spend (measured: two
6278 + // embedding calls per product save, second one wins). Skip ONLY under the
6279 + // exact conditions the shutdown writer runs — same option read as its own
6280 + // gate — because with the integration off (or WooCommerce inactive) this
6281 + // path is the sole product indexer and must keep working.
6282 + if ($post_type === 'product'
6283 + && class_exists('WooCommerce')
6284 + && isset($this->options['enable_woocommerce_integration'])
6285 + && in_array($this->options['enable_woocommerce_integration'], ['1', 'on'])) {
6286 + return;
6287 + }
6288 +
5219 6289 // Get the source URL
5220 6290 $source_url = get_permalink($post_id);
5221 -
5222 - // Get content with proper formatting (matching ajax_mxchat_process_selected_content)
5223 - $title = get_the_title($post_id);
5224 - $content = get_post_field('post_content', $post_id);
5225 - $excerpt = get_post_field('post_excerpt', $post_id);
5226 6291
5227 - // Remove shortcode tags but preserve content inside them
5228 - $content = $this->strip_shortcode_tags_preserve_content($content);
5229 - $excerpt = $this->strip_shortcode_tags_preserve_content($excerpt);
6292 + // A draft published programmatically (wp_publish_post) can reach this
6293 + // point with an EMPTY post_name — wp_insert_post skips slug generation
6294 + // for draft/pending — and get_permalink() then resolves to the bare
6295 + // site root. A knowledge row keyed to the homepage cites the wrong URL
6296 + // and answers homepage questions with this post's body, so refuse to
6297 + // write it; the post indexes correctly on its next save, once the slug
6298 + // exists. The empty-post_name test is what keeps a legitimate static
6299 + // front page (which has a slug but a root permalink) indexable.
6300 + // (Plan d138c4.)
6301 + if ('' === $post->post_name
6302 + && untrailingslashit($source_url) === untrailingslashit(home_url())) {
6303 + return;
6304 + }
5230 6305
5231 - // Strip tags but preserve structure (don't use 'the_content' filter as it may re-add shortcodes)
5232 - $content = wp_strip_all_tags($content);
6306 + /**
6307 + * Allow developers to modify post data before processing into the knowledge base.
6308 + * Same filter and signature as the manual bulk-import path
6309 + * (ajax_mxchat_process_selected_content), so a callback registered once covers
6310 + * every indexing route. Purely additive — zero behaviour change when unhooked.
6311 + * Auto-sync runs under the 'default' bot context, matching the rest of this
6312 + * function.
6313 + *
6314 + * @param WP_Post $post The post about to be indexed.
6315 + * @param string $bot_id Bot context ('default' on auto-sync).
6316 + */
6317 + $post = apply_filters('mxchat_before_process_post', $post, 'default');
6318 + if (!($post instanceof WP_Post)) {
6319 + $post = get_post($post_id); // defend against a bad callback return
6320 + }
5233 6321
5234 - // Combine title, short description (if exists), and content
5235 - $final_content = $title . "\n\n";
6322 + // Assemble the indexable text via the shared post-kind assembler (a3d60c),
6323 + // reading from the FILTERED post object — not re-fetched by ID, which would
6324 + // discard it. Auto-sync reads content/excerpt in its historical
6325 + // get_post_field() display context, never appended product custom tabs
6326 + // (its product branch is reachable only with the WooCommerce integration
6327 + // off), and gates ACF→PDF extraction behind its own opt-in option —
6328 + // default OFF, because re-parsing every ACF PDF on every editor save is
6329 + // expensive and most sites don't want it (the 25 MB size cap lives in the
6330 + // shared extractor either way).
6331 + $prepared = $this->mxchat_prepare_post_content_for_indexing($post_id, $post, array(
6332 + 'read_display' => true,
6333 + 'extract_acf_pdfs' => get_option('mxchat_auto_sync_acf_pdfs', '0') === '1',
6334 + 'include_product_tabs' => false,
6335 + ));
6336 + $final_content = $prepared['content'];
5236 6337
5237 - // Add short description if it exists (WooCommerce products use post_excerpt for short description)
5238 - if (!empty($excerpt)) {
5239 - $final_content .= "Short Description: " . wp_strip_all_tags($excerpt) . "\n\n";
6338 + // Embedding decision — custom-provider-aware. Gating on a cloud API key
6339 + // here silently killed auto-sync on keyless custom-embeddings sites,
6340 + // because generate_embedding() routes custom FIRST and never needs the
6341 + // key (plan cbd5fd). Silent-return shape preserved.
6342 + $preflight = MxChat_Utils::embedding_preflight(get_option('mxchat_options'));
6343 + if (!$preflight['ok']) {
6344 + return;
5240 6345 }
6346 + $api_key = $preflight['api_key'];
5241 6347
5242 - $final_content .= $content;
6348 + // Use the centralized utility function for storage
6349 + $result = MxChat_Utils::submit_content_to_db(
6350 + $final_content,
6351 + $source_url,
6352 + $api_key,
6353 + md5($source_url) // Vector ID for Pinecone
6354 + );
6355 +
6356 + // After successful storage, apply role restriction based on tags
6357 + if (!is_wp_error($result)) {
6358 + $this->apply_role_restriction_to_post($post_id, $source_url);
6359 + }
6360 +}
5243 6361
5244 - // For WooCommerce products, include pricing and product details
5245 - if ($post_type === 'product' && class_exists('WooCommerce')) {
5246 - $product = wc_get_product($post_id);
6362 +/**
6363 + * Shared post-fields content assembler (plan a3d60c) — the ONE body behind both
6364 + * post-kind ingestion paths: manual bulk import (ajax_mxchat_process_selected_content)
6365 + * and auto-sync (mxchat_index_published_post). Behavior-preserving extraction; the
6366 + * measured per-caller differences ride $args instead of living as drifting copies:
6367 + *
6368 + * 'read_display' bool Auto-sync historically reads content/excerpt via
6369 + * get_post_field() in its default 'display' context
6370 + * (the post_content / post_excerpt display filters
6371 + * fire); bulk import reads the raw properties. Inert
6372 + * on a stock install — preserved per-path, not converged.
6373 + * 'extract_acf_pdfs' bool Each caller passes its OWN option (bulk:
6374 + * mxchat_acf_pdf_extraction; auto-sync:
6375 + * mxchat_auto_sync_acf_pdfs) — the two-option design
6376 + * is deliberate (plan 11720c). Gates BOTH the PDF-id
6377 + * collection walk and the extraction loop; the ids are
6378 + * only ever read inside the extraction branch, so
6379 + * gating collection is output-identical on every install.
6380 + * 'include_product_tabs' bool The bulk path has always appended yikes_woo custom
6381 + * tabs to product content; the auto-sync product branch
6382 + * (reachable only with the WooCommerce integration off)
6383 + * never did. Preserved per-path — converging it would be
6384 + * a behavior change, recorded on the plan instead.
6385 + *
6386 + * Returns array: 'content' (the assembled indexable text), 'acf_fields_found' and
6387 + * 'pdf_extracted_count' (the bulk path reports both in its AJAX response).
6388 + */
6389 +private function mxchat_prepare_post_content_for_indexing($post_id, $post, $args) {
6390 + $read_display = !empty($args['read_display']);
6391 + $extract_acf_pdfs = !empty($args['extract_acf_pdfs']);
6392 + $include_product_tabs = !empty($args['include_product_tabs']);
5247 6393
5248 - if ($product) {
5249 - // Get pricing information
5250 - $regular_price = $product->get_regular_price();
5251 - $sale_price = $product->get_sale_price();
5252 - $price = $product->get_price();
5253 - $sku = $product->get_sku();
6394 + // Raw post_title, NOT get_the_title(): the_title applies wptexturize +
6395 + // convert_chars and prepends the "Protected:" / "Private:" display chrome.
6396 + // The knowledge base stores facts, not display strings. Entity decode at
6397 + // output time (single-pass, shared helper) — a stored `&amp;` embeds worse
6398 + // than `&` and gets quoted back to visitors (d2c92e).
6399 + $content = $this->mxchat_decode_entities_for_indexing($post->post_title) . "\n\n";
5254 6400
5255 - // Get currency symbol
5256 - $currency_symbol = get_woocommerce_currency_symbol();
6401 + $raw_excerpt = $read_display ? get_post_field('post_excerpt', $post) : $post->post_excerpt;
6402 + $raw_content = $read_display ? get_post_field('post_content', $post) : $post->post_content;
5257 6403
5258 - // Add pricing information
5259 - $final_content .= "\n";
5260 - if (!empty($regular_price)) {
5261 - $final_content .= "Price: " . $currency_symbol . $regular_price . "\n";
5262 - } elseif (!empty($price)) {
5263 - $final_content .= "Price: " . $currency_symbol . $price . "\n";
5264 - }
6404 + // Add short description if it exists (WooCommerce products use post_excerpt for short description)
6405 + // Strip FIRST, then test: an excerpt that is nothing but shortcodes strips to
6406 + // empty, and testing the raw value emitted a bare "Short Description: " label
6407 + // with no value after it. trim() only in the TEST — the emitted value is
6408 + // untouched, so a populated excerpt is byte-identical to before. A
6409 + // whitespace-only excerpt is an empty excerpt and must not produce a labelled
6410 + // line with nothing after it.
6411 + $clean_excerpt = $this->strip_shortcode_tags_preserve_content($raw_excerpt);
6412 + if (trim($clean_excerpt) !== '') {
6413 + $content .= "Short Description: " . $this->mxchat_decode_entities_for_indexing(wp_strip_all_tags($clean_excerpt)) . "\n\n";
6414 + }
5265 6415
5266 - if (!empty($sale_price) && $sale_price !== $regular_price) {
5267 - $final_content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
5268 - }
6416 + // Main content — remove shortcode tags but preserve content inside them, then
6417 + // strip tags (don't use 'the_content' filter as it may re-add shortcodes).
6418 + $clean_content = $this->strip_shortcode_tags_preserve_content($raw_content);
6419 + $content .= $this->mxchat_decode_entities_for_indexing(wp_strip_all_tags($clean_content));
5269 6420
5270 - // Handle variable products - show price range
5271 - if ($product->is_type('variable')) {
5272 - $min_price = $product->get_variation_price('min');
5273 - $max_price = $product->get_variation_price('max');
5274 - if ($min_price !== $max_price) {
5275 - $final_content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
5276 - }
5277 - }
6421 + // WooCommerce product enrichment (post-fields kind). The WC-object assembler
6422 + // (mxchat_prepare_product_content_for_indexing) owns product rows whenever the
6423 + // integration is on; this branch serves the bulk import (all configurations)
6424 + // and auto-sync with the integration off.
6425 + if (get_post_type($post_id) === 'product' && class_exists('WooCommerce')) {
6426 + $product = wc_get_product($post_id);
5278 6427
5279 - if (!empty($sku)) {
5280 - $final_content .= "SKU: " . $sku . "\n";
5281 - }
6428 + if ($product) {
6429 + $content .= "\n";
6430 + $content .= $this->mxchat_woo_product_summary_lines($product);
6431 + }
5282 6432
5283 - // Get product categories
5284 - $categories = wp_get_post_terms($post_id, 'product_cat', array('fields' => 'names'));
5285 - if (!empty($categories) && !is_wp_error($categories)) {
5286 - $final_content .= "Categories: " . implode(', ', $categories) . "\n";
5287 - }
5288 - }
6433 + if ($include_product_tabs) {
6434 + $content .= $this->mxchat_woo_custom_tabs_text($post_id);
5289 6435 }
6436 + }
5290 6437
5291 - // For custom post types like job_listing, include additional fields
5292 - if ($post_type === 'job_listing') {
5293 - // Add job-specific meta if available
5294 - $job_location = get_post_meta($post_id, '_job_location', true);
5295 - if (!empty($job_location)) {
5296 - $final_content .= "\n\nLocation: " . $job_location;
5297 - }
6438 + // For custom post types like job_listing, include additional fields
6439 + if (get_post_type($post_id) === 'job_listing') {
6440 + // Add job-specific meta if available
6441 + $job_location = get_post_meta($post_id, '_job_location', true);
6442 + if (!empty($job_location)) {
6443 + $content .= "\n\nLocation: " . $job_location;
6444 + }
5298 6445
5299 - // Get job type terms
5300 - $job_types = get_the_terms($post_id, 'job_listing_type');
5301 - if (!empty($job_types) && !is_wp_error($job_types)) {
5302 - $types = array();
5303 - foreach ($job_types as $type) {
5304 - $types[] = $type->name;
5305 - }
5306 - $final_content .= "\n\nJob Type: " . implode(', ', $types);
6446 + // Get job type terms
6447 + $job_types = get_the_terms($post_id, 'job_listing_type');
6448 + if (!empty($job_types) && !is_wp_error($job_types)) {
6449 + $types = array();
6450 + foreach ($job_types as $type) {
6451 + $types[] = $type->name;
5307 6452 }
6453 + $content .= "\n\nJob Type: " . implode(', ', $types);
6454 + }
5308 6455
5309 - // Get company name if available
5310 - $company_name = get_post_meta($post_id, '_company_name', true);
5311 - if (!empty($company_name)) {
5312 - $final_content .= "\n\nCompany: " . $company_name;
5313 - }
6456 + // Get company name if available
6457 + $company_name = get_post_meta($post_id, '_company_name', true);
6458 + if (!empty($company_name)) {
6459 + $content .= "\n\nCompany: " . $company_name;
5314 6460 }
6461 + }
5315 6462
5316 - // ADD ACF FIELDS SUPPORT (matches ajax_mxchat_process_selected_content behavior)
5317 - $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
5318 - if (!empty($acf_fields)) {
5319 - $acf_content_parts = array();
6463 + // ADD ACF FIELDS SUPPORT
6464 + $acf_fields = $this->mxchat_get_acf_fields_for_post($post_id);
6465 + $pdf_extracted_count = 0;
6466 + if (!empty($acf_fields)) {
6467 + $acf_content_parts = array();
6468 + $pdf_attachment_ids = array();
5320 6469
5321 - foreach ($acf_fields as $field_name => $field_value) {
5322 - $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
5323 - if (!empty($formatted_value)) {
5324 - // Convert field name to readable label
5325 - $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
5326 - $acf_content_parts[] = $field_label . ": " . $formatted_value;
5327 - }
6470 + foreach ($acf_fields as $field_name => $field_value) {
6471 + $formatted_value = $this->mxchat_format_acf_field_value($field_value, $field_name, $post_id);
6472 +
6473 + if (!empty($formatted_value)) {
6474 + // Both separators: a hyphenated ACF name should read as words.
6475 + $field_label = ucwords(str_replace(['_', '-'], ' ', $field_name));
6476 + $acf_content_parts[] = $field_label . ": " . $formatted_value;
5328 6477 }
5329 6478
5330 - if (!empty($acf_content_parts)) {
5331 - $final_content .= "\n\n" . implode("\n", $acf_content_parts);
6479 + // Walk this field's value tree for any PDF attachment references and
6480 + // queue them for extraction — only when this caller's PDF option is on.
6481 + if ($extract_acf_pdfs) {
6482 + $this->mxchat_collect_pdf_attachment_ids_from_acf_value($field_value, $pdf_attachment_ids);
5332 6483 }
5333 6484 }
5334 6485
5335 - // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
5336 - $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
5337 - if (!empty($custom_meta)) {
5338 - $meta_content_parts = array();
6486 + if (!empty($acf_content_parts)) {
6487 + $content .= "\n\n" . implode("\n", $acf_content_parts);
6488 + }
5339 6489
5340 - foreach ($custom_meta as $meta_key => $meta_value) {
5341 - // Convert meta key to readable label
5342 - $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
5343 - $meta_content_parts[] = $meta_label . ": " . $meta_value;
6490 + // Extract text from each unique PDF found in ACF fields and append as a labeled section
6491 + if ($extract_acf_pdfs && !empty($pdf_attachment_ids)) {
6492 + $pdf_attachment_ids = array_unique(array_filter(array_map('intval', $pdf_attachment_ids)));
6493 + $pdf_sections = array();
6494 + foreach ($pdf_attachment_ids as $att_id) {
6495 + $pdf_text = $this->mxchat_extract_pdf_text_by_attachment_id($att_id);
6496 + if (!empty($pdf_text)) {
6497 + $pdf_title = get_the_title($att_id);
6498 + $pdf_url = wp_get_attachment_url($att_id);
6499 + $header = 'PDF Attachment';
6500 + if (!empty($pdf_title)) {
6501 + $header .= ': ' . $pdf_title;
6502 + }
6503 + if (!empty($pdf_url)) {
6504 + $header .= ' (' . $pdf_url . ')';
6505 + }
6506 + $pdf_sections[] = $header . "\n" . $pdf_text;
6507 + $pdf_extracted_count++;
6508 + }
5344 6509 }
5345 -
5346 - if (!empty($meta_content_parts)) {
5347 - $final_content .= "\n\n" . implode("\n", $meta_content_parts);
6510 + if (!empty($pdf_sections)) {
6511 + $content .= "\n\nAttached PDFs:\n" . implode("\n\n", $pdf_sections);
5348 6512 }
5349 6513 }
6514 + }
5350 6515
5351 - // Get API key with proper model detection
5352 - $options = get_option('mxchat_options');
5353 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
5354 -
5355 - if (strpos($selected_model, 'voyage') === 0) {
5356 - $api_key = $options['voyage_api_key'] ?? '';
5357 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
5358 - $api_key = $options['gemini_api_key'] ?? '';
5359 - } else {
5360 - $api_key = $options['api_key'] ?? '';
6516 + // ADD CUSTOM POST META SUPPORT (whitelisted non-ACF meta fields)
6517 + $custom_meta = $this->mxchat_get_whitelisted_post_meta($post_id);
6518 + if (!empty($custom_meta)) {
6519 + $meta_content_parts = array();
6520 +
6521 + foreach ($custom_meta as $meta_key => $meta_value) {
6522 + // Convert meta key to readable label
6523 + $meta_label = ucwords(str_replace(array('_', '-'), ' ', $meta_key));
6524 + $meta_content_parts[] = $meta_label . ": " . $meta_value;
5361 6525 }
5362 -
5363 - if (empty($api_key)) {
5364 - return;
6526 +
6527 + if (!empty($meta_content_parts)) {
6528 + $content .= "\n\n" . implode("\n", $meta_content_parts);
5365 6529 }
5366 -
5367 - // Use the centralized utility function for storage
5368 - $result = MxChat_Utils::submit_content_to_db(
5369 - $final_content,
5370 - $source_url,
5371 - $api_key,
5372 - md5($source_url) // Vector ID for Pinecone
5373 - );
5374 -
5375 - // After successful storage, apply role restriction based on tags
5376 - if (!is_wp_error($result)) {
5377 - $this->apply_role_restriction_to_post($post_id, $source_url);
6530 + }
6531 +
6532 + return array(
6533 + 'content' => $content,
6534 + 'acf_fields_found' => count($acf_fields),
6535 + 'pdf_extracted_count' => $pdf_extracted_count,
6536 + );
6537 +}
6538 +
6539 +/**
6540 + * Shared WC-object product assembler (plan a3d60c) — the ONE body behind the two
6541 + * WooCommerce-object ingestion paths: the auto-sync product writer
6542 + * (mxchat_store_product_embedding) and the URL/sitemap product import
6543 + * (mxchat_extract_woocommerce_product_content). Assembles from the WC_Product,
6544 + * the authoritative source for product rows (scope decision on the plan).
6545 + */
6546 +private function mxchat_prepare_product_content_for_indexing($product) {
6547 + $title = $product->get_name();
6548 + $description = $product->get_description();
6549 + $short_description = $product->get_short_description();
6550 +
6551 + // Format content consistently
6552 + $content = $title . "\n\n";
6553 +
6554 + if (!empty($short_description)) {
6555 + $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
6556 + }
6557 +
6558 + if (!empty($description)) {
6559 + $content .= wp_strip_all_tags($description) . "\n\n";
6560 + }
6561 +
6562 + $content .= $this->mxchat_woo_product_summary_lines($product);
6563 + $content .= $this->mxchat_woo_custom_tabs_text($product->get_id());
6564 +
6565 + return $content;
6566 +}
6567 +
6568 +/**
6569 + * Pricing + SKU + categories lines for a product — shared by both assembler kinds
6570 + * (the post-fields product enrichment and the WC-object assembler).
6571 + */
6572 +private function mxchat_woo_product_summary_lines($product) {
6573 + // Add pricing information (base-currency pinned — see mxchat_product_price_lines)
6574 + $lines = $this->mxchat_product_price_lines($product);
6575 +
6576 + $sku = $product->get_sku();
6577 + if (!empty($sku)) {
6578 + $lines .= "SKU: " . $sku . "\n";
6579 + }
6580 +
6581 + // Get product categories
6582 + $categories = wp_get_post_terms($product->get_id(), 'product_cat', array('fields' => 'names'));
6583 + if (!empty($categories) && !is_wp_error($categories)) {
6584 + $lines .= "Categories: " . implode(', ', $categories) . "\n";
6585 + }
6586 +
6587 + return $lines;
6588 +}
6589 +
6590 +/**
6591 + * Custom Product Tabs text (supports "Custom Product Tabs for WooCommerce" by
6592 + * Code Parrots) — direct tabs plus applied reusable/saved tabs. The ONE copy of
6593 + * the yikes_woo logic; three sites carried byte-identical clones before a3d60c.
6594 + */
6595 +private function mxchat_woo_custom_tabs_text($product_id) {
6596 + $text = '';
6597 +
6598 + $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
6599 + if (!empty($custom_tabs) && is_array($custom_tabs)) {
6600 + foreach ($custom_tabs as $tab) {
6601 + $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
6602 + $tab_content = isset($tab['content']) ? $tab['content'] : '';
6603 +
6604 + if (!empty($tab_title) && !empty($tab_content)) {
6605 + $text .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
6606 + }
5378 6607 }
5379 6608 }
5380 -
5381 - // Clean up the stored previous status if not used above
5382 - if ($previous_status !== 'publish' || $post->post_status === 'publish') {
5383 - delete_transient($previous_status_key);
5384 - delete_transient($previous_url_key);
6609 +
6610 + // Also check for reusable/saved tabs applied to this product
6611 + $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
6612 + if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
6613 + $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
6614 + if (!empty($saved_tabs) && is_array($saved_tabs)) {
6615 + foreach ($applied_saved_tabs as $saved_tab_id) {
6616 + if (isset($saved_tabs[$saved_tab_id])) {
6617 + $tab = $saved_tabs[$saved_tab_id];
6618 + $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
6619 + $tab_content = isset($tab['content']) ? $tab['content'] : '';
6620 +
6621 + if (!empty($tab_title) && !empty($tab_content)) {
6622 + $text .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
6623 + }
6624 + }
6625 + }
6626 + }
5385 6627 }
6628 +
6629 + return $text;
5386 6630 }
5387 6631
5388 6632 /**
5389 6633 * Store the post status and URL before update to detect status transitions
@@ -5389,8 +6633,12 @@
5389 6633 * Store the post status and URL before update to detect status transitions
5390 6634 * This runs before the post is actually updated in the database
5391 6635 */
5392 6636 public function mxchat_store_pre_update_status($post_id, $data) {
6637 + // Core is inside wp_insert_post's update branch, so a post_updated WILL fire later
6638 + // this request and can consume the arrival-edge guard (plan a664f3).
6639 + $this->pending_post_update[$post_id] = true;
6640 +
5393 6641 // Get the current post from database (before update)
5394 6642 $current_post = get_post($post_id);
5395 6643
5396 6644 if ($current_post) {
@@ -5406,8 +6654,407 @@
5406 6654 }
5407 6655 }
5408 6656 }
5409 6657
6658 +/**
6659 + * Whether auto-sync is enabled for a post type (mirrors the checks used by the
6660 + * update/delete handlers; kept as one helper so new call sites cannot drift).
6661 + */
6662 +private function mxchat_is_auto_sync_enabled($post_type) {
6663 + if ($post_type === 'post') {
6664 + return get_option('mxchat_auto_sync_posts') === '1';
6665 + }
6666 + if ($post_type === 'page') {
6667 + return get_option('mxchat_auto_sync_pages') === '1';
6668 + }
6669 + return get_option('mxchat_auto_sync_' . $post_type) === '1';
6670 +}
6671 +
6672 +/**
6673 + * Remove a post's vectors the moment it leaves 'publish', using the authoritative
6674 + * old status core passes to transition_post_status — no transient involved (plan 816fb1).
6675 + *
6676 + * Covers status changes that never route through wp_update_post (scheduled-expiry
6677 + * plugins and others that flip post_status directly and call wp_transition_post_status),
6678 + * where neither pre_post_update nor post_updated fires and the old detection missed.
6679 + */
6680 +public function mxchat_handle_status_transition($new_status, $old_status, $post) {
6681 + if (!($post instanceof WP_Post) || wp_is_post_revision($post->ID)) {
6682 + return;
6683 + }
6684 +
6685 + // Arrival edge (plan 3055e1): a post BECOMING published is indexed here, because
6686 + // wp_publish_post() — the path scheduled posts take via check_and_publish_future_post —
6687 + // and direct wp_insert_post(status=publish) creates never fire post_updated, so the
6688 + // auto-sync ADD path alone misses them. Editor publishes also pass through here;
6689 + // the transition_indexed_posts guard keeps mxchat_handle_post_update from embedding
6690 + // a second time in the same request.
6691 + if ($new_status === 'publish' && $old_status !== 'publish') {
6692 + if ($this->mxchat_is_auto_sync_enabled($post->post_type)) {
6693 + $this->mxchat_index_published_post($post->ID, $post);
6694 +
6695 + // Arm the double-fire guard ONLY when a post_updated is actually coming to
6696 + // consume it (plan a664f3). Two publish paths never fire post_updated at all:
6697 + // a direct wp_insert_post(status=publish) create, and wp_publish_post() — the
6698 + // call check_and_publish_future_post() makes for scheduled posts. Arming the
6699 + // guard unconditionally left it set with nothing to consume it, so the NEXT
6700 + // update of that post was swallowed entirely: zero embed calls, no knowledge
6701 + // -base row, silently. Consume-once on this side too, so a guard can never
6702 + // outlive the single save it was armed for.
6703 + if (!empty($this->pending_post_update[$post->ID])) {
6704 + unset($this->pending_post_update[$post->ID]);
6705 + $this->transition_indexed_posts[$post->ID] = true;
6706 + }
6707 + }
6708 + return;
6709 + }
6710 +
6711 + // Only the publish -> not-publish edge matters here.
6712 + if ($old_status !== 'publish' || $new_status === 'publish') {
6713 + return;
6714 + }
6715 + // Trash is handled by mxchat_handle_post_delete (wp_trash_post) with pre-trash URL
6716 + // resolution; skip to avoid a second network round-trip per trash.
6717 + if ($new_status === 'trash') {
6718 + return;
6719 + }
6720 + if (!$this->mxchat_is_auto_sync_enabled($post->post_type)) {
6721 + return;
6722 + }
6723 +
6724 + $urls = array();
6725 +
6726 + // The DB may already hold the new status when this fires, so get_permalink() on the
6727 + // live post could build a draft-style URL whose md5 misses the stored vector IDs.
6728 + // Reconstruct the published permalink from a clone instead.
6729 + $published_clone = clone $post;
6730 + $published_clone->post_status = 'publish';
6731 + $published_url = get_permalink($published_clone);
6732 + if ($published_url) {
6733 + $urls[] = $published_url;
6734 + }
6735 +
6736 + // Honour the pre-update capture when present (covers a slug change in the same save).
6737 + $previous_url = get_transient('mxchat_prev_url_' . $post->ID);
6738 + if (!empty($previous_url)) {
6739 + $urls[] = $previous_url;
6740 + }
6741 +
6742 + foreach (array_unique($urls) as $url) {
6743 + MxChat_Utils::delete_chunks_for_url($url, 'default');
6744 + }
6745 +
6746 + if (!empty($urls)) {
6747 + $this->transition_deleted_posts[$post->ID] = true;
6748 + }
6749 +}
6750 +
6751 +/**
6752 + * WP-CLI: remove knowledge-base entries left behind by posts that were unpublished,
6753 + * trashed, or made private before the transition_post_status handler existed.
6754 + *
6755 + * Walks every auto-synced post type's non-published posts, reconstructs each one's
6756 + * published-era permalink, and deletes its vectors (routes to Pinecone or the WP table).
6757 + * Deletion is idempotent, so never-indexed posts are a cheap no-op.
6758 + *
6759 + * ## OPTIONS
6760 + *
6761 + * [--dry-run]
6762 + * : Report what would be removed without deleting anything.
6763 + *
6764 + * ## EXAMPLES
6765 + *
6766 + * wp mxchat prune-unpublished --dry-run
6767 + * wp mxchat prune-unpublished
6768 + */
6769 +public function cli_prune_unpublished($args, $assoc_args) {
6770 + global $wpdb;
6771 + $dry_run = !empty($assoc_args['dry-run']);
6772 + $table = $wpdb->prefix . 'mxchat_system_prompt_content';
6773 +
6774 + $candidate_types = array_merge(array('post', 'page'), array_values(get_post_types(array('_builtin' => false), 'names')));
6775 + $synced_types = array();
6776 + foreach ($candidate_types as $type) {
6777 + if ($this->mxchat_is_auto_sync_enabled($type)) {
6778 + $synced_types[] = $type;
6779 + }
6780 + }
6781 + if (empty($synced_types)) {
6782 + WP_CLI::success('No post types have auto-sync enabled; nothing to prune.');
6783 + return;
6784 + }
6785 +
6786 + $scanned = 0;
6787 + $pruned = 0;
6788 + $paged = 1;
6789 + do {
6790 + $query = new WP_Query(array(
6791 + 'post_type' => $synced_types,
6792 + 'post_status' => array('draft', 'pending', 'private', 'future', 'trash'),
6793 + 'posts_per_page' => 100,
6794 + 'paged' => $paged,
6795 + 'fields' => 'ids',
6796 + ));
6797 + foreach ($query->posts as $post_id) {
6798 + $post = get_post($post_id);
6799 + if (!$post) {
6800 + continue;
6801 + }
6802 + $scanned++;
6803 +
6804 + // Rebuild the permalink the post had while published: publish-status clone,
6805 + // with wp_trash_post's __trashed slug suffix stripped for trashed posts.
6806 + $clone = clone $post;
6807 + $clone->post_status = 'publish';
6808 + if (substr($clone->post_name, -9) === '__trashed') {
6809 + $clone->post_name = substr($clone->post_name, 0, -9);
6810 + }
6811 + $url = get_permalink($clone);
6812 + if (!$url) {
6813 + continue;
6814 + }
6815 +
6816 + // Local-table row count is exact in WordPress-DB mode; in Pinecone mode it
6817 + // reads 0 but the delete below still routes to Pinecone and is idempotent.
6818 + $local_rows = (int) $wpdb->get_var($wpdb->prepare(
6819 + "SELECT COUNT(*) FROM {$table} WHERE source_url = %s", $url
6820 + ));
6821 +
6822 + if ($dry_run) {
6823 + if ($local_rows > 0) {
6824 + WP_CLI::log(sprintf('Would remove %d row(s): %s (post %d, %s)', $local_rows, $url, $post_id, $post->post_status));
6825 + $pruned += $local_rows;
6826 + }
6827 + continue;
6828 + }
6829 +
6830 + MxChat_Utils::delete_chunks_for_url($url, 'default');
6831 + if ($local_rows > 0) {
6832 + WP_CLI::log(sprintf('Removed %d row(s): %s (post %d, %s)', $local_rows, $url, $post_id, $post->post_status));
6833 + $pruned += $local_rows;
6834 + }
6835 + }
6836 + $more = $paged < $query->max_num_pages;
6837 + $paged++;
6838 + } while ($more);
6839 +
6840 + WP_CLI::success(sprintf(
6841 + '%s %d local knowledge row(s) across %d non-published post(s) scanned.%s',
6842 + $dry_run ? 'Would remove' : 'Removed',
6843 + $pruned,
6844 + $scanned,
6845 + ' (Pinecone-mode deletions are not counted locally.)'
6846 + ));
6847 +}
6848 +
6849 +/**
6850 + * WP-CLI: repair knowledge-base rows whose PDF text was imported in visual
6851 + * (reversed) order before the RTL normalizer existed. 32bf9e fixed new
6852 + * imports only; this fixes rows already in the table without the customer
6853 + * having to re-source and re-upload the original PDFs (plan d1e6f7).
6854 + *
6855 + * Detection reuses MxChat_Utils::normalize_pdf_rtl() on the stored text: a
6856 + * row is a candidate exactly when the normalizer would change it, so the
6857 + * import-time heuristic and the repair heuristic can never disagree.
6858 + * Repaired rows are RE-EMBEDDED — the stored vector was computed over
6859 + * reversed text and is as broken as the text — so a wet run calls the
6860 + * embedding provider once per repaired row on the site's API key. Runs
6861 + * beyond 25 rows therefore require --yes.
6862 + *
6863 + * Scope notes:
6864 + * - Scans the WordPress knowledge table. Pinecone-mode entries live in
6865 + * Pinecone, not this table, and are not scanned; if a scanned row's bot
6866 + * ALSO has Pinecone enabled (hybrid drift), the repaired entry is
6867 + * re-submitted through the normal import path so the md5-keyed Pinecone
6868 + * vector is replaced too.
6869 + * - Knowledge rows do not carry a bot id; --bot only selects whose
6870 + * embedding configuration (model + key) is used for re-embedding.
6871 + * - The mxchat_pdf_rtl_normalize filter is honoured: a site that disabled
6872 + * normalization gets detections of zero, not surprise rewrites.
6873 + * - The metadata header the PDF importer stores before the text separator
6874 + * is preserved byte-identical; only the text segment is repaired.
6875 + *
6876 + * ## OPTIONS
6877 + *
6878 + * [--dry-run]
6879 + * : List the rows that would be repaired without changing anything.
6880 + *
6881 + * [--bot=<id>]
6882 + * : Embedding configuration to use for re-embedding. Default: default.
6883 + *
6884 + * [--all-content]
6885 + * : Scan every row containing right-to-left text, not just rows with PDF
6886 + * provenance (a page anchor in the source URL, or pdf content type).
6887 + *
6888 + * [--yes]
6889 + * : Proceed even when more than 25 rows need re-embedding (API cost gate).
6890 + *
6891 + * ## EXAMPLES
6892 + *
6893 + * wp mxchat rtl-repair --dry-run
6894 + * wp mxchat rtl-repair
6895 + * wp mxchat rtl-repair --all-content --yes
6896 + */
6897 +public function cli_rtl_repair($args, $assoc_args) {
6898 + global $wpdb;
6899 + $dry_run = !empty($assoc_args['dry-run']);
6900 + $all = !empty($assoc_args['all-content']);
6901 + $yes = !empty($assoc_args['yes']);
6902 + $bot_id = isset($assoc_args['bot']) ? sanitize_key($assoc_args['bot']) : 'default';
6903 + $table = $wpdb->prefix . 'mxchat_system_prompt_content';
6904 +
6905 + // Detection pass — no API calls. Walk the table in id batches so a large
6906 + // knowledge base never loads at once.
6907 + $rtl_re = '/[\x{0590}-\x{05FF}\x{0600}-\x{06FF}\x{0750}-\x{077F}\x{FB50}-\x{FDFF}\x{FE70}-\x{FEFF}]/u';
6908 + $candidates = array();
6909 + $scanned = 0;
6910 + $last_id = 0;
6911 + do {
6912 + if ($all) {
6913 + $rows = $wpdb->get_results($wpdb->prepare(
6914 + "SELECT id, article_content, source_url, content_type FROM {$table}
6915 + WHERE id > %d ORDER BY id ASC LIMIT 200",
6916 + $last_id
6917 + ));
6918 + } else {
6919 + $rows = $wpdb->get_results($wpdb->prepare(
6920 + "SELECT id, article_content, source_url, content_type FROM {$table}
6921 + WHERE id > %d AND (source_url LIKE %s OR content_type = 'pdf')
6922 + ORDER BY id ASC LIMIT 200",
6923 + $last_id,
6924 + '%' . $wpdb->esc_like('#page=') . '%'
6925 + ));
6926 + }
6927 + foreach ($rows as $row) {
6928 + $last_id = (int) $row->id;
6929 + $scanned++;
6930 + $content = (string) $row->article_content;
6931 + if (!preg_match($rtl_re, $content)) {
6932 + continue;
6933 + }
6934 + list($header, $text) = $this->mxchat_rtl_repair_split($content);
6935 + $normalized = MxChat_Utils::normalize_pdf_rtl($text, 'rtl-repair row ' . $row->id);
6936 + if (is_string($normalized) && $normalized !== $text) {
6937 + $candidates[] = array(
6938 + 'id' => (int) $row->id,
6939 + 'source_url' => (string) $row->source_url,
6940 + 'content_type' => (string) $row->content_type,
6941 + 'new_content' => $header . $normalized,
6942 + );
6943 + }
6944 + }
6945 + } while (count($rows) === 200);
6946 +
6947 + WP_CLI::log(sprintf('Scanned %d row(s); %d stored in reversed (visual) order.', $scanned, count($candidates)));
6948 + if (empty($candidates)) {
6949 + WP_CLI::success('No reversed RTL rows found — nothing to repair.');
6950 + return;
6951 + }
6952 +
6953 + foreach ($candidates as $c) {
6954 + WP_CLI::log(sprintf('%s row %d %s', $dry_run ? 'Would repair' : 'Will repair', $c['id'], $c['source_url']));
6955 + }
6956 + if ($dry_run) {
6957 + WP_CLI::success(sprintf('Dry run: %d row(s) would be repaired and re-embedded. Run without --dry-run to apply.', count($candidates)));
6958 + return;
6959 + }
6960 +
6961 + // Cost gate: re-embedding spends the customer's API budget.
6962 + 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)));
6963 + if (count($candidates) > 25 && !$yes) {
6964 + 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)));
6965 + }
6966 +
6967 + $bot_options = $this->get_bot_options($bot_id);
6968 + $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
6969 + $preflight = MxChat_Utils::embedding_preflight($options);
6970 + if (!$preflight['ok']) {
6971 + WP_CLI::error('Embedding configuration problem: ' . $preflight['reason']);
6972 + }
6973 + $api_key = $preflight['api_key'];
6974 +
6975 + $pinecone_hybrid = $this->mxchat_rtl_repair_pinecone_enabled($bot_id);
6976 + $repaired = 0;
6977 + $failed = 0;
6978 + foreach ($candidates as $c) {
6979 + $vector = MxChat_Utils::regenerate_embedding($c['new_content'], $api_key, $bot_id);
6980 + if (!is_array($vector)) {
6981 + $failed++;
6982 + $reason = is_wp_error($vector) ? $vector->get_error_message() : 'embedding request failed';
6983 + // Text and vector must stay consistent: never write repaired text
6984 + // beside the stale reversed-text vector.
6985 + WP_CLI::warning(sprintf('Row %d NOT repaired — %s. Row left unchanged.', $c['id'], $reason));
6986 + continue;
6987 + }
6988 + $wpdb->update(
6989 + $table,
6990 + array(
6991 + 'article_content' => $c['new_content'],
6992 + 'embedding_vector' => maybe_serialize($vector),
6993 + ),
6994 + array('id' => $c['id']),
6995 + array('%s', '%s'),
6996 + array('%d')
6997 + );
6998 + $repaired++;
6999 + if (class_exists('MxChat_Admin')) {
7000 + MxChat_Admin::mxchat_log_debug('pdf_rtl_repaired', 'Stored KB row restored to logical order and re-embedded', array(
7001 + 'row_id' => $c['id'],
7002 + 'source_url' => $c['source_url'],
7003 + 'bot' => $bot_id,
7004 + ));
7005 + }
7006 + // Hybrid drift: the bot indexes into Pinecone but this row sat in the
7007 + // WP table — push the repaired entry through the normal import path so
7008 + // the md5(source_url)-keyed Pinecone vector is replaced as well.
7009 + if ($pinecone_hybrid) {
7010 + MxChat_Utils::submit_content_to_db(
7011 + $c['new_content'],
7012 + $c['source_url'],
7013 + $api_key,
7014 + null,
7015 + $bot_id,
7016 + $c['content_type'] !== '' ? $c['content_type'] : 'pdf'
7017 + );
7018 + }
7019 + }
7020 +
7021 + WP_CLI::success(sprintf('Repaired + re-embedded %d row(s); %d failed; %d scanned.', $repaired, $failed, $scanned));
7022 +}
7023 +
7024 +/**
7025 + * Split a stored KB row into (metadata header incl. separator, text segment).
7026 + * The PDF importer stores wp_json_encode($metadata) . "\n---\n" . $text —
7027 + * repair must touch only the text and keep the header byte-identical.
7028 + */
7029 +private function mxchat_rtl_repair_split($content) {
7030 + $sep = "\n---\n";
7031 + $pos = strpos($content, $sep);
7032 + if ($pos !== false && $pos > 0 && $content[0] === '{') {
7033 + $maybe_json = substr($content, 0, $pos);
7034 + if (json_decode($maybe_json) !== null) {
7035 + return array(substr($content, 0, $pos + strlen($sep)), substr($content, $pos + strlen($sep)));
7036 + }
7037 + }
7038 + return array('', $content);
7039 +}
7040 +
7041 +/**
7042 + * Mirror of MxChat_Utils::is_pinecone_enabled_for_bot() (private there) for
7043 + * the repair CLI's hybrid-drift check.
7044 + */
7045 +private function mxchat_rtl_repair_pinecone_enabled($bot_id) {
7046 + if ($bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager')) {
7047 + $cfg = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
7048 + if (!empty($cfg)) {
7049 + return !empty($cfg['use_pinecone']) && !empty($cfg['api_key']) && !empty($cfg['host']);
7050 + }
7051 + }
7052 + $po = get_option('mxchat_pinecone_addon_options');
7053 + return !empty($po['mxchat_use_pinecone']) && $po['mxchat_use_pinecone'] !== '0'
7054 + && !empty($po['mxchat_pinecone_api_key']) && !empty($po['mxchat_pinecone_host']);
7055 +}
7056 +
5410 7057 public function mxchat_handle_post_delete($post_id) {
5411 7058 // Get post data before it's deleted
5412 7059 $post = get_post($post_id);
5413 7060
@@ -5507,110 +7154,20 @@
5507 7154
5508 7155 $source_url = get_permalink($product->get_id());
5509 7156 $product_id = $product->get_id();
5510 7157
5511 - // Build product content
5512 - $title = $product->get_name();
5513 - $description = $product->get_description();
5514 - $short_description = $product->get_short_description();
5515 - $regular_price = $product->get_regular_price();
5516 - $sale_price = $product->get_sale_price();
5517 - $price = $product->get_price();
5518 - $sku = $product->get_sku();
7158 + // Build product content via the shared WC-object assembler (a3d60c) — this
7159 + // writer owns product rows whenever the integration is on.
7160 + $content = $this->mxchat_prepare_product_content_for_indexing($product);
5519 7161
5520 - // Get currency symbol
5521 - $currency_symbol = get_woocommerce_currency_symbol();
5522 -
5523 - // Format content consistently
5524 - $content = $title . "\n\n";
5525 -
5526 - if (!empty($short_description)) {
5527 - $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
5528 - }
5529 -
5530 - if (!empty($description)) {
5531 - $content .= wp_strip_all_tags($description) . "\n\n";
5532 - }
5533 -
5534 - // Add pricing information
5535 - if (!empty($regular_price)) {
5536 - $content .= "Price: " . $currency_symbol . $regular_price . "\n";
5537 - } elseif (!empty($price)) {
5538 - $content .= "Price: " . $currency_symbol . $price . "\n";
5539 - }
5540 -
5541 - if (!empty($sale_price) && $sale_price !== $regular_price) {
5542 - $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
5543 - }
5544 -
5545 - // Handle variable products - show price range
5546 - if ($product->is_type('variable')) {
5547 - $min_price = $product->get_variation_price('min');
5548 - $max_price = $product->get_variation_price('max');
5549 - if ($min_price !== $max_price) {
5550 - $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
5551 - }
5552 - }
5553 -
5554 - if (!empty($sku)) {
5555 - $content .= "SKU: " . $sku . "\n";
5556 - }
5557 -
5558 - // Get product categories
5559 - $categories = wp_get_post_terms($product_id, 'product_cat', array('fields' => 'names'));
5560 - if (!empty($categories) && !is_wp_error($categories)) {
5561 - $content .= "Categories: " . implode(', ', $categories) . "\n";
5562 - }
5563 -
5564 - // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
5565 - $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
5566 - if (!empty($custom_tabs) && is_array($custom_tabs)) {
5567 - foreach ($custom_tabs as $tab) {
5568 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
5569 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
5570 -
5571 - if (!empty($tab_title) && !empty($tab_content)) {
5572 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
5573 - }
5574 - }
5575 - }
5576 -
5577 - // Also check for reusable/saved tabs applied to this product
5578 - $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
5579 - if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
5580 - // Get the saved tabs option
5581 - $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
5582 - if (!empty($saved_tabs) && is_array($saved_tabs)) {
5583 - foreach ($applied_saved_tabs as $saved_tab_id) {
5584 - if (isset($saved_tabs[$saved_tab_id])) {
5585 - $tab = $saved_tabs[$saved_tab_id];
5586 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
5587 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
5588 -
5589 - if (!empty($tab_title) && !empty($tab_content)) {
5590 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
5591 - }
5592 - }
5593 - }
5594 - }
5595 - }
5596 -
5597 - // Get API key with proper model detection
5598 - $options = get_option('mxchat_options');
5599 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
5600 -
5601 - if (strpos($selected_model, 'voyage') === 0) {
5602 - $api_key = $options['voyage_api_key'] ?? '';
5603 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
5604 - $api_key = $options['gemini_api_key'] ?? '';
5605 - } else {
5606 - $api_key = $options['api_key'] ?? '';
5607 - }
5608 -
5609 - if (empty($api_key)) {
5610 - //error_log('MxChat Auto-sync: No API key configured for embedding model');
7162 + // Embedding decision — custom-provider-aware (plan cbd5fd); silent-return
7163 + // shape preserved.
7164 + $preflight = MxChat_Utils::embedding_preflight(get_option('mxchat_options'));
7165 + if (!$preflight['ok']) {
7166 + //error_log('MxChat Auto-sync: embedding pre-flight failed: ' . $preflight['reason']);
5611 7167 return;
5612 7168 }
7169 + $api_key = $preflight['api_key'];
5613 7170
5614 7171 // Use the centralized utility function for storage
5615 7172 $result = MxChat_Utils::submit_content_to_db(
5616 7173 $content,
@@ -5686,17 +7243,23 @@
5686 7243
5687 7244 // Delete from Pinecone
5688 7245 $pinecone_manager = MxChat_Pinecone_Manager::get_instance();
5689 7246 $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
5690 - $vector_id,
5691 - $pinecone_options['mxchat_pinecone_api_key'],
5692 - $pinecone_options['mxchat_pinecone_host']
7247 + $vector_id,
7248 + $pinecone_options['mxchat_pinecone_api_key'],
7249 + $pinecone_options['mxchat_pinecone_host'],
7250 + $pinecone_options['mxchat_pinecone_namespace'] ?? ''
5693 7251 );
5694 7252
5695 7253 if ($result['success']) {
7254 + // Mirror the removal to the OpenAI Vector Store mapping (plan 15b5c6);
7255 + // a chunk vector id reduces to its base entry there.
7256 + if (class_exists('MxChat_Vectorstore_Manager')) {
7257 + MxChat_Vectorstore_Manager::sync_delete_by_key($vector_id, 'default');
7258 + }
5696 7259 // No cache clearing needed since we removed caching
5697 - set_transient('mxchat_admin_notice_success',
5698 - esc_html__('Entry deleted successfully from Pinecone.', 'mxchat'),
7260 + set_transient('mxchat_admin_notice_success',
7261 + esc_html__('Entry deleted successfully from Pinecone.', 'mxchat'),
5699 7262 30
5700 7263 );
5701 7264 } else {
5702 7265 set_transient('mxchat_admin_notice_error',
@@ -5741,16 +7304,21 @@
5741 7304 wp_send_json_error('Pinecone is not properly configured for bot: ' . $bot_id);
5742 7305 exit;
5743 7306 }
5744 7307
5745 - // Delete from the correct Pinecone index
7308 + // Delete from the correct Pinecone index and namespace
5746 7309 $result = $pinecone_manager->mxchat_delete_from_pinecone_by_vector_id(
5747 - $vector_id,
5748 - $pinecone_options['mxchat_pinecone_api_key'],
5749 - $pinecone_options['mxchat_pinecone_host']
7310 + $vector_id,
7311 + $pinecone_options['mxchat_pinecone_api_key'],
7312 + $pinecone_options['mxchat_pinecone_host'],
7313 + $pinecone_options['mxchat_pinecone_namespace'] ?? ''
5750 7314 );
5751 7315
5752 7316 if ($result['success']) {
7317 + // Mirror the removal to the OpenAI Vector Store mapping (plan 15b5c6)
7318 + if (class_exists('MxChat_Vectorstore_Manager')) {
7319 + MxChat_Vectorstore_Manager::sync_delete_by_key($vector_id, $bot_id);
7320 + }
5753 7321 // No cache clearing needed since we removed caching
5754 7322 wp_send_json_success(array(
5755 7323 'message' => 'Entry deleted successfully from Pinecone',
5756 7324 'vector_id' => $vector_id,
@@ -5849,8 +7417,13 @@
5849 7417 }
5850 7418 }
5851 7419
5852 7420 if (empty($vectors_to_delete)) {
7421 + // Entry already gone from Pinecone — still clear any mirrored
7422 + // Vector Store file so it can't outlive the entry (plan 15b5c6).
7423 + if (class_exists('MxChat_Vectorstore_Manager')) {
7424 + MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
7425 + }
5853 7426 wp_send_json_success(array(
5854 7427 'message' => 'No vectors found to delete',
5855 7428 'source_url' => $source_url
5856 7429 ));
@@ -5891,8 +7464,13 @@
5891 7464 wp_send_json_error('Pinecone API error (HTTP ' . $response_code . ')');
5892 7465 exit;
5893 7466 }
5894 7467
7468 + // Mirror the removal to the OpenAI Vector Store (plan 15b5c6)
7469 + if (class_exists('MxChat_Vectorstore_Manager')) {
7470 + MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
7471 + }
7472 +
5895 7473 wp_send_json_success(array(
5896 7474 'message' => 'All chunks deleted successfully from Pinecone',
5897 7475 'source_url' => $source_url,
5898 7476 'deleted_count' => count($vectors_to_delete)
@@ -5914,8 +7492,13 @@
5914 7492 wp_send_json_error('Failed to delete from database: ' . $wpdb->last_error);
5915 7493 exit;
5916 7494 }
5917 7495
7496 + // Mirror the removal to the OpenAI Vector Store (plan 15b5c6)
7497 + if (class_exists('MxChat_Vectorstore_Manager')) {
7498 + MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
7499 + }
7500 +
5918 7501 wp_send_json_success(array(
5919 7502 'message' => 'All chunks deleted successfully from database',
5920 7503 'source_url' => $source_url,
5921 7504 'deleted_count' => $result
@@ -5950,8 +7533,15 @@
5950 7533
5951 7534 global $wpdb;
5952 7535 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
5953 7536
7537 + // Capture the identity BEFORE the row disappears — needed to mirror the
7538 + // change into the Vector Store (plan 15b5c6).
7539 + $source_url = $wpdb->get_var($wpdb->prepare(
7540 + "SELECT source_url FROM {$table_name} WHERE id = %d",
7541 + $entry_id
7542 + ));
7543 +
5954 7544 // Clear cache for this entry
5955 7545 wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
5956 7546
5957 7547 // Delete from database
@@ -5961,8 +7551,23 @@
5961 7551 array('%d')
5962 7552 );
5963 7553
5964 7554 if ($result !== false) {
7555 + // Mirror to the Vector Store: if sibling rows remain (this was one
7556 + // chunk of a larger entry) the entry's file is REFRESHED from what's
7557 + // left; if none remain, the file is removed.
7558 + if (!empty($source_url) && class_exists('MxChat_Vectorstore_Manager')) {
7559 + $remaining = (int) $wpdb->get_var($wpdb->prepare(
7560 + "SELECT COUNT(*) FROM {$table_name} WHERE source_url = %s",
7561 + $source_url
7562 + ));
7563 + if ($remaining > 0) {
7564 + MxChat_Vectorstore_Manager::sync_upsert_entry($source_url, '', 'default');
7565 + } else {
7566 + MxChat_Vectorstore_Manager::sync_delete_entry($source_url, 'default');
7567 + }
7568 + }
7569 +
5965 7570 wp_send_json_success(array(
5966 7571 'message' => 'Entry deleted successfully',
5967 7572 'entry_id' => $entry_id
5968 7573 ));
@@ -6015,8 +7620,9 @@
6015 7620 $use_pinecone = ($pinecone_options['mxchat_use_pinecone'] ?? '0') === '1';
6016 7621
6017 7622 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
6018 7623 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
7624 + $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
6019 7625
6020 7626 // =============================================
6021 7627 // PHASE 1: Collect all Pinecone vector IDs
6022 7628 // and separate WordPress entries
@@ -6023,8 +7629,10 @@
6023 7629 // =============================================
6024 7630 $pinecone_entry_ids = array(); // entry IDs that are pinecone-sourced
6025 7631 $wordpress_entries = array(); // entries for WordPress DB deletion
6026 7632 $all_vector_ids = array(); // all pinecone vector IDs to delete in one batch
7633 + $vs_mirror_urls = array(); // Vector Store mirror: URLs to delete (plan 15b5c6)
7634 + $vs_mirror_keys = array(); // Vector Store mirror: bare vector ids to delete
6027 7635
6028 7636 foreach ($entries as $entry) {
6029 7637 $entry_id = sanitize_text_field($entry['id'] ?? '');
6030 7638 $source = sanitize_text_field($entry['source'] ?? 'wordpress');
@@ -6049,8 +7657,11 @@
6049 7657 $base_vector_id = md5($source_url);
6050 7658 $all_vector_ids[] = $base_vector_id;
6051 7659
6052 7660 $list_url = 'https://' . $host . '/vectors/list?prefix=' . urlencode($base_vector_id . '_chunk_') . '&limit=100';
7661 + if (!empty($namespace)) {
7662 + $list_url .= '&namespace=' . rawurlencode($namespace);
7663 + }
6053 7664 $list_response = wp_remote_get($list_url, array(
6054 7665 'headers' => array(
6055 7666 'Api-Key' => $api_key,
6056 7667 'accept' => 'application/json'
@@ -6071,8 +7682,14 @@
6071 7682 } else {
6072 7683 // Single entry: the entry_id IS the vector ID
6073 7684 $all_vector_ids[] = $entry_id;
6074 7685 }
7686 +
7687 + if (!empty($source_url)) {
7688 + $vs_mirror_urls[] = $source_url;
7689 + } else {
7690 + $vs_mirror_keys[] = $entry_id;
7691 + }
6075 7692 } else {
6076 7693 $wordpress_entries[] = $entry;
6077 7694 }
6078 7695 }
@@ -6085,8 +7702,12 @@
6085 7702 $pinecone_success = true;
6086 7703 $batches = array_chunk($all_vector_ids, 100);
6087 7704
6088 7705 foreach ($batches as $batch) {
7706 + $delete_body = array('ids' => $batch);
7707 + if (!empty($namespace)) {
7708 + $delete_body['namespace'] = $namespace;
7709 + }
6089 7710 $delete_response = wp_remote_post("https://{$host}/vectors/delete", array(
6090 7711 'headers' => array(
6091 7712 'Api-Key' => $api_key,
6092 7713 'accept' => 'application/json',
@@ -6091,9 +7712,9 @@
6091 7712 'Api-Key' => $api_key,
6092 7713 'accept' => 'application/json',
6093 7714 'content-type' => 'application/json'
6094 7715 ),
6095 - 'body' => wp_json_encode(array('ids' => $batch)),
7716 + 'body' => wp_json_encode($delete_body),
6096 7717 'timeout' => 60
6097 7718 ));
6098 7719
6099 7720 if (is_wp_error($delete_response)) {
@@ -6118,8 +7739,18 @@
6118 7739 } else {
6119 7740 $failed_ids[] = $eid;
6120 7741 }
6121 7742 }
7743 +
7744 + // Mirror the removals to the OpenAI Vector Store (plan 15b5c6)
7745 + if ($pinecone_success && class_exists('MxChat_Vectorstore_Manager')) {
7746 + foreach (array_unique($vs_mirror_urls) as $vs_url) {
7747 + MxChat_Vectorstore_Manager::sync_delete_entry($vs_url, $bot_id);
7748 + }
7749 + foreach (array_unique($vs_mirror_keys) as $vs_key) {
7750 + MxChat_Vectorstore_Manager::sync_delete_by_key($vs_key, $bot_id);
7751 + }
7752 + }
6122 7753 }
6123 7754
6124 7755 // =============================================
6125 7756 // PHASE 3: WordPress database deletions
@@ -6139,9 +7770,15 @@
6139 7770 $table_name,
6140 7771 array('source_url' => $source_url),
6141 7772 array('%s')
6142 7773 );
7774 + $row_url = $source_url;
6143 7775 } else {
7776 + // Identity captured pre-delete for the Vector Store mirror
7777 + $row_url = $wpdb->get_var($wpdb->prepare(
7778 + "SELECT source_url FROM {$table_name} WHERE id = %d",
7779 + intval($entry_id)
7780 + ));
6144 7781 wp_cache_delete('prompt_' . $entry_id, 'mxchat_prompts');
6145 7782 $result = $wpdb->delete(
6146 7783 $table_name,
6147 7784 array('id' => intval($entry_id)),
@@ -6150,8 +7787,21 @@
6150 7787 }
6151 7788
6152 7789 if ($result !== false) {
6153 7790 $success_ids[] = $entry_id;
7791 + // Mirror to the Vector Store: refresh the entry's file when
7792 + // sibling chunk rows survive, remove it when none do.
7793 + if (!empty($row_url) && class_exists('MxChat_Vectorstore_Manager')) {
7794 + $remaining = (int) $wpdb->get_var($wpdb->prepare(
7795 + "SELECT COUNT(*) FROM {$table_name} WHERE source_url = %s",
7796 + $row_url
7797 + ));
7798 + if ($remaining > 0) {
7799 + MxChat_Vectorstore_Manager::sync_upsert_entry($row_url, '', $bot_id);
7800 + } else {
7801 + MxChat_Vectorstore_Manager::sync_delete_entry($row_url, $bot_id);
7802 + }
7803 + }
6154 7804 } else {
6155 7805 $failed_ids[] = $entry_id;
6156 7806 $errors[] = "Database error for entry: $entry_id";
6157 7807 }
@@ -6303,9 +7953,29 @@
6303 7953 if ($result === false) {
6304 7954 wp_send_json_error('Database update failed: ' . $wpdb->last_error);
6305 7955 exit;
6306 7956 }
6307 -
7957 +
7958 + // Keep the OpenAI Vector Store mirror consistent with the new restriction
7959 + // (plan 15b5c6): non-public pulls the mirrored file, public re-mirrors.
7960 + if (class_exists('MxChat_Vectorstore_Manager')) {
7961 + if ($data_source === 'pinecone') {
7962 + if ($role_restriction !== 'public') {
7963 + MxChat_Vectorstore_Manager::sync_delete_by_key($entry_id, 'default');
7964 + }
7965 + // Public again: Pinecone-mode content isn't held locally, so the
7966 + // entry re-mirrors on its next save/import rather than here.
7967 + } else {
7968 + $row_url = $wpdb->get_var($wpdb->prepare(
7969 + "SELECT source_url FROM {$wpdb->prefix}mxchat_system_prompt_content WHERE id = %d",
7970 + absint($entry_id)
7971 + ));
7972 + if (!empty($row_url)) {
7973 + MxChat_Vectorstore_Manager::handle_role_change($row_url, 'default', $role_restriction);
7974 + }
7975 + }
7976 + }
7977 +
6308 7978 wp_send_json_success(array(
6309 7979 'message' => 'Role restriction updated successfully',
6310 7980 'role_restriction' => $role_restriction,
6311 7981 'data_source' => $data_source,
@@ -6348,16 +8018,16 @@
6348 8018 wp_send_json_error('Unauthorized access');
6349 8019 exit;
6350 8020 }
6351 8021
6352 - $tag_slug = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
8022 + $tag_input = isset($_POST['tag_slug']) ? sanitize_text_field($_POST['tag_slug']) : '';
6353 8023 $role_restriction = isset($_POST['role_restriction']) ? sanitize_text_field($_POST['role_restriction']) : 'public';
6354 -
6355 - if (empty($tag_slug)) {
6356 - wp_send_json_error('Tag slug is required');
8024 +
8025 + if (empty($tag_input)) {
8026 + wp_send_json_error('Please enter a tag name or slug');
6357 8027 exit;
6358 8028 }
6359 -
8029 +
6360 8030 // Validate role restriction
6361 8031 $valid_roles = array_keys($this->mxchat_get_role_options());
6362 8032 if (!in_array($role_restriction, $valid_roles)) {
6363 8033 wp_send_json_error('Invalid role restriction');
@@ -6362,16 +8032,27 @@
6362 8032 if (!in_array($role_restriction, $valid_roles)) {
6363 8033 wp_send_json_error('Invalid role restriction');
6364 8034 exit;
6365 8035 }
6366 -
6367 - // Check if tag exists in WordPress
6368 - $term = get_term_by('slug', $tag_slug, 'post_tag');
8036 +
8037 + // Resolve the tag by slug first, then fall back to its display name, so users can
8038 + // enter either "premium-content" or "Premium Content". (plan b8bcf5 — the field is
8039 + // labeled by name but previously validated by slug only, producing the confusing
8040 + // "Tag does not exist in WordPress" error when a real tag's name was typed.)
8041 + $term = get_term_by('slug', $tag_input, 'post_tag');
6369 8042 if (!$term) {
6370 - wp_send_json_error('Tag does not exist in WordPress');
8043 + $term = get_term_by('name', $tag_input, 'post_tag');
8044 + }
8045 + if (!$term) {
8046 + 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.');
6371 8047 exit;
6372 8048 }
6373 -
8049 +
8050 + // Always key the mapping by the RESOLVED slug — apply_role_restriction_to_post()
8051 + // compares against each post's tag slugs, so the stored key must be a slug,
8052 + // never the raw (possibly display-name) input.
8053 + $tag_slug = $term->slug;
8054 +
6374 8055 // Get existing mappings
6375 8056 $mappings = get_option('mxchat_tag_role_mappings', array());
6376 8057
6377 8058 // Check if mapping already exists
@@ -6646,9 +8327,9 @@
6646 8327 );
6647 8328 } else {
6648 8329 // Update WordPress DB
6649 8330 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6650 -
8331 +
6651 8332 $wpdb->update(
6652 8333 $table_name,
6653 8334 array('role_restriction' => $highest_role),
6654 8335 array('source_url' => $source_url),
@@ -6655,8 +8336,15 @@
6655 8336 array('%s'),
6656 8337 array('%s')
6657 8338 );
6658 8339 }
8340 +
8341 + // The entry's restriction just changed — keep the OpenAI Vector Store
8342 + // mirror consistent: non-public pulls the file (file_search has no
8343 + // per-role filtering), public re-mirrors it (plan 15b5c6).
8344 + if (class_exists('MxChat_Vectorstore_Manager')) {
8345 + MxChat_Vectorstore_Manager::handle_role_change($source_url, 'default', $highest_role);
8346 + }
6659 8347 }
6660 8348
6661 8349 /**
6662 8350 * Apply role restriction after content is stored (for auto-sync)
@@ -6725,9 +8413,9 @@
6725 8413 );
6726 8414 } else {
6727 8415 // Update WordPress DB
6728 8416 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
6729 -
8417 +
6730 8418 $wpdb->update(
6731 8419 $table_name,
6732 8420 array('role_restriction' => $highest_role),
6733 8421 array('source_url' => $source_url),
@@ -6734,8 +8422,15 @@
6734 8422 array('%s'),
6735 8423 array('%s')
6736 8424 );
6737 8425 }
8426 +
8427 + // The entry's restriction just changed — keep the OpenAI Vector Store
8428 + // mirror consistent: non-public pulls the file (file_search has no
8429 + // per-role filtering), public re-mirrors it (plan 15b5c6).
8430 + if (class_exists('MxChat_Vectorstore_Manager')) {
8431 + MxChat_Vectorstore_Manager::handle_role_change($source_url, 'default', $highest_role);
8432 + }
6738 8433 }
6739 8434
6740 8435
6741 8436 // ========================================
@@ -7196,25 +8891,19 @@
7196 8891 if (empty($url)) {
7197 8892 return new WP_Error('invalid_url', 'URL is empty');
7198 8893 }
7199 8894
7200 - // Get bot-specific API key early (needed for both paths)
8895 + // Get bot-specific embedding decision early (needed for both paths) —
8896 + // custom-provider-aware (plan cbd5fd). Error code preserved.
7201 8897 $bot_options = $this->get_bot_options($bot_id);
7202 8898 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
7203 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
7204 8899
7205 - if (strpos($selected_model, 'voyage') === 0) {
7206 - $api_key = $options['voyage_api_key'] ?? '';
7207 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
7208 - $api_key = $options['gemini_api_key'] ?? '';
7209 - } else {
7210 - $api_key = $options['api_key'] ?? '';
8900 + $preflight = MxChat_Utils::embedding_preflight($options);
8901 + if (!$preflight['ok']) {
8902 + return new WP_Error('no_api_key', $preflight['reason'] . ' (bot: ' . $bot_id . ')');
7211 8903 }
8904 + $api_key = $preflight['api_key'];
7212 8905
7213 - if (empty($api_key)) {
7214 - return new WP_Error('no_api_key', 'API key not configured for bot: ' . $bot_id);
7215 - }
7216 -
7217 8906 // Check if this is a WooCommerce product URL and WooCommerce is active
7218 8907 $is_product_url = (strpos($url, '/product/') !== false || strpos($url, '/shop/') !== false);
7219 8908 $content_type = $is_product_url ? 'product' : 'url';
7220 8909
@@ -7241,9 +8930,9 @@
7241 8930 $is_likely_pdf = (strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?: '', PATHINFO_EXTENSION)) === 'pdf');
7242 8931 $response = wp_remote_get($url, array(
7243 8932 'timeout' => $is_likely_pdf ? 120 : 30,
7244 8933 'redirection' => 5,
7245 - 'user-agent' => 'MxChat/1.0'
8934 + 'user-agent' => mxchat_ingest_user_agent(),
7246 8935 ));
7247 8936
7248 8937 if (is_wp_error($response)) {
7249 8938 return $response;
@@ -7408,8 +9097,9 @@
7408 9097
7409 9098 for ($i = 0; $i < $total_pages; $i++) {
7410 9099 $page_num = $i + 1;
7411 9100 $text = $pages[$i]->getText();
9101 + $text = MxChat_Utils::normalize_pdf_rtl($text, 'kb_pdf_import page ' . $page_num);
7412 9102 if (empty($text)) {
7413 9103 $skipped_pages[] = 'Page ' . $page_num . ': No text could be extracted — page may contain only images, links, or non-standard encoding';
7414 9104 continue;
7415 9105 }
@@ -7496,95 +9186,12 @@
7496 9186 if (!$product) {
7497 9187 return false;
7498 9188 }
7499 9189
7500 - // Build product content with pricing (similar to mxchat_store_product_embedding)
7501 - $title = $product->get_name();
7502 - $description = $product->get_description();
7503 - $short_description = $product->get_short_description();
7504 - $sku = $product->get_sku();
9190 + // Build product content via the shared WC-object assembler (a3d60c) — same
9191 + // body as the auto-sync product writer, so the two paths can never drift.
9192 + $content = $this->mxchat_prepare_product_content_for_indexing($product);
7505 9193
7506 - // Get pricing information
7507 - $regular_price = $product->get_regular_price();
7508 - $sale_price = $product->get_sale_price();
7509 - $price = $product->get_price(); // Current active price
7510 -
7511 - // Get currency symbol
7512 - $currency_symbol = get_woocommerce_currency_symbol();
7513 -
7514 - // Format content
7515 - $content = $title . "\n\n";
7516 -
7517 - if (!empty($short_description)) {
7518 - $content .= "Short Description: " . wp_strip_all_tags($short_description) . "\n\n";
7519 - }
7520 -
7521 - if (!empty($description)) {
7522 - $content .= wp_strip_all_tags($description) . "\n\n";
7523 - }
7524 -
7525 - // Add pricing information
7526 - if (!empty($regular_price)) {
7527 - $content .= "Price: " . $currency_symbol . $regular_price . "\n";
7528 - } elseif (!empty($price)) {
7529 - $content .= "Price: " . $currency_symbol . $price . "\n";
7530 - }
7531 -
7532 - if (!empty($sale_price) && $sale_price !== $regular_price) {
7533 - $content .= "Sale Price: " . $currency_symbol . $sale_price . "\n";
7534 - }
7535 -
7536 - // Handle variable products - show price range
7537 - if ($product->is_type('variable')) {
7538 - $min_price = $product->get_variation_price('min');
7539 - $max_price = $product->get_variation_price('max');
7540 - if ($min_price !== $max_price) {
7541 - $content .= "Price Range: " . $currency_symbol . $min_price . " - " . $currency_symbol . $max_price . "\n";
7542 - }
7543 - }
7544 -
7545 - if (!empty($sku)) {
7546 - $content .= "SKU: " . $sku . "\n";
7547 - }
7548 -
7549 - // Get product categories
7550 - $categories = wp_get_post_terms($product_id, 'product_cat', array('fields' => 'names'));
7551 - if (!empty($categories) && !is_wp_error($categories)) {
7552 - $content .= "Categories: " . implode(', ', $categories) . "\n";
7553 - }
7554 -
7555 - // Get Custom Product Tabs (supports "Custom Product Tabs for WooCommerce" by Code Parrots)
7556 - $custom_tabs = get_post_meta($product_id, 'yikes_woo_products_tabs', true);
7557 - if (!empty($custom_tabs) && is_array($custom_tabs)) {
7558 - foreach ($custom_tabs as $tab) {
7559 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
7560 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
7561 -
7562 - if (!empty($tab_title) && !empty($tab_content)) {
7563 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
7564 - }
7565 - }
7566 - }
7567 -
7568 - // Also check for reusable/saved tabs applied to this product
7569 - $applied_saved_tabs = get_post_meta($product_id, 'yikes_woo_reusable_products_tabs_applied', true);
7570 - if (!empty($applied_saved_tabs) && is_array($applied_saved_tabs)) {
7571 - $saved_tabs = get_option('yikes_woo_reusable_products_tabs', array());
7572 - if (!empty($saved_tabs) && is_array($saved_tabs)) {
7573 - foreach ($applied_saved_tabs as $saved_tab_id) {
7574 - if (isset($saved_tabs[$saved_tab_id])) {
7575 - $tab = $saved_tabs[$saved_tab_id];
7576 - $tab_title = isset($tab['title']) ? $tab['title'] : (isset($tab['tab_title']) ? $tab['tab_title'] : '');
7577 - $tab_content = isset($tab['content']) ? $tab['content'] : '';
7578 -
7579 - if (!empty($tab_title) && !empty($tab_content)) {
7580 - $content .= "\n" . $tab_title . ": " . wp_strip_all_tags($tab_content) . "\n";
7581 - }
7582 - }
7583 - }
7584 - }
7585 - }
7586 -
7587 9194 return $this->mxchat_sanitize_content_for_api($content);
7588 9195 }
7589 9196
7590 9197 /**
@@ -7614,8 +9221,9 @@
7614 9221 return new WP_Error('page_not_found', 'Page ' . $page_number . ' not found in PDF');
7615 9222 }
7616 9223
7617 9224 $text = $pages[$page_number - 1]->getText();
9225 + $text = MxChat_Utils::normalize_pdf_rtl($text, 'kb_pdf_page page ' . $page_number);
7618 9226
7619 9227 if (empty($text)) {
7620 9228 return new WP_Error('empty_page', 'Page ' . $page_number . ': No text could be extracted — page may contain only images, links, or non-standard encoding');
7621 9229 }
@@ -7636,24 +9244,18 @@
7636 9244
7637 9245 $content_with_metadata = wp_json_encode($metadata) . "\n---\n" . $sanitized;
7638 9246 $page_url = esc_url($pdf_url . "#page=" . $page_number);
7639 9247
7640 - // Get bot-specific API key
9248 + // Get bot-specific embedding decision — custom-provider-aware
9249 + // (plan cbd5fd). Error code preserved.
7641 9250 $bot_options = $this->get_bot_options($bot_id);
7642 9251 $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
7643 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
7644 -
7645 - if (strpos($selected_model, 'voyage') === 0) {
7646 - $api_key = $options['voyage_api_key'] ?? '';
7647 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
7648 - $api_key = $options['gemini_api_key'] ?? '';
7649 - } else {
7650 - $api_key = $options['api_key'] ?? '';
9252 +
9253 + $preflight = MxChat_Utils::embedding_preflight($options);
9254 + if (!$preflight['ok']) {
9255 + return new WP_Error('no_api_key', $preflight['reason'] . ' (bot: ' . $bot_id . ')');
7651 9256 }
7652 -
7653 - if (empty($api_key)) {
7654 - return new WP_Error('no_api_key', 'API key not configured for bot: ' . $bot_id);
7655 - }
9257 + $api_key = $preflight['api_key'];
7656 9258
7657 9259 // Submit to database - UPDATED 2.5.6: Added content_type 'pdf'
7658 9260 $result = MxChat_Utils::submit_content_to_db(
7659 9261 $content_with_metadata,