PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.24
MxChat – AI Chatbot & Content Generation for WordPress v3.2.24
3.2.25 3.2.24 3.2.23 3.2.22 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 All 156 releases
← All changes | includes/class-mxchat-utils.php +954 -60 3.2.19 → 3.2.24 View file →
@@ -5,8 +5,17 @@
5 5
6 6 class MxChat_Utils {
7 7
8 8 /**
9 + * True while a storage routine is re-writing an entry it is about to re-add
10 + * (submit_chunked_content's clean-slate delete). delete_chunks_for_url() skips
11 + * the Vector Store mirror-delete while set — an internal re-store is not an
12 + * entry removal, and mirroring it would delete-then-reupload the entry's file
13 + * on every chunked save (plan 15b5c6).
14 + */
15 +private static $vectorstore_mirror_suspended = false;
16 +
17 +/**
9 18 * Validate a client-supplied session id (plan-mxchat-20260731-d42bec).
10 19 *
11 20 * sanitize_text_field() — which every session_id read site used before this —
12 21 * preserves '/' and '..'. Harmless where the value is only an option or
@@ -38,8 +47,47 @@
38 47 return preg_match('/\A[A-Za-z0-9_-]{1,128}\z/', $val) ? $val : '';
39 48 }
40 49
41 50 /**
51 + * Sanitize the lead-capture consent-checkbox label (plan b062c4).
52 + *
53 + * The label is owner-supplied and renders inside the widget's email form, so
54 + * this is a security boundary, not a formatting nicety. One explicit
55 + * allowlist, used at BOTH save time (options.php sanitize + autosave AJAX)
56 + * and render time (widget form, admin surfaces) so the two can never drift:
57 + * an anchor — the whole point is "I agree to the <a>Privacy Policy</a>" —
58 + * plus inline emphasis. No block tags, no images, no style attributes.
59 + *
60 + * The stored consent record keeps this exact sanitized string as "the text
61 + * the visitor saw", so it must be deterministic: same input, same output,
62 + * whichever path ran it.
63 + *
64 + * @param mixed $raw Owner-entered label.
65 + * @return string Sanitized label, capped at 1000 chars.
66 + */
67 +public static function sanitize_consent_label($raw) {
68 + if (!is_scalar($raw)) {
69 + return '';
70 + }
71 +
72 + $allowed = array(
73 + 'a' => array(
74 + 'href' => true,
75 + 'title' => true,
76 + 'target' => true,
77 + 'rel' => true,
78 + ),
79 + 'strong' => array(),
80 + 'em' => array(),
81 + 'br' => array(),
82 + );
83 +
84 + $label = wp_kses(trim((string) $raw), $allowed);
85 +
86 + return mb_substr($label, 0, 1000);
87 +}
88 +
89 +/**
42 90 * Per-request cache for get_session_history(). Mirrors get_option()'s
43 91 * request-scoped caching, which the mxchat_history_ option reads got for
44 92 * free before plan 839c4c moved history reads onto the transcripts table.
45 93 */
@@ -137,8 +185,78 @@
137 185 unset(self::$history_cache[(string) $session_id]);
138 186 }
139 187
140 188 /**
189 + * Append one message to a session's transcript on behalf of an add-on
190 + * (plan 4a664e). The Forms add-on's wizard steps travel over the add-on's
191 + * own AJAX action, never through the chat request, so nothing wrote them
192 + * to the transcript: Transcripts, the live-agent handoff card, Download
193 + * Transcript and every transcript email showed "[WIZARD_COMPLETE]" and no
194 + * answers. This is the write half of get_session_history() — same row
195 + * shape as the chat writer (visitor identity, captured name/email and
196 + * originating page from the session store, GMT timestamp), without the
197 + * new-session side effects (ownership, notification email, delayed
198 + * transcript) that only the visitor's first message should trigger.
199 + *
200 + * Public and static so add-ons can call it, guarded with method_exists
201 + * against an older mxchat-basic.
202 + *
203 + * @param string $session_id Chat session id.
204 + * @param string $role user|bot|system|agent.
205 + * @param string $message Stored as-is: bot rows may carry HTML, user rows
206 + * are rendered as text by every reader.
207 + * @return int The new transcripts row id, 0 when nothing was written.
208 + */
209 +public static function save_session_message($session_id, $role, $message) {
210 + global $wpdb;
211 +
212 + $session_id = self::sanitize_session_id($session_id);
213 + $role = is_string($role) ? strtolower(trim($role)) : '';
214 + $message = is_scalar($message) ? trim((string) $message) : '';
215 +
216 + if ($session_id === '' || $message === ''
217 + || !in_array($role, array('user', 'bot', 'system', 'agent'), true)) {
218 + return 0;
219 + }
220 +
221 + $table = $wpdb->prefix . 'mxchat_chat_transcripts';
222 +
223 + $saved_email = MxChat_Session_Store::get($session_id, 'email');
224 + $saved_name = MxChat_Session_Store::get($session_id, 'name');
225 +
226 + $row = array(
227 + 'user_id' => is_user_logged_in() ? get_current_user_id() : 0,
228 + 'user_identifier' => MxChat_User::mxchat_get_user_identifier(),
229 + 'user_email' => $saved_email ?: MxChat_User::mxchat_get_user_email(),
230 + 'user_name' => $saved_name ?: '',
231 + 'session_id' => $session_id,
232 + 'role' => $role,
233 + 'message' => $message,
234 + 'timestamp' => current_time('mysql', 1),
235 + );
236 +
237 + // Same page attribution as the rest of the session; the column pair was
238 + // added by an upgrade routine, so tolerate its absence like the chat
239 + // writer does.
240 + $originating = MxChat_Session_Store::get($session_id, 'originating_page');
241 + if (is_array($originating) && !empty($originating['url'])
242 + && $wpdb->get_var("SHOW COLUMNS FROM `$table` LIKE 'originating_page_url'")) {
243 + $row['originating_page_url'] = $originating['url'];
244 + $row['originating_page_title'] = isset($originating['title']) ? $originating['title'] : '';
245 + }
246 +
247 + $inserted = $wpdb->insert($table, $row);
248 + if ($inserted === false) {
249 + return 0;
250 + }
251 +
252 + $row_id = (int) $wpdb->insert_id;
253 + self::flush_session_history_cache($session_id);
254 +
255 + return $row_id;
256 +}
257 +
258 +/**
141 259 * Most recipients the Notification Email field will accept (plan 2f131a).
142 260 * A settings field is not a mailing list.
143 261 */
144 262 const NOTIFICATION_EMAIL_MAX = 5;
@@ -339,8 +457,100 @@
339 457 return $options['embedding_model'] ?? '';
340 458 }
341 459
342 460 /**
461 + * Dimension of a STANDARD embedding model, from the dropdown and its two
462 + * adjustable-size settings. Says nothing about a Custom Provider model — ask
463 + * expected_embedding_dimension() for the model the site actually embeds with.
464 + */
465 +public static function standard_embedding_dimension($options = null) {
466 + if (!is_array($options)) {
467 + $options = get_option('mxchat_options', array());
468 + $options = is_array($options) ? $options : array();
469 + }
470 + $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
471 + $model_dimensions = array(
472 + 'text-embedding-ada-002' => 1536,
473 + 'text-embedding-3-small' => 1536,
474 + 'text-embedding-3-large' => 3072,
475 + 'voyage-2' => 1024,
476 + 'voyage-large-2' => 1536,
477 + 'voyage-3-large' => 2048,
478 + 'gemini-embedding-001' => 1536,
479 + );
480 + if (strpos($selected_model, 'voyage-3-large') === 0) {
481 + return intval($options['voyage_output_dimension'] ?? 2048);
482 + }
483 + if (strpos($selected_model, 'gemini-embedding') === 0) {
484 + return intval($options['gemini_output_dimension'] ?? 1536);
485 + }
486 + return $model_dimensions[$selected_model] ?? 1536;
487 +}
488 +
489 +/**
490 + * Dimension of the vectors the NEXT embed will produce — the one place that
491 + * answers it (plan 418afd). With Custom Provider embeddings on, the standard
492 + * dropdown is disabled and says nothing about the custom model, so the number
493 + * is read from the model itself: one short probe embed, remembered per base
494 + * URL + model. Three copies of the dropdown table used to answer instead, and
495 + * a custom model of another size got a wrong-sized index and wrong-sized
496 + * listing vectors.
497 + *
498 + * @param array|null $options mxchat_options (or a bot's merged options).
499 + * @param bool $force Probe again even when a recent probe failed.
500 + * @return int|WP_Error WP_Error only when a custom model's size could not be read.
501 + */
502 +public static function expected_embedding_dimension($options = null, $force = false) {
503 + if (!is_array($options)) {
504 + $options = get_option('mxchat_options', array());
505 + $options = is_array($options) ? $options : array();
506 + }
507 + if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
508 + return self::custom_embedding_dimension($options, $force);
509 + }
510 + return self::standard_embedding_dimension($options);
511 +}
512 +
513 +/**
514 + * A Custom Provider embedding model's dimension, by asking it once. The
515 + * answer is stored against the base URL + model, so changing either asks
516 + * again. A failure is remembered for five minutes so a dead endpoint does not
517 + * hold up every admin page that prints the number; $force skips that memory.
518 + *
519 + * @return int|WP_Error
520 + */
521 +public static function custom_embedding_dimension($options, $force = false) {
522 + $model = self::resolve_custom_embedding_model($options);
523 + $base_url = isset($options['custom_provider_base_url']) ? rtrim(trim((string) $options['custom_provider_base_url']), '/') : '';
524 + $key = md5(strtolower($base_url) . '|' . $model);
525 +
526 + $known = get_option('mxchat_custom_embedding_dimension', array());
527 + if (is_array($known) && ($known['key'] ?? '') === $key && (int) ($known['dimension'] ?? 0) > 0) {
528 + return (int) $known['dimension'];
529 + }
530 +
531 + $failed = get_transient('mxchat_custom_embedding_dimension_failed');
532 + if (!$force && is_array($failed) && ($failed['key'] ?? '') === $key) {
533 + return new WP_Error('embedding_dimension_unknown', (string) ($failed['reason'] ?? ''));
534 + }
535 +
536 + $vector = self::generate_embedding_custom('MxChat embedding dimension check', $options);
537 + if (!is_array($vector) || count($vector) === 0) {
538 + $reason = (is_string($vector) && $vector !== '') ? $vector : __('The custom provider returned no embedding.', 'mxchat');
539 + set_transient('mxchat_custom_embedding_dimension_failed', array('key' => $key, 'reason' => $reason), 5 * MINUTE_IN_SECONDS);
540 + return new WP_Error('embedding_dimension_unknown', $reason);
541 + }
542 +
543 + update_option('mxchat_custom_embedding_dimension', array(
544 + 'key' => $key,
545 + 'model' => 'custom:' . $model,
546 + 'dimension' => count($vector),
547 + ), false);
548 + delete_transient('mxchat_custom_embedding_dimension_failed');
549 + return count($vector);
550 +}
551 +
552 +/**
343 553 * Extract the 11-character YouTube video ID from a URL, or '' if the URL is
344 554 * not a single-video YouTube link. Single source of truth for both the KB
345 555 * ingestion side and the chat render side — do not duplicate this parsing.
346 556 * Channel, playlist, and search URLs deliberately return '' (only a URL that
@@ -417,9 +627,9 @@
417 627 * @param string $bot_id The bot ID for multi-bot support
418 628 * @param string $content_type The type of content (post, page, pdf, url, manual, product, etc.)
419 629 * @return bool|WP_Error True on success, WP_Error on failure
420 630 */
421 -public static function submit_content_to_db($content, $source_url, $api_key, $vector_id = null, $bot_id = 'default', $content_type = 'content') {
631 +public static function submit_content_to_db($content, $source_url, $api_key, $vector_id = null, $bot_id = 'default', $content_type = 'content', $title = '') {
422 632 global $wpdb;
423 633 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
424 634
425 635 //error_log('[MXCHAT-DB] Starting database submission for URL: ' . $source_url . ' (Bot: ' . $bot_id . ', Type: ' . $content_type . ')');
@@ -424,10 +634,18 @@
424 634
425 635 //error_log('[MXCHAT-DB] Starting database submission for URL: ' . $source_url . ' (Bot: ' . $bot_id . ', Type: ' . $content_type . ')');
426 636 //error_log('[MXCHAT-DB] Content length: ' . strlen($content) . ' bytes');
427 637
428 - // Sanitize the source URL
429 - $source_url = esc_url_raw($source_url);
638 + // Sanitize the source URL. Internal identities (mxchat:// manual docs,
639 + // upload:// file uploads) are NOT web URLs — esc_url_raw EMPTIES them
640 + // because its protocol list is statically cached and effectively
641 + // unfilterable (measured, plan 945406 / 0485e5) — sanitize those as text
642 + // so upserts stay keyed to a stable identity across re-imports.
643 + if (preg_match('#^(mxchat|upload)://#i', $source_url)) {
644 + $source_url = sanitize_text_field($source_url);
645 + } else {
646 + $source_url = esc_url_raw($source_url);
647 + }
430 648
431 649 // Sanitize content_type
432 650 $content_type = sanitize_key($content_type);
433 651 if (empty($content_type)) {
@@ -433,8 +651,14 @@
433 651 if (empty($content_type)) {
434 652 $content_type = 'content'; // Fallback for backwards compatibility
435 653 }
436 654
655 + // Page title captured by the importer (plan d9ee66), registered under the
656 + // SANITIZED url so the storage writers below find it by the same key.
657 + if (is_string($title) && trim($title) !== '') {
658 + self::set_source_title($source_url, $title);
659 + }
660 +
437 661 // Just ensure UTF-8 validity without aggressive escaping
438 662 $safe_content = wp_check_invalid_utf8($content);
439 663 // Remove only null bytes and other control characters, but preserve newlines (\n = \x0A) and carriage returns (\r = \x0D)
440 664 $safe_content = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $safe_content);
@@ -442,9 +666,16 @@
442 666 // Check if chunking should be applied
443 667 $chunker = MxChat_Chunker::from_settings();
444 668 if ($chunker->should_chunk($safe_content)) {
445 669 //error_log('[MXCHAT-DB] Content exceeds chunk threshold, using chunked submission');
446 - return self::submit_chunked_content($safe_content, $source_url, $api_key, $bot_id, $content_type, $chunker);
670 + $chunk_result = self::submit_chunked_content($safe_content, $source_url, $api_key, $bot_id, $content_type, $chunker);
671 + // Vector Store mirror gets the entry WHOLE — one file per KB entry,
672 + // OpenAI chunks server-side; local chunking is our own embedding
673 + // concern and never reaches the store (plan 15b5c6).
674 + if ($chunk_result === true && class_exists('MxChat_Vectorstore_Manager')) {
675 + MxChat_Vectorstore_Manager::sync_upsert_entry($source_url, $safe_content, $bot_id, $content_type);
676 + }
677 + return $chunk_result;
447 678 }
448 679
449 680 // UPDATED: Generate the embedding using bot-specific configuration
450 681 $embedding_vector = self::generate_embedding($content, $api_key, $bot_id);
@@ -462,18 +693,175 @@
462 693 // UPDATED: Check if Pinecone is enabled for this specific bot
463 694 if (self::is_pinecone_enabled_for_bot($bot_id)) {
464 695 //error_log('[MXCHAT-DB] Pinecone is enabled for bot ' . $bot_id . ' - using Pinecone storage');
465 696 // Store in Pinecone only
466 - return self::store_in_pinecone_only($embedding_vector, $content, $source_url, $vector_id, $bot_id, $content_type);
697 + $result = self::store_in_pinecone_only($embedding_vector, $content, $source_url, $vector_id, $bot_id, $content_type);
698 + // If this URL previously stored as CHUNKED content and the new content fits in a
699 + // single vector, the upsert above only overwrote the base id — the old
700 + // md5(url)_chunk_N vectors would keep serving the stale text. Sweep them.
701 + // Only for a real source_url: chunk ids derive from it, and md5('') is shared
702 + // by legacy URL-less entries so a blind sweep there could hit other entries.
703 + if ($result === true && !empty($source_url)) {
704 + self::cleanup_pinecone_chunk_stragglers($source_url, $bot_id);
705 + }
706 + if ($result === true && class_exists('MxChat_Vectorstore_Manager')) {
707 + MxChat_Vectorstore_Manager::sync_upsert_entry($source_url, $safe_content, $bot_id, $content_type);
708 + }
709 + return $result;
467 710 } else {
468 711 //error_log('[MXCHAT-DB] Pinecone not enabled for bot ' . $bot_id . ' - using WordPress storage');
469 712 // Store in WordPress database only
470 713 $embedding_vector_serialized = maybe_serialize($embedding_vector);
471 - return self::store_in_wordpress_db($safe_content, $source_url, $embedding_vector_serialized, $table_name, $content_type);
714 + $result = self::store_in_wordpress_db($safe_content, $source_url, $embedding_vector_serialized, $table_name, $content_type);
715 + if ($result === true && class_exists('MxChat_Vectorstore_Manager')) {
716 + MxChat_Vectorstore_Manager::sync_upsert_entry($source_url, $safe_content, $bot_id, $content_type);
717 + }
718 + return $result;
472 719 }
473 720 }
474 721
722 +/* -------------------------------------------------------------------------
723 + * Source titles (3.2.23, plan d9ee66)
724 + *
725 + * A knowledge entry imported from a URL or sitemap remembers the page's own
726 + * title, so an add-on that names a source (AI Search's source cards) can show
727 + * "About Us" instead of a guess from the slug ("Aboutus"). Stored in the
728 + * `title` column of the WP knowledge table and as `title` metadata on Pinecone
729 + * vectors / documents. The importer hands the title to submit_content_to_db(),
730 + * which registers it under the source URL; the writers look it up by that key.
731 + * ---------------------------------------------------------------------- */
732 +
733 +/** @var array<string,string> source_url => title, for the current request */
734 +private static $source_titles = array();
735 +
736 +public static function set_source_title($source_url, $title) {
737 + $key = trim((string) $source_url);
738 + $title = self::clean_title($title);
739 + if ($key === '' || $title === '') {
740 + return;
741 + }
742 + self::$source_titles[$key] = $title;
743 +}
744 +
745 +public static function source_title_for($source_url) {
746 + $key = trim((string) $source_url);
747 + return ($key !== '' && isset(self::$source_titles[$key])) ? self::$source_titles[$key] : '';
748 +}
749 +
750 +/** Entities decoded, tags gone, whitespace collapsed, capped at the column width. */
751 +public static function clean_title($title) {
752 + $title = html_entity_decode((string) $title, ENT_QUOTES | ENT_HTML5, 'UTF-8');
753 + $title = wp_strip_all_tags($title);
754 + $title = trim((string) preg_replace('/\s+/u', ' ', $title));
755 + if (mb_strlen($title, 'UTF-8') > 255) {
756 + $title = rtrim(mb_substr($title, 0, 255, 'UTF-8'));
757 + }
758 + return $title;
759 +}
760 +
475 761 /**
762 + * The page's own title from fetched HTML: og:title, else <title> with a
763 + * trailing "| Site Name" style suffix removed, else the first <h1>. Returns ''
764 + * when nothing usable is there, so the caller keeps its own fallback.
765 + */
766 +public static function extract_page_title($html) {
767 + $html = (string) $html;
768 + if ($html === '') {
769 + return '';
770 + }
771 + $head = substr($html, 0, 200000);
772 + $site = '';
773 + if (preg_match('/<meta\s+[^>]*property=["\']og:site_name["\'][^>]*content=["\']([^"\']{1,120})["\']/i', $head, $m)
774 + || preg_match('/<meta\s+[^>]*content=["\']([^"\']{1,120})["\'][^>]*property=["\']og:site_name["\']/i', $head, $m)) {
775 + $site = self::clean_title($m[1]);
776 + }
777 + $candidate = '';
778 + if (preg_match('/<meta\s+[^>]*property=["\']og:title["\'][^>]*content=["\']([^"\']{1,500})["\']/i', $head, $m)
779 + || preg_match('/<meta\s+[^>]*content=["\']([^"\']{1,500})["\'][^>]*property=["\']og:title["\']/i', $head, $m)) {
780 + $candidate = self::clean_title($m[1]);
781 + }
782 + if ($candidate === '' && preg_match('/<title[^>]*>(.*?)<\/title>/is', $head, $m)) {
783 + $candidate = self::clean_title($m[1]);
784 + }
785 + if ($candidate !== '') {
786 + $candidate = self::strip_site_suffix($candidate, $site);
787 + }
788 + if ($candidate === '' && preg_match('/<h1[^>]*>(.*?)<\/h1>/is', $html, $m)) {
789 + $candidate = self::clean_title($m[1]);
790 + }
791 + return $candidate;
792 +}
793 +
794 +/**
795 + * "About Us | Just Facts" -> "About Us". Only a TRAILING segment after a spaced
796 + * separator goes, only when what is left is a real title (3+ characters) and the
797 + * removed part is short enough to be a site name; a known og:site_name wins.
798 + * Byte offsets throughout: every separator starts with an ASCII space, so a cut
799 + * there never lands inside a multibyte character.
800 + */
801 +private static function strip_site_suffix($title, $site_name = '') {
802 + $seps = array(' | ', ' – ', ' — ', ' - ', ' :: ', ' · ', ' » ', ' • ');
803 + if ($site_name !== '') {
804 + foreach ($seps as $sep) {
805 + $tail = $sep . $site_name;
806 + if (strlen($title) > strlen($tail) && substr($title, -strlen($tail)) === $tail) {
807 + return trim(substr($title, 0, strlen($title) - strlen($tail)));
808 + }
809 + }
810 + }
811 + $best_pos = -1;
812 + $best_sep = '';
813 + foreach ($seps as $sep) {
814 + $pos = strrpos($title, $sep);
815 + if ($pos !== false && $pos > $best_pos) {
816 + $best_pos = $pos;
817 + $best_sep = $sep;
818 + }
819 + }
820 + if ($best_pos > 0) {
821 + $left = trim(substr($title, 0, $best_pos));
822 + $right = trim(substr($title, $best_pos + strlen($best_sep)));
823 + if (mb_strlen($left, 'UTF-8') >= 3 && $right !== '' && mb_strlen($right, 'UTF-8') <= 60) {
824 + return $left;
825 + }
826 + }
827 + return $title;
828 +}
829 +
830 +/**
831 + * Make sure the knowledge table has the `title` column (3.2.23, plan d9ee66).
832 + * Activation adds it; this is the safety net for a copy updated in place, and
833 + * the one place add-ons ask "may I read/write title?". A positive answer is
834 + * remembered in an option, so after the first time this costs one option read.
835 + */
836 +public static function ensure_kb_title_column() {
837 + global $wpdb;
838 + static $ready = null;
839 + if ($ready !== null) {
840 + return $ready;
841 + }
842 + if (get_option('mxchat_kb_title_column') === '1') {
843 + $ready = true;
844 + return true;
845 + }
846 + $table = $wpdb->prefix . 'mxchat_system_prompt_content';
847 + $cols = $wpdb->get_col("SHOW COLUMNS FROM {$table}", 0);
848 + if (empty($cols)) {
849 + $ready = false;
850 + return false;
851 + }
852 + if (!in_array('title', $cols, true)) {
853 + $wpdb->query("ALTER TABLE {$table} ADD COLUMN title VARCHAR(255) DEFAULT NULL AFTER source_url");
854 + $cols = $wpdb->get_col("SHOW COLUMNS FROM {$table}", 0);
855 + }
856 + $ready = in_array('title', $cols, true);
857 + if ($ready) {
858 + update_option('mxchat_kb_title_column', '1', false);
859 + }
860 + return $ready;
861 +}
862 +
863 +/**
476 864 * UPDATED: Check if Pinecone is enabled and properly configured for a specific bot
477 865 */
478 866 private static function is_pinecone_enabled_for_bot($bot_id = 'default') {
479 867 // For default bot or when multi-bot is not active, use original method
@@ -585,11 +973,15 @@
585 973 // Check if this is truly manual content (no URL at all) vs a real URL that filter_var rejects
586 974 // filter_var(FILTER_VALIDATE_URL) rejects valid URLs with encoded chars, non-ASCII, fragments, etc.
587 975 // Use a looser check: if it starts with http(s):// or has a scheme, it's a URL
588 976 $has_url_scheme = !empty($source_url) && preg_match('#^https?://#i', $source_url);
977 + // upload:// identities (admin document/PDF uploads, plan 0485e5) are stable
978 + // and deduplicable — treat them like URLs so a re-upload UPDATES the row
979 + // instead of minting a fresh manual identity (which would duplicate).
980 + $has_stable_identity = $has_url_scheme || (!empty($source_url) && preg_match('#^upload://#i', $source_url));
589 981 // Treat legacy mxchat.ai source URLs as manual — old bug assigned the site URL to manual entries
590 982 $is_legacy_mxchat_url = $has_url_scheme && strpos($source_url, 'mxchat.ai') !== false;
591 - $is_manual_content = empty($source_url) || $source_url === '' || !$has_url_scheme || $is_legacy_mxchat_url;
983 + $is_manual_content = empty($source_url) || $source_url === '' || !$has_stable_identity || $is_legacy_mxchat_url;
592 984
593 985 if ($is_manual_content) {
594 986 // Generate unique identifier for manual content to prevent overwrites
595 987 $source_url = 'mxchat://manual-content/' . time() . '-' . wp_generate_password(8, false);
@@ -610,8 +1002,13 @@
610 1002 //error_log('[MXCHAT-DB] Manual content - will create new entry (no duplicate check)');
611 1003 }
612 1004 // ===== END FIX =====
613 1005
1006 + // Page title captured at import (plan d9ee66): written only when the
1007 + // importer supplied one, so a re-index without a title keeps the old value.
1008 + $title_value = $is_manual_content ? '' : self::source_title_for($source_url);
1009 + $title_ready = ($title_value !== '') && self::ensure_kb_title_column();
1010 +
614 1011 // Progressive fallback mechanism for problematic content
615 1012 $attempt = 1;
616 1013 $max_attempts = 3;
617 1014 $current_content = $safe_content;
@@ -622,39 +1019,41 @@
622 1019 if ($existing_id) {
623 1020 //error_log('[MXCHAT-DB] Found existing entry (ID: ' . $existing_id . '). Updating... (Attempt ' . $attempt . ')');
624 1021
625 1022 // Update the existing row - UPDATED 2.5.6: Added content_type
626 - $result = $wpdb->update(
627 - $table_name,
628 - array(
629 - 'url' => $source_url,
630 - 'article_content' => $current_content,
631 - 'embedding_vector' => $embedding_vector_serialized,
632 - 'source_url' => $source_url,
633 - 'content_type' => $content_type,
634 - 'timestamp' => current_time('mysql'),
635 - ),
636 - array('id' => $existing_id),
637 - array('%s','%s','%s','%s','%s','%s'),
638 - array('%d')
1023 + $row = array(
1024 + 'url' => $source_url,
1025 + 'article_content' => $current_content,
1026 + 'embedding_vector' => $embedding_vector_serialized,
1027 + 'source_url' => $source_url,
1028 + 'content_type' => $content_type,
1029 + 'timestamp' => current_time('mysql'),
639 1030 );
1031 + $formats = array('%s','%s','%s','%s','%s','%s');
1032 + if ($title_ready) {
1033 + $row['title'] = $title_value;
1034 + $formats[] = '%s';
1035 + }
1036 + $result = $wpdb->update($table_name, $row, array('id' => $existing_id), $formats, array('%d'));
640 1037 } else {
641 1038 //error_log('[MXCHAT-DB] No existing entry found. Inserting new row... (Attempt ' . $attempt . ')');
642 1039 //error_log('[MXCHAT-DB] Content sample: ' . substr($current_content, 0, 1000));
643 1040
644 1041 // Insert a new row - UPDATED 2.5.6: Added content_type
645 - $result = $wpdb->insert(
646 - $table_name,
647 - array(
648 - 'url' => $source_url, // Now unique for manual content
649 - 'article_content' => $current_content,
650 - 'embedding_vector' => $embedding_vector_serialized,
651 - 'source_url' => $source_url, // Now unique for manual content
652 - 'content_type' => $content_type,
653 - 'timestamp' => current_time('mysql'),
654 - ),
655 - array('%s','%s','%s','%s','%s','%s')
1042 + $row = array(
1043 + 'url' => $source_url, // Now unique for manual content
1044 + 'article_content' => $current_content,
1045 + 'embedding_vector' => $embedding_vector_serialized,
1046 + 'source_url' => $source_url, // Now unique for manual content
1047 + 'content_type' => $content_type,
1048 + 'timestamp' => current_time('mysql'),
656 1049 );
1050 + $formats = array('%s','%s','%s','%s','%s','%s');
1051 + if ($title_ready) {
1052 + $row['title'] = $title_value;
1053 + $formats[] = '%s';
1054 + }
1055 + $result = $wpdb->insert($table_name, $row, $formats);
657 1056 }
658 1057
659 1058 if ($result === false) {
660 1059 //error_log('[MXCHAT-DB] Database operation failed (Attempt ' . $attempt . ')');
@@ -713,10 +1112,11 @@
713 1112 // ===== UPDATED: Handle manual content with unique vector IDs =====
714 1113 if ($vector_id) {
715 1114 // Use provided vector ID
716 1115 //error_log('[MXCHAT-PINECONE-MAIN] Using provided vector ID: ' . $vector_id);
717 - } elseif (!empty($url) && preg_match('#^https?://#i', $url)) {
718 - // For URLs, use URL-based ID (existing behavior)
1116 + } elseif (!empty($url) && preg_match('#^(https?|upload)://#i', $url)) {
1117 + // For URLs — and stable upload:// identities (plan 0485e5) — use an
1118 + // identity-derived ID so a re-import upserts the same vector.
719 1119 $vector_id = md5($url);
720 1120 //error_log('[MXCHAT-PINECONE-MAIN] Generated vector ID from URL: ' . $vector_id);
721 1121 } else {
722 1122 // For manual content (empty/no URL scheme), generate unique ID
@@ -777,9 +1177,23 @@
777 1177 'last_updated' => time(),
778 1178 'created_at' => time(), // Add creation timestamp
779 1179 'bot_id' => $bot_id, // Add bot identification
780 1180 );
781 -
1181 + $page_title = self::source_title_for($url);
1182 + if ($page_title !== '') {
1183 + $metadata['title'] = $page_title; // page title captured at import (plan d9ee66)
1184 + }
1185 +
1186 + // Document index (plan 362c31): same id, same metadata, through the
1187 + // documents API. A site on the classic vector index never enters here.
1188 + if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_index($bot_id)) {
1189 + return MxChat_Pinecone_Documents::upsert_one($vector_id, $content, $embedding_vector, $metadata, array(
1190 + 'api_key' => $api_key,
1191 + 'host' => $host,
1192 + 'namespace' => $namespace,
1193 + ));
1194 + }
1195 +
782 1196 $vector_data = array(
783 1197 'id' => $vector_id,
784 1198 'values' => $embedding_vector,
785 1199 'metadata' => $metadata
@@ -1247,10 +1661,26 @@
1247 1661
1248 1662 //error_log('[MXCHAT-CHUNK-DEBUG] Starting chunked submission for: ' . $source_url);
1249 1663 //error_log('[MXCHAT-CHUNK-DEBUG] Content length: ' . strlen($content) . ' chars');
1250 1664
1251 - // First, delete any existing chunks for this URL (clean slate)
1665 + // URL-less (manual) content needs a minted identity BEFORE chunk ids are derived:
1666 + // every chunk id is md5(source_url)_chunk_N, so with source_url = '' EVERY long
1667 + // manual document shared the md5('') prefix — and the clean-slate delete below
1668 + // wiped the PREVIOUS manual entry's chunks each time a new one was added. The
1669 + // single-vector paths already mint (mxchat:// in WP, manual_* in Pinecone); this
1670 + // was the one storage path that didn't. Keep an mxchat:// identity if the caller
1671 + // already carries one.
1672 + if (strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, 'upload://') !== 0 && !preg_match('#^https?://#i', $source_url)) {
1673 + $source_url = 'mxchat://manual-content/' . time() . '-' . wp_generate_password(8, false);
1674 + }
1675 +
1676 + // First, delete any existing chunks for this URL (clean slate).
1677 + // Mirror-suspended: this is a re-store, not an entry removal — the Vector
1678 + // Store file is replaced (or kept, on hash match) by the caller's
1679 + // sync_upsert_entry after storage succeeds.
1680 + self::$vectorstore_mirror_suspended = true;
1252 1681 $delete_result = self::delete_chunks_for_url($source_url, $bot_id);
1682 + self::$vectorstore_mirror_suspended = false;
1253 1683 if (is_wp_error($delete_result)) {
1254 1684 //error_log('[MXCHAT-CHUNK-DEBUG] Warning: Failed to delete existing chunks: ' . $delete_result->get_error_message());
1255 1685 // Continue anyway - we'll overwrite with upsert
1256 1686 }
@@ -1439,9 +1869,22 @@
1439 1869 'last_updated' => time(),
1440 1870 'created_at' => time(),
1441 1871 'bot_id' => $bot_id,
1442 1872 );
1873 + $page_title = self::source_title_for($source_url);
1874 + if ($page_title !== '') {
1875 + $metadata['title'] = $page_title; // page title captured at import (plan d9ee66)
1876 + }
1443 1877
1878 + // Document index (plan 362c31): same chunk id and metadata, documents API.
1879 + if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_index($bot_id)) {
1880 + return MxChat_Pinecone_Documents::upsert_one($vector_id, $chunk_text, $embedding_vector, $metadata, array(
1881 + 'api_key' => $api_key,
1882 + 'host' => $host,
1883 + 'namespace' => $namespace,
1884 + ));
1885 + }
1886 +
1444 1887 $vector_data = array(
1445 1888 'id' => $vector_id,
1446 1889 'values' => $embedding_vector,
1447 1890 'metadata' => $metadata
@@ -1484,20 +1927,25 @@
1484 1927 global $wpdb;
1485 1928
1486 1929 // For chunks, we always insert new rows (no duplicate checking)
1487 1930 // The URL includes chunk info in the metadata, but source_url stays the same for grouping
1488 - $result = $wpdb->insert(
1489 - $table_name,
1490 - array(
1491 - 'url' => $source_url,
1492 - 'article_content' => $content_with_metadata,
1493 - 'embedding_vector' => $embedding_vector_serialized,
1494 - 'source_url' => $source_url,
1495 - 'content_type' => $content_type,
1496 - 'timestamp' => current_time('mysql')
1497 - ),
1498 - array('%s', '%s', '%s', '%s', '%s', '%s')
1931 + $row = array(
1932 + 'url' => $source_url,
1933 + 'article_content' => $content_with_metadata,
1934 + 'embedding_vector' => $embedding_vector_serialized,
1935 + 'source_url' => $source_url,
1936 + 'content_type' => $content_type,
1937 + 'timestamp' => current_time('mysql')
1499 1938 );
1939 + $formats = array('%s', '%s', '%s', '%s', '%s', '%s');
1940 + // Every chunk row carries the page title too (plan d9ee66) - readers group by
1941 + // source_url and may pick any chunk as the representative.
1942 + $page_title = self::source_title_for($source_url);
1943 + if ($page_title !== '' && self::ensure_kb_title_column()) {
1944 + $row['title'] = $page_title;
1945 + $formats[] = '%s';
1946 + }
1947 + $result = $wpdb->insert($table_name, $row, $formats);
1500 1948
1501 1949 if ($result === false) {
1502 1950 return new WP_Error('database_failed', 'Failed to insert chunk: ' . $wpdb->last_error);
1503 1951 }
@@ -1505,8 +1953,142 @@
1505 1953 return true;
1506 1954 }
1507 1955
1508 1956 /**
1957 + * Option holding the page an uploaded file should be cited as (plan 025ea2).
1958 + * Keyed "<bot_id>|upload://<filename>" => https URL. The entry itself keeps
1959 + * its upload:// identity, so a re-upload of the same file still replaces the
1960 + * same entry and never collides with the cited page's own knowledge entry.
1961 + */
1962 +const UPLOAD_SOURCE_URLS_OPTION = 'mxchat_upload_source_urls';
1963 +
1964 +/**
1965 + * Whether a stored source is an uploaded file (Document Upload / PDF Upload).
1966 + *
1967 + * @param string $source_url Stored source identity.
1968 + * @return bool
1969 + */
1970 +public static function is_upload_source($source_url) {
1971 + return is_string($source_url) && stripos($source_url, 'upload://') === 0;
1972 +}
1973 +
1974 +/**
1975 + * File name of an uploaded source, for a plain-text label.
1976 + *
1977 + * @param string $source_url Stored source identity (upload://<filename>).
1978 + * @return string
1979 + */
1980 +public static function upload_source_name($source_url) {
1981 + return self::is_upload_source($source_url) ? substr(self::upload_source_base($source_url), strlen('upload://')) : '';
1982 +}
1983 +
1984 +/**
1985 + * An uploaded source without its page fragment (plan dcc19d). A page of an
1986 + * uploaded PDF is stored as upload://<filename>#page=<n>; the file it belongs
1987 + * to, and the key its Source URL is kept under, is upload://<filename>.
1988 + *
1989 + * @param string $source_url Stored source identity.
1990 + * @return string
1991 + */
1992 +public static function upload_source_base($source_url) {
1993 + return is_string($source_url) ? preg_replace('/#page=\d+$/', '', $source_url) : '';
1994 +}
1995 +
1996 +/**
1997 + * Page number carried by an uploaded PDF page's identity, 0 when it has none.
1998 + *
1999 + * @param string $source_url Stored source identity.
2000 + * @return int
2001 + */
2002 +public static function upload_source_page($source_url) {
2003 + return (self::is_upload_source($source_url) && preg_match('/#page=(\d+)$/', $source_url, $m)) ? (int) $m[1] : 0;
2004 +}
2005 +
2006 +/**
2007 + * Identity one page of a PDF is stored under: <pdf>#page=<n>. A web PDF goes
2008 + * through esc_url() as before. An uploaded PDF (upload://<filename>) must not:
2009 + * esc_url() empties that scheme, which stored every page of an uploaded PDF
2010 + * as anonymous manual content, so the file could not be named, cited, given
2011 + * a Source URL, or replaced by a re-upload (plan dcc19d).
2012 + *
2013 + * @param string $pdf_url The PDF's own source (URL or upload://<filename>).
2014 + * @param int $page_number 1-based page number.
2015 + * @return string
2016 + */
2017 +public static function pdf_page_identity($pdf_url, $page_number) {
2018 + $identity = $pdf_url . '#page=' . (int) $page_number;
2019 + return self::is_upload_source($pdf_url) ? sanitize_text_field($identity) : esc_url($identity);
2020 +}
2021 +
2022 +/**
2023 + * Remember (or clear) the page an uploaded file should be cited as.
2024 + * Only http(s) URLs are kept; anything else clears the entry.
2025 + *
2026 + * @param string $source_label upload://<filename>
2027 + * @param string $bot_id Bot the upload belongs to.
2028 + * @param string $url Page URL, or '' to clear.
2029 + * @return string The URL stored, '' when cleared.
2030 + */
2031 +public static function set_upload_source_url($source_label, $bot_id, $url) {
2032 + if (!self::is_upload_source($source_label)) {
2033 + return '';
2034 + }
2035 + $bot_id = ($bot_id === '' || $bot_id === null) ? 'default' : (string) $bot_id;
2036 + $url = is_string($url) ? esc_url_raw(trim($url), array('http', 'https')) : '';
2037 + if ($url !== '' && !preg_match('#^https?://#i', $url)) {
2038 + $url = '';
2039 + }
2040 +
2041 + $map = get_option(self::UPLOAD_SOURCE_URLS_OPTION, array());
2042 + if (!is_array($map)) {
2043 + $map = array();
2044 + }
2045 + $key = $bot_id . '|' . $source_label;
2046 + if ($url === '') {
2047 + if (!isset($map[$key])) {
2048 + return '';
2049 + }
2050 + unset($map[$key]);
2051 + } else {
2052 + $map[$key] = $url;
2053 + }
2054 + if (empty($map)) {
2055 + delete_option(self::UPLOAD_SOURCE_URLS_OPTION);
2056 + } else {
2057 + update_option(self::UPLOAD_SOURCE_URLS_OPTION, $map, false);
2058 + }
2059 + return $url;
2060 +}
2061 +
2062 +/**
2063 + * The page an uploaded file is cited as, '' when it has none.
2064 + * Looks under the asking bot first, then under the default bot (on the
2065 + * WordPress database store every bot reads the same entries).
2066 + *
2067 + * @param string $source_label upload://<filename>
2068 + * @param string $bot_id Bot answering the turn.
2069 + * @return string
2070 + */
2071 +public static function get_upload_source_url($source_label, $bot_id = 'default') {
2072 + if (!self::is_upload_source($source_label)) {
2073 + return '';
2074 + }
2075 + $map = get_option(self::UPLOAD_SOURCE_URLS_OPTION, array());
2076 + if (!is_array($map) || empty($map)) {
2077 + return '';
2078 + }
2079 + // A PDF page (upload://<file>#page=<n>) is cited as its file's page.
2080 + $source_label = self::upload_source_base($source_label);
2081 + $bot_id = ($bot_id === '' || $bot_id === null) ? 'default' : (string) $bot_id;
2082 + foreach (array($bot_id . '|' . $source_label, 'default|' . $source_label) as $key) {
2083 + if (!empty($map[$key]) && preg_match('#^https?://#i', $map[$key])) {
2084 + return $map[$key];
2085 + }
2086 + }
2087 + return '';
2088 +}
2089 +
2090 +/**
1509 2091 * Delete all chunks for a given URL
1510 2092 *
1511 2093 * @param string $source_url The source URL
1512 2094 * @param string $bot_id The bot ID
@@ -1514,20 +2096,144 @@
1514 2096 */
1515 2097 public static function delete_chunks_for_url($source_url, $bot_id = 'default') {
1516 2098 //error_log('[MXCHAT-CHUNK-DELETE] Deleting chunks for URL: ' . $source_url);
1517 2099
2100 + // Entry removal — mirror it to the Vector Store (unless a storage routine
2101 + // is mid-re-store, see $vectorstore_mirror_suspended).
2102 + if (!self::$vectorstore_mirror_suspended && class_exists('MxChat_Vectorstore_Manager')) {
2103 + MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
2104 + }
2105 +
1518 2106 if (self::is_pinecone_enabled_for_bot($bot_id)) {
1519 - return self::delete_pinecone_chunks_by_url($source_url, $bot_id);
2107 + $result = self::delete_pinecone_chunks_by_url($source_url, $bot_id);
1520 2108 } else {
1521 - return self::delete_wordpress_chunks_by_url($source_url);
2109 + $result = self::delete_wordpress_chunks_by_url($source_url);
1522 2110 }
2111 +
2112 + // The entry is gone for good (not the clean-slate half of a re-store):
2113 + // a media file's "already indexed, nothing changed" signature must go
2114 + // with it, or auto-sync keeps skipping the file on every later save.
2115 + if (!self::$vectorstore_mirror_suspended && !is_wp_error($result)) {
2116 + self::forget_attachment_signature($source_url, $bot_id);
2117 + }
2118 +
2119 + return $result;
1523 2120 }
1524 2121
1525 2122 /**
2123 + * Clear the media auto-sync signature (_mxchat_kb_attachment_sig) of the
2124 + * attachment a just-deleted knowledge entry was indexed from.
2125 + *
2126 + * Call it wherever a WHOLE entry is removed (never for the clean-slate half
2127 + * of a re-store, and never when sibling chunk rows survive). Posts and pages
2128 + * carry no signature; for them this is two indexed lookups that find nothing.
2129 + *
2130 + * @param string $source_url The entry's source URL.
2131 + * @param string $bot_id The bot whose entry was removed.
2132 + */
2133 +public static function forget_attachment_signature($source_url, $bot_id = 'default') {
2134 + global $wpdb;
2135 +
2136 + $source_url = (string) $source_url;
2137 + if (!preg_match('#^https?://#i', $source_url) || !self::attachment_signature_follows_bot($bot_id)) {
2138 + return;
2139 + }
2140 +
2141 + $attachment_ids = array();
2142 + $by_url = attachment_url_to_postid($source_url);
2143 + if ($by_url) {
2144 + $attachment_ids[] = (int) $by_url;
2145 + }
2146 +
2147 + // The signature stores the exact URL the indexer used, which can differ
2148 + // from where the file lives now (replaced file, scaled variant). Bounded
2149 + // by the number of signed attachments: the meta_key index does the work.
2150 + $candidates = $wpdb->get_results($wpdb->prepare(
2151 + "SELECT post_id, meta_value FROM {$wpdb->postmeta}
2152 + WHERE meta_key = '_mxchat_kb_attachment_sig' AND meta_value LIKE %s
2153 + LIMIT 20",
2154 + '%' . $wpdb->esc_like($source_url) . '%'
2155 + ));
2156 + foreach ((array) $candidates as $candidate) {
2157 + $signature = maybe_unserialize($candidate->meta_value);
2158 + if (is_array($signature) && isset($signature['url']) && $signature['url'] === $source_url) {
2159 + $attachment_ids[] = (int) $candidate->post_id;
2160 + }
2161 + }
2162 +
2163 + foreach (array_unique($attachment_ids) as $attachment_id) {
2164 + delete_post_meta($attachment_id, '_mxchat_kb_attachment_sig');
2165 + }
2166 +}
2167 +
2168 +/**
2169 + * The same, for callers that hold a Pinecone vector id instead of a URL.
2170 + * Only a base id (md5 of the source URL) names a whole entry; a single
2171 + * chunk id leaves the rest of the entry in place, so it is ignored.
2172 + */
2173 +public static function forget_attachment_signature_by_key($entry_key, $bot_id = 'default') {
2174 + global $wpdb;
2175 +
2176 + $entry_key = (string) $entry_key;
2177 + if (!preg_match('/^[a-f0-9]{32}$/', $entry_key) || !self::attachment_signature_follows_bot($bot_id)) {
2178 + return;
2179 + }
2180 +
2181 + $signed = $wpdb->get_results(
2182 + "SELECT post_id, meta_value FROM {$wpdb->postmeta}
2183 + WHERE meta_key = '_mxchat_kb_attachment_sig' LIMIT 5000"
2184 + );
2185 + foreach ((array) $signed as $row) {
2186 + $signature = maybe_unserialize($row->meta_value);
2187 + if (is_array($signature) && isset($signature['url']) && md5((string) $signature['url']) === $entry_key) {
2188 + delete_post_meta((int) $row->post_id, '_mxchat_kb_attachment_sig');
2189 + }
2190 + }
2191 +}
2192 +
2193 +/**
2194 + * Every media entry of a bot was removed at once ("Delete all").
2195 + */
2196 +public static function forget_all_attachment_signatures($bot_id = 'default') {
2197 + if ($bot_id !== 'default' && $bot_id !== 'testing' && !self::is_pinecone_enabled_for_bot($bot_id)) {
2198 + // WordPress-database "Delete all" for another bot removes that bot's
2199 + // rows only; the default bot's entries are still there.
2200 + return;
2201 + }
2202 + if (self::attachment_signature_follows_bot($bot_id)) {
2203 + delete_post_meta_by_key('_mxchat_kb_attachment_sig');
2204 + }
2205 +}
2206 +
2207 +/**
2208 + * The signature vouches for the DEFAULT bot's entry (media auto-sync only
2209 + * indexes for the default bot). Removing another bot's copy from a Pinecone
2210 + * store of its own leaves that entry, and so the signature, in place.
2211 + */
2212 +private static function attachment_signature_follows_bot($bot_id) {
2213 + if ($bot_id === 'default' || $bot_id === 'testing' || !self::is_pinecone_enabled_for_bot($bot_id)) {
2214 + return true;
2215 + }
2216 + if (!class_exists('MxChat_Pinecone_Documents') || !self::is_pinecone_enabled_for_bot('default')) {
2217 + return false;
2218 + }
2219 + $theirs = MxChat_Pinecone_Documents::config($bot_id);
2220 + $ours = MxChat_Pinecone_Documents::config('default');
2221 + return strtolower((string) $theirs['host']) === strtolower((string) $ours['host'])
2222 + && (string) $theirs['namespace'] === (string) $ours['namespace'];
2223 +}
2224 +
2225 +/**
1526 2226 * Delete all chunks for a URL from Pinecone
1527 2227 */
1528 -private static function delete_pinecone_chunks_by_url($source_url, $bot_id) {
1529 - // Get Pinecone configuration
2228 +/**
2229 + * Delete leftover md5(url)_chunk_N vectors after a URL's content was re-stored
2230 + * as a SINGLE vector (content shrank below the chunk threshold on edit/re-import).
2231 + * Unlike delete_pinecone_chunks_by_url this leaves the base id alone — the caller
2232 + * just upserted the new content there. Failure is logged, not fatal: the save
2233 + * itself succeeded, and the next save retries the sweep.
2234 + */
2235 +private static function cleanup_pinecone_chunk_stragglers($source_url, $bot_id) {
1530 2236 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1531 2237 $pinecone_options = get_option('mxchat_pinecone_addon_options');
1532 2238 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1533 2239 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
@@ -1546,21 +2252,30 @@
1546 2252 }
1547 2253 }
1548 2254
1549 2255 if (empty($host) || empty($api_key)) {
1550 - return new WP_Error('pinecone_config', 'Pinecone is not properly configured');
2256 + return;
1551 2257 }
1552 2258
1553 - $base_vector_id = md5($source_url);
1554 - $vectors_to_delete = array();
2259 + // Document index (plan 362c31): list the chunk ids and delete them through the documents API.
2260 + if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_index($bot_id)) {
2261 + $docs_cfg = array('api_key' => $api_key, 'host' => $host, 'namespace' => $namespace);
2262 + $doc_stragglers = MxChat_Pinecone_Documents::list_ids(md5($source_url) . '_chunk_', $docs_cfg);
2263 + if (!empty($doc_stragglers)) {
2264 + $doc_result = MxChat_Pinecone_Documents::delete_ids($doc_stragglers, $docs_cfg);
2265 + if (is_wp_error($doc_result) && class_exists('MxChat_Admin') && method_exists('MxChat_Admin', 'mxchat_log_debug')) {
2266 + MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to sweep stale chunk documents after single-vector re-store', array('source_url' => $source_url, 'bot_id' => $bot_id, 'count' => count($doc_stragglers)));
2267 + }
2268 + }
2269 + return;
2270 + }
1555 2271
1556 - // Add the original single-vector ID (for non-chunked content)
1557 - $vectors_to_delete[] = $base_vector_id;
2272 + $stragglers = array();
1558 2273
1559 - // Pinecone /vectors/list is a GET endpoint with query-string parameters; a POST here returns a
1560 - // non-200 silently and we end up only deleting the base vector, leaving chunks orphaned.
2274 + // Pinecone /vectors/list is a GET endpoint with query-string parameters (a POST
2275 + // answers 200-with-an-empty-body, which reads as "no stragglers").
1561 2276 $query_params = array(
1562 - 'prefix' => $base_vector_id . '_chunk_',
2277 + 'prefix' => md5($source_url) . '_chunk_',
1563 2278 'limit' => 100,
1564 2279 );
1565 2280 if (!empty($namespace)) {
1566 2281 $query_params['namespace'] = $namespace;
@@ -1567,9 +2282,8 @@
1567 2282 }
1568 2283
1569 2284 $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
1570 2285
1571 - // Paginate in case a URL has more than 100 chunks.
1572 2286 do {
1573 2287 $list_response = wp_remote_get($list_url, array(
1574 2288 'headers' => array(
1575 2289 'Api-Key' => $api_key,
@@ -1585,9 +2299,9 @@
1585 2299 $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
1586 2300 if (!empty($list_data['vectors'])) {
1587 2301 foreach ($list_data['vectors'] as $vector) {
1588 2302 if (isset($vector['id'])) {
1589 - $vectors_to_delete[] = $vector['id'];
2303 + $stragglers[] = $vector['id'];
1590 2304 }
1591 2305 }
1592 2306 }
1593 2307
@@ -1598,8 +2312,188 @@
1598 2312
1599 2313 $query_params['paginationToken'] = $next_token;
1600 2314 $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
1601 2315 } while (true);
2316 +
2317 + if (empty($stragglers)) {
2318 + return;
2319 + }
2320 +
2321 + $delete_body = array('ids' => $stragglers);
2322 + if (!empty($namespace)) {
2323 + $delete_body['namespace'] = $namespace;
2324 + }
2325 +
2326 + $delete_response = wp_remote_post("https://{$host}/vectors/delete", array(
2327 + 'headers' => array(
2328 + 'Api-Key' => $api_key,
2329 + 'accept' => 'application/json',
2330 + 'content-type' => 'application/json'
2331 + ),
2332 + 'body' => wp_json_encode($delete_body),
2333 + 'timeout' => 30
2334 + ));
2335 +
2336 + if ((is_wp_error($delete_response) || wp_remote_retrieve_response_code($delete_response) !== 200)
2337 + && class_exists('MxChat_Admin') && method_exists('MxChat_Admin', 'mxchat_log_debug')) {
2338 + MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to sweep stale chunk vectors after single-vector re-store', array('source_url' => $source_url, 'bot_id' => $bot_id, 'count' => count($stragglers)));
2339 + }
2340 +}
2341 +
2342 +/**
2343 + * Resolve the Pinecone credentials for a bot: the bot's own config when the
2344 + * multi-bot add-on supplies one, else the site-wide options. Extracted so the
2345 + * delete path and the count path can never resolve different credentials.
2346 + *
2347 + * @param string $bot_id
2348 + * @return array{api_key:string,host:string,namespace:string}
2349 + */
2350 +private static function pinecone_config_for_bot($bot_id) {
2351 + if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
2352 + $bot_pinecone_config = array();
2353 + } else {
2354 + $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
2355 + }
2356 +
2357 + if (empty($bot_pinecone_config)) {
2358 + $pinecone_options = get_option('mxchat_pinecone_addon_options');
2359 + return array(
2360 + 'api_key' => $pinecone_options['mxchat_pinecone_api_key'] ?? '',
2361 + 'host' => $pinecone_options['mxchat_pinecone_host'] ?? '',
2362 + 'namespace' => $pinecone_options['mxchat_pinecone_namespace'] ?? '',
2363 + // 'vector' | 'document' (plan 362c31) — the delete / count paths branch on it.
2364 + 'index_type' => (($pinecone_options['mxchat_pinecone_index_type'] ?? 'vector') === 'document') ? 'document' : 'vector',
2365 + );
2366 + }
2367 +
2368 + return array(
2369 + 'api_key' => $bot_pinecone_config['api_key'] ?? '',
2370 + 'host' => $bot_pinecone_config['host'] ?? '',
2371 + 'namespace' => $bot_pinecone_config['namespace'] ?? '',
2372 + 'index_type' => (($bot_pinecone_config['index_type'] ?? 'vector') === 'document') ? 'document' : 'vector',
2373 + );
2374 +}
2375 +
2376 +/**
2377 + * List every Pinecone vector id under a prefix, following pagination.
2378 + *
2379 + * Pinecone /vectors/list is a GET endpoint with query-string parameters; a POST here returns a
2380 + * non-200 silently and we end up seeing only the base vector, leaving chunks orphaned.
2381 + *
2382 + * @param string $prefix Vector-id prefix to match.
2383 + * @param array $cfg From pinecone_config_for_bot().
2384 + * @return array List of vector ids (empty when none match or the API is unreachable).
2385 + */
2386 +private static function pinecone_list_ids_by_prefix($prefix, array $cfg) {
2387 + // Document index (plan 362c31): the documents API lists by prefix too.
2388 + if (($cfg['index_type'] ?? 'vector') === 'document' && class_exists('MxChat_Pinecone_Documents')) {
2389 + return MxChat_Pinecone_Documents::list_ids($prefix, $cfg);
2390 + }
2391 +
2392 + $ids = array();
2393 +
2394 + $query_params = array(
2395 + 'prefix' => $prefix,
2396 + 'limit' => 100,
2397 + );
2398 + if (!empty($cfg['namespace'])) {
2399 + $query_params['namespace'] = $cfg['namespace'];
2400 + }
2401 +
2402 + $list_url = "https://{$cfg['host']}/vectors/list?" . http_build_query($query_params);
2403 +
2404 + // Paginate in case a URL has more than 100 chunks.
2405 + do {
2406 + $list_response = wp_remote_get($list_url, array(
2407 + 'headers' => array(
2408 + 'Api-Key' => $cfg['api_key'],
2409 + 'accept' => 'application/json',
2410 + ),
2411 + 'timeout' => 30,
2412 + ));
2413 +
2414 + if (is_wp_error($list_response) || wp_remote_retrieve_response_code($list_response) !== 200) {
2415 + break;
2416 + }
2417 +
2418 + $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
2419 + if (!empty($list_data['vectors'])) {
2420 + foreach ($list_data['vectors'] as $vector) {
2421 + if (isset($vector['id'])) {
2422 + $ids[] = $vector['id'];
2423 + }
2424 + }
2425 + }
2426 +
2427 + $next_token = $list_data['pagination']['next'] ?? '';
2428 + if (empty($next_token)) {
2429 + break;
2430 + }
2431 +
2432 + $query_params['paginationToken'] = $next_token;
2433 + $list_url = "https://{$cfg['host']}/vectors/list?" . http_build_query($query_params);
2434 + } while (true);
2435 +
2436 + return $ids;
2437 +}
2438 +
2439 +/**
2440 + * How many stored pieces a URL currently has, in whichever backend holds it.
2441 + *
2442 + * Branches on the SAME condition delete_chunks_for_url() branches on, so the
2443 + * number reported is the number that call removes. Used by the REST DELETE
2444 + * route to answer "was anything actually there?" without a second delete path.
2445 + *
2446 + * @param string $source_url
2447 + * @param string $bot_id
2448 + * @return int
2449 + */
2450 +public static function count_chunks_for_url($source_url, $bot_id = 'default') {
2451 + if (self::is_pinecone_enabled_for_bot($bot_id)) {
2452 + $cfg = self::pinecone_config_for_bot($bot_id);
2453 + if (empty($cfg['host']) || empty($cfg['api_key'])) {
2454 + return 0;
2455 + }
2456 + // The base id and every "<base>_chunk_N" share the base as a prefix,
2457 + // so one prefix listing covers chunked and non-chunked content alike.
2458 + return count(self::pinecone_list_ids_by_prefix(md5($source_url), $cfg));
2459 + }
2460 +
2461 + global $wpdb;
2462 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
2463 +
2464 + return (int) $wpdb->get_var($wpdb->prepare(
2465 + "SELECT COUNT(*) FROM {$table_name} WHERE source_url = %s",
2466 + $source_url
2467 + ));
2468 +}
2469 +
2470 +private static function delete_pinecone_chunks_by_url($source_url, $bot_id) {
2471 + $cfg = self::pinecone_config_for_bot($bot_id);
2472 + $api_key = $cfg['api_key'];
2473 + $host = $cfg['host'];
2474 + $namespace = $cfg['namespace'];
2475 +
2476 + if (empty($host) || empty($api_key)) {
2477 + return new WP_Error('pinecone_config', 'Pinecone is not properly configured');
2478 + }
2479 +
2480 + // Document index (plan 362c31): base id + every <md5>_chunk_N, documents API.
2481 + if (($cfg['index_type'] ?? 'vector') === 'document' && class_exists('MxChat_Pinecone_Documents')) {
2482 + $doc_ids = array_merge(array(md5($source_url)), MxChat_Pinecone_Documents::list_ids(md5($source_url) . '_chunk_', $cfg));
2483 + return MxChat_Pinecone_Documents::delete_ids($doc_ids, $cfg);
2484 + }
2485 +
2486 + $base_vector_id = md5($source_url);
2487 + $vectors_to_delete = array();
2488 +
2489 + // Add the original single-vector ID (for non-chunked content)
2490 + $vectors_to_delete[] = $base_vector_id;
2491 +
2492 + $vectors_to_delete = array_merge(
2493 + $vectors_to_delete,
2494 + self::pinecone_list_ids_by_prefix($base_vector_id . '_chunk_', $cfg)
2495 + );
1602 2496
1603 2497 if (empty($vectors_to_delete)) {
1604 2498 //error_log('[MXCHAT-CHUNK-DELETE] No vectors found to delete');
1605 2499 return true;