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