PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.23
MxChat – AI Chatbot & Content Generation for WordPress v3.2.23
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 +614 -58 3.2.19 → 3.2.23 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;
@@ -417,9 +535,9 @@
417 535 * @param string $bot_id The bot ID for multi-bot support
418 536 * @param string $content_type The type of content (post, page, pdf, url, manual, product, etc.)
419 537 * @return bool|WP_Error True on success, WP_Error on failure
420 538 */
421 -public static function submit_content_to_db($content, $source_url, $api_key, $vector_id = null, $bot_id = 'default', $content_type = 'content') {
539 +public static function submit_content_to_db($content, $source_url, $api_key, $vector_id = null, $bot_id = 'default', $content_type = 'content', $title = '') {
422 540 global $wpdb;
423 541 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
424 542
425 543 //error_log('[MXCHAT-DB] Starting database submission for URL: ' . $source_url . ' (Bot: ' . $bot_id . ', Type: ' . $content_type . ')');
@@ -424,10 +542,18 @@
424 542
425 543 //error_log('[MXCHAT-DB] Starting database submission for URL: ' . $source_url . ' (Bot: ' . $bot_id . ', Type: ' . $content_type . ')');
426 544 //error_log('[MXCHAT-DB] Content length: ' . strlen($content) . ' bytes');
427 545
428 - // Sanitize the source URL
429 - $source_url = esc_url_raw($source_url);
546 + // Sanitize the source URL. Internal identities (mxchat:// manual docs,
547 + // upload:// file uploads) are NOT web URLs — esc_url_raw EMPTIES them
548 + // because its protocol list is statically cached and effectively
549 + // unfilterable (measured, plan 945406 / 0485e5) — sanitize those as text
550 + // so upserts stay keyed to a stable identity across re-imports.
551 + if (preg_match('#^(mxchat|upload)://#i', $source_url)) {
552 + $source_url = sanitize_text_field($source_url);
553 + } else {
554 + $source_url = esc_url_raw($source_url);
555 + }
430 556
431 557 // Sanitize content_type
432 558 $content_type = sanitize_key($content_type);
433 559 if (empty($content_type)) {
@@ -433,8 +559,14 @@
433 559 if (empty($content_type)) {
434 560 $content_type = 'content'; // Fallback for backwards compatibility
435 561 }
436 562
563 + // Page title captured by the importer (plan d9ee66), registered under the
564 + // SANITIZED url so the storage writers below find it by the same key.
565 + if (is_string($title) && trim($title) !== '') {
566 + self::set_source_title($source_url, $title);
567 + }
568 +
437 569 // Just ensure UTF-8 validity without aggressive escaping
438 570 $safe_content = wp_check_invalid_utf8($content);
439 571 // Remove only null bytes and other control characters, but preserve newlines (\n = \x0A) and carriage returns (\r = \x0D)
440 572 $safe_content = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $safe_content);
@@ -442,9 +574,16 @@
442 574 // Check if chunking should be applied
443 575 $chunker = MxChat_Chunker::from_settings();
444 576 if ($chunker->should_chunk($safe_content)) {
445 577 //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);
578 + $chunk_result = self::submit_chunked_content($safe_content, $source_url, $api_key, $bot_id, $content_type, $chunker);
579 + // Vector Store mirror gets the entry WHOLE — one file per KB entry,
580 + // OpenAI chunks server-side; local chunking is our own embedding
581 + // concern and never reaches the store (plan 15b5c6).
582 + if ($chunk_result === true && class_exists('MxChat_Vectorstore_Manager')) {
583 + MxChat_Vectorstore_Manager::sync_upsert_entry($source_url, $safe_content, $bot_id, $content_type);
584 + }
585 + return $chunk_result;
447 586 }
448 587
449 588 // UPDATED: Generate the embedding using bot-specific configuration
450 589 $embedding_vector = self::generate_embedding($content, $api_key, $bot_id);
@@ -462,18 +601,175 @@
462 601 // UPDATED: Check if Pinecone is enabled for this specific bot
463 602 if (self::is_pinecone_enabled_for_bot($bot_id)) {
464 603 //error_log('[MXCHAT-DB] Pinecone is enabled for bot ' . $bot_id . ' - using Pinecone storage');
465 604 // Store in Pinecone only
466 - return self::store_in_pinecone_only($embedding_vector, $content, $source_url, $vector_id, $bot_id, $content_type);
605 + $result = self::store_in_pinecone_only($embedding_vector, $content, $source_url, $vector_id, $bot_id, $content_type);
606 + // If this URL previously stored as CHUNKED content and the new content fits in a
607 + // single vector, the upsert above only overwrote the base id — the old
608 + // md5(url)_chunk_N vectors would keep serving the stale text. Sweep them.
609 + // Only for a real source_url: chunk ids derive from it, and md5('') is shared
610 + // by legacy URL-less entries so a blind sweep there could hit other entries.
611 + if ($result === true && !empty($source_url)) {
612 + self::cleanup_pinecone_chunk_stragglers($source_url, $bot_id);
613 + }
614 + if ($result === true && class_exists('MxChat_Vectorstore_Manager')) {
615 + MxChat_Vectorstore_Manager::sync_upsert_entry($source_url, $safe_content, $bot_id, $content_type);
616 + }
617 + return $result;
467 618 } else {
468 619 //error_log('[MXCHAT-DB] Pinecone not enabled for bot ' . $bot_id . ' - using WordPress storage');
469 620 // Store in WordPress database only
470 621 $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);
622 + $result = self::store_in_wordpress_db($safe_content, $source_url, $embedding_vector_serialized, $table_name, $content_type);
623 + if ($result === true && class_exists('MxChat_Vectorstore_Manager')) {
624 + MxChat_Vectorstore_Manager::sync_upsert_entry($source_url, $safe_content, $bot_id, $content_type);
625 + }
626 + return $result;
472 627 }
473 628 }
474 629
630 +/* -------------------------------------------------------------------------
631 + * Source titles (3.2.23, plan d9ee66)
632 + *
633 + * A knowledge entry imported from a URL or sitemap remembers the page's own
634 + * title, so an add-on that names a source (AI Search's source cards) can show
635 + * "About Us" instead of a guess from the slug ("Aboutus"). Stored in the
636 + * `title` column of the WP knowledge table and as `title` metadata on Pinecone
637 + * vectors / documents. The importer hands the title to submit_content_to_db(),
638 + * which registers it under the source URL; the writers look it up by that key.
639 + * ---------------------------------------------------------------------- */
640 +
641 +/** @var array<string,string> source_url => title, for the current request */
642 +private static $source_titles = array();
643 +
644 +public static function set_source_title($source_url, $title) {
645 + $key = trim((string) $source_url);
646 + $title = self::clean_title($title);
647 + if ($key === '' || $title === '') {
648 + return;
649 + }
650 + self::$source_titles[$key] = $title;
651 +}
652 +
653 +public static function source_title_for($source_url) {
654 + $key = trim((string) $source_url);
655 + return ($key !== '' && isset(self::$source_titles[$key])) ? self::$source_titles[$key] : '';
656 +}
657 +
658 +/** Entities decoded, tags gone, whitespace collapsed, capped at the column width. */
659 +public static function clean_title($title) {
660 + $title = html_entity_decode((string) $title, ENT_QUOTES | ENT_HTML5, 'UTF-8');
661 + $title = wp_strip_all_tags($title);
662 + $title = trim((string) preg_replace('/\s+/u', ' ', $title));
663 + if (mb_strlen($title, 'UTF-8') > 255) {
664 + $title = rtrim(mb_substr($title, 0, 255, 'UTF-8'));
665 + }
666 + return $title;
667 +}
668 +
475 669 /**
670 + * The page's own title from fetched HTML: og:title, else <title> with a
671 + * trailing "| Site Name" style suffix removed, else the first <h1>. Returns ''
672 + * when nothing usable is there, so the caller keeps its own fallback.
673 + */
674 +public static function extract_page_title($html) {
675 + $html = (string) $html;
676 + if ($html === '') {
677 + return '';
678 + }
679 + $head = substr($html, 0, 200000);
680 + $site = '';
681 + if (preg_match('/<meta\s+[^>]*property=["\']og:site_name["\'][^>]*content=["\']([^"\']{1,120})["\']/i', $head, $m)
682 + || preg_match('/<meta\s+[^>]*content=["\']([^"\']{1,120})["\'][^>]*property=["\']og:site_name["\']/i', $head, $m)) {
683 + $site = self::clean_title($m[1]);
684 + }
685 + $candidate = '';
686 + if (preg_match('/<meta\s+[^>]*property=["\']og:title["\'][^>]*content=["\']([^"\']{1,500})["\']/i', $head, $m)
687 + || preg_match('/<meta\s+[^>]*content=["\']([^"\']{1,500})["\'][^>]*property=["\']og:title["\']/i', $head, $m)) {
688 + $candidate = self::clean_title($m[1]);
689 + }
690 + if ($candidate === '' && preg_match('/<title[^>]*>(.*?)<\/title>/is', $head, $m)) {
691 + $candidate = self::clean_title($m[1]);
692 + }
693 + if ($candidate !== '') {
694 + $candidate = self::strip_site_suffix($candidate, $site);
695 + }
696 + if ($candidate === '' && preg_match('/<h1[^>]*>(.*?)<\/h1>/is', $html, $m)) {
697 + $candidate = self::clean_title($m[1]);
698 + }
699 + return $candidate;
700 +}
701 +
702 +/**
703 + * "About Us | Just Facts" -> "About Us". Only a TRAILING segment after a spaced
704 + * separator goes, only when what is left is a real title (3+ characters) and the
705 + * removed part is short enough to be a site name; a known og:site_name wins.
706 + * Byte offsets throughout: every separator starts with an ASCII space, so a cut
707 + * there never lands inside a multibyte character.
708 + */
709 +private static function strip_site_suffix($title, $site_name = '') {
710 + $seps = array(' | ', ' – ', ' — ', ' - ', ' :: ', ' · ', ' » ', ' • ');
711 + if ($site_name !== '') {
712 + foreach ($seps as $sep) {
713 + $tail = $sep . $site_name;
714 + if (strlen($title) > strlen($tail) && substr($title, -strlen($tail)) === $tail) {
715 + return trim(substr($title, 0, strlen($title) - strlen($tail)));
716 + }
717 + }
718 + }
719 + $best_pos = -1;
720 + $best_sep = '';
721 + foreach ($seps as $sep) {
722 + $pos = strrpos($title, $sep);
723 + if ($pos !== false && $pos > $best_pos) {
724 + $best_pos = $pos;
725 + $best_sep = $sep;
726 + }
727 + }
728 + if ($best_pos > 0) {
729 + $left = trim(substr($title, 0, $best_pos));
730 + $right = trim(substr($title, $best_pos + strlen($best_sep)));
731 + if (mb_strlen($left, 'UTF-8') >= 3 && $right !== '' && mb_strlen($right, 'UTF-8') <= 60) {
732 + return $left;
733 + }
734 + }
735 + return $title;
736 +}
737 +
738 +/**
739 + * Make sure the knowledge table has the `title` column (3.2.23, plan d9ee66).
740 + * Activation adds it; this is the safety net for a copy updated in place, and
741 + * the one place add-ons ask "may I read/write title?". A positive answer is
742 + * remembered in an option, so after the first time this costs one option read.
743 + */
744 +public static function ensure_kb_title_column() {
745 + global $wpdb;
746 + static $ready = null;
747 + if ($ready !== null) {
748 + return $ready;
749 + }
750 + if (get_option('mxchat_kb_title_column') === '1') {
751 + $ready = true;
752 + return true;
753 + }
754 + $table = $wpdb->prefix . 'mxchat_system_prompt_content';
755 + $cols = $wpdb->get_col("SHOW COLUMNS FROM {$table}", 0);
756 + if (empty($cols)) {
757 + $ready = false;
758 + return false;
759 + }
760 + if (!in_array('title', $cols, true)) {
761 + $wpdb->query("ALTER TABLE {$table} ADD COLUMN title VARCHAR(255) DEFAULT NULL AFTER source_url");
762 + $cols = $wpdb->get_col("SHOW COLUMNS FROM {$table}", 0);
763 + }
764 + $ready = in_array('title', $cols, true);
765 + if ($ready) {
766 + update_option('mxchat_kb_title_column', '1', false);
767 + }
768 + return $ready;
769 +}
770 +
771 +/**
476 772 * UPDATED: Check if Pinecone is enabled and properly configured for a specific bot
477 773 */
478 774 private static function is_pinecone_enabled_for_bot($bot_id = 'default') {
479 775 // For default bot or when multi-bot is not active, use original method
@@ -585,11 +881,15 @@
585 881 // Check if this is truly manual content (no URL at all) vs a real URL that filter_var rejects
586 882 // filter_var(FILTER_VALIDATE_URL) rejects valid URLs with encoded chars, non-ASCII, fragments, etc.
587 883 // Use a looser check: if it starts with http(s):// or has a scheme, it's a URL
588 884 $has_url_scheme = !empty($source_url) && preg_match('#^https?://#i', $source_url);
885 + // upload:// identities (admin document/PDF uploads, plan 0485e5) are stable
886 + // and deduplicable — treat them like URLs so a re-upload UPDATES the row
887 + // instead of minting a fresh manual identity (which would duplicate).
888 + $has_stable_identity = $has_url_scheme || (!empty($source_url) && preg_match('#^upload://#i', $source_url));
589 889 // Treat legacy mxchat.ai source URLs as manual — old bug assigned the site URL to manual entries
590 890 $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;
891 + $is_manual_content = empty($source_url) || $source_url === '' || !$has_stable_identity || $is_legacy_mxchat_url;
592 892
593 893 if ($is_manual_content) {
594 894 // Generate unique identifier for manual content to prevent overwrites
595 895 $source_url = 'mxchat://manual-content/' . time() . '-' . wp_generate_password(8, false);
@@ -610,8 +910,13 @@
610 910 //error_log('[MXCHAT-DB] Manual content - will create new entry (no duplicate check)');
611 911 }
612 912 // ===== END FIX =====
613 913
914 + // Page title captured at import (plan d9ee66): written only when the
915 + // importer supplied one, so a re-index without a title keeps the old value.
916 + $title_value = $is_manual_content ? '' : self::source_title_for($source_url);
917 + $title_ready = ($title_value !== '') && self::ensure_kb_title_column();
918 +
614 919 // Progressive fallback mechanism for problematic content
615 920 $attempt = 1;
616 921 $max_attempts = 3;
617 922 $current_content = $safe_content;
@@ -622,39 +927,41 @@
622 927 if ($existing_id) {
623 928 //error_log('[MXCHAT-DB] Found existing entry (ID: ' . $existing_id . '). Updating... (Attempt ' . $attempt . ')');
624 929
625 930 // 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')
931 + $row = array(
932 + 'url' => $source_url,
933 + 'article_content' => $current_content,
934 + 'embedding_vector' => $embedding_vector_serialized,
935 + 'source_url' => $source_url,
936 + 'content_type' => $content_type,
937 + 'timestamp' => current_time('mysql'),
639 938 );
939 + $formats = array('%s','%s','%s','%s','%s','%s');
940 + if ($title_ready) {
941 + $row['title'] = $title_value;
942 + $formats[] = '%s';
943 + }
944 + $result = $wpdb->update($table_name, $row, array('id' => $existing_id), $formats, array('%d'));
640 945 } else {
641 946 //error_log('[MXCHAT-DB] No existing entry found. Inserting new row... (Attempt ' . $attempt . ')');
642 947 //error_log('[MXCHAT-DB] Content sample: ' . substr($current_content, 0, 1000));
643 948
644 949 // 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')
950 + $row = array(
951 + 'url' => $source_url, // Now unique for manual content
952 + 'article_content' => $current_content,
953 + 'embedding_vector' => $embedding_vector_serialized,
954 + 'source_url' => $source_url, // Now unique for manual content
955 + 'content_type' => $content_type,
956 + 'timestamp' => current_time('mysql'),
656 957 );
958 + $formats = array('%s','%s','%s','%s','%s','%s');
959 + if ($title_ready) {
960 + $row['title'] = $title_value;
961 + $formats[] = '%s';
962 + }
963 + $result = $wpdb->insert($table_name, $row, $formats);
657 964 }
658 965
659 966 if ($result === false) {
660 967 //error_log('[MXCHAT-DB] Database operation failed (Attempt ' . $attempt . ')');
@@ -713,10 +1020,11 @@
713 1020 // ===== UPDATED: Handle manual content with unique vector IDs =====
714 1021 if ($vector_id) {
715 1022 // Use provided vector ID
716 1023 //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)
1024 + } elseif (!empty($url) && preg_match('#^(https?|upload)://#i', $url)) {
1025 + // For URLs — and stable upload:// identities (plan 0485e5) — use an
1026 + // identity-derived ID so a re-import upserts the same vector.
719 1027 $vector_id = md5($url);
720 1028 //error_log('[MXCHAT-PINECONE-MAIN] Generated vector ID from URL: ' . $vector_id);
721 1029 } else {
722 1030 // For manual content (empty/no URL scheme), generate unique ID
@@ -777,9 +1085,23 @@
777 1085 'last_updated' => time(),
778 1086 'created_at' => time(), // Add creation timestamp
779 1087 'bot_id' => $bot_id, // Add bot identification
780 1088 );
781 -
1089 + $page_title = self::source_title_for($url);
1090 + if ($page_title !== '') {
1091 + $metadata['title'] = $page_title; // page title captured at import (plan d9ee66)
1092 + }
1093 +
1094 + // Document index (plan 362c31): same id, same metadata, through the
1095 + // documents API. A site on the classic vector index never enters here.
1096 + if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_index($bot_id)) {
1097 + return MxChat_Pinecone_Documents::upsert_one($vector_id, $content, $embedding_vector, $metadata, array(
1098 + 'api_key' => $api_key,
1099 + 'host' => $host,
1100 + 'namespace' => $namespace,
1101 + ));
1102 + }
1103 +
782 1104 $vector_data = array(
783 1105 'id' => $vector_id,
784 1106 'values' => $embedding_vector,
785 1107 'metadata' => $metadata
@@ -1247,10 +1569,26 @@
1247 1569
1248 1570 //error_log('[MXCHAT-CHUNK-DEBUG] Starting chunked submission for: ' . $source_url);
1249 1571 //error_log('[MXCHAT-CHUNK-DEBUG] Content length: ' . strlen($content) . ' chars');
1250 1572
1251 - // First, delete any existing chunks for this URL (clean slate)
1573 + // URL-less (manual) content needs a minted identity BEFORE chunk ids are derived:
1574 + // every chunk id is md5(source_url)_chunk_N, so with source_url = '' EVERY long
1575 + // manual document shared the md5('') prefix — and the clean-slate delete below
1576 + // wiped the PREVIOUS manual entry's chunks each time a new one was added. The
1577 + // single-vector paths already mint (mxchat:// in WP, manual_* in Pinecone); this
1578 + // was the one storage path that didn't. Keep an mxchat:// identity if the caller
1579 + // already carries one.
1580 + if (strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, 'upload://') !== 0 && !preg_match('#^https?://#i', $source_url)) {
1581 + $source_url = 'mxchat://manual-content/' . time() . '-' . wp_generate_password(8, false);
1582 + }
1583 +
1584 + // First, delete any existing chunks for this URL (clean slate).
1585 + // Mirror-suspended: this is a re-store, not an entry removal — the Vector
1586 + // Store file is replaced (or kept, on hash match) by the caller's
1587 + // sync_upsert_entry after storage succeeds.
1588 + self::$vectorstore_mirror_suspended = true;
1252 1589 $delete_result = self::delete_chunks_for_url($source_url, $bot_id);
1590 + self::$vectorstore_mirror_suspended = false;
1253 1591 if (is_wp_error($delete_result)) {
1254 1592 //error_log('[MXCHAT-CHUNK-DEBUG] Warning: Failed to delete existing chunks: ' . $delete_result->get_error_message());
1255 1593 // Continue anyway - we'll overwrite with upsert
1256 1594 }
@@ -1439,9 +1777,22 @@
1439 1777 'last_updated' => time(),
1440 1778 'created_at' => time(),
1441 1779 'bot_id' => $bot_id,
1442 1780 );
1781 + $page_title = self::source_title_for($source_url);
1782 + if ($page_title !== '') {
1783 + $metadata['title'] = $page_title; // page title captured at import (plan d9ee66)
1784 + }
1443 1785
1786 + // Document index (plan 362c31): same chunk id and metadata, documents API.
1787 + if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_index($bot_id)) {
1788 + return MxChat_Pinecone_Documents::upsert_one($vector_id, $chunk_text, $embedding_vector, $metadata, array(
1789 + 'api_key' => $api_key,
1790 + 'host' => $host,
1791 + 'namespace' => $namespace,
1792 + ));
1793 + }
1794 +
1444 1795 $vector_data = array(
1445 1796 'id' => $vector_id,
1446 1797 'values' => $embedding_vector,
1447 1798 'metadata' => $metadata
@@ -1484,20 +1835,25 @@
1484 1835 global $wpdb;
1485 1836
1486 1837 // For chunks, we always insert new rows (no duplicate checking)
1487 1838 // 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')
1839 + $row = array(
1840 + 'url' => $source_url,
1841 + 'article_content' => $content_with_metadata,
1842 + 'embedding_vector' => $embedding_vector_serialized,
1843 + 'source_url' => $source_url,
1844 + 'content_type' => $content_type,
1845 + 'timestamp' => current_time('mysql')
1499 1846 );
1847 + $formats = array('%s', '%s', '%s', '%s', '%s', '%s');
1848 + // Every chunk row carries the page title too (plan d9ee66) - readers group by
1849 + // source_url and may pick any chunk as the representative.
1850 + $page_title = self::source_title_for($source_url);
1851 + if ($page_title !== '' && self::ensure_kb_title_column()) {
1852 + $row['title'] = $page_title;
1853 + $formats[] = '%s';
1854 + }
1855 + $result = $wpdb->insert($table_name, $row, $formats);
1500 1856
1501 1857 if ($result === false) {
1502 1858 return new WP_Error('database_failed', 'Failed to insert chunk: ' . $wpdb->last_error);
1503 1859 }
@@ -1514,8 +1870,14 @@
1514 1870 */
1515 1871 public static function delete_chunks_for_url($source_url, $bot_id = 'default') {
1516 1872 //error_log('[MXCHAT-CHUNK-DELETE] Deleting chunks for URL: ' . $source_url);
1517 1873
1874 + // Entry removal — mirror it to the Vector Store (unless a storage routine
1875 + // is mid-re-store, see $vectorstore_mirror_suspended).
1876 + if (!self::$vectorstore_mirror_suspended && class_exists('MxChat_Vectorstore_Manager')) {
1877 + MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
1878 + }
1879 +
1518 1880 if (self::is_pinecone_enabled_for_bot($bot_id)) {
1519 1881 return self::delete_pinecone_chunks_by_url($source_url, $bot_id);
1520 1882 } else {
1521 1883 return self::delete_wordpress_chunks_by_url($source_url);
@@ -1524,10 +1886,16 @@
1524 1886
1525 1887 /**
1526 1888 * Delete all chunks for a URL from Pinecone
1527 1889 */
1528 -private static function delete_pinecone_chunks_by_url($source_url, $bot_id) {
1529 - // Get Pinecone configuration
1890 +/**
1891 + * Delete leftover md5(url)_chunk_N vectors after a URL's content was re-stored
1892 + * as a SINGLE vector (content shrank below the chunk threshold on edit/re-import).
1893 + * Unlike delete_pinecone_chunks_by_url this leaves the base id alone — the caller
1894 + * just upserted the new content there. Failure is logged, not fatal: the save
1895 + * itself succeeded, and the next save retries the sweep.
1896 + */
1897 +private static function cleanup_pinecone_chunk_stragglers($source_url, $bot_id) {
1530 1898 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1531 1899 $pinecone_options = get_option('mxchat_pinecone_addon_options');
1532 1900 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1533 1901 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
@@ -1546,21 +1914,30 @@
1546 1914 }
1547 1915 }
1548 1916
1549 1917 if (empty($host) || empty($api_key)) {
1550 - return new WP_Error('pinecone_config', 'Pinecone is not properly configured');
1918 + return;
1551 1919 }
1552 1920
1553 - $base_vector_id = md5($source_url);
1554 - $vectors_to_delete = array();
1921 + // Document index (plan 362c31): list the chunk ids and delete them through the documents API.
1922 + if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_index($bot_id)) {
1923 + $docs_cfg = array('api_key' => $api_key, 'host' => $host, 'namespace' => $namespace);
1924 + $doc_stragglers = MxChat_Pinecone_Documents::list_ids(md5($source_url) . '_chunk_', $docs_cfg);
1925 + if (!empty($doc_stragglers)) {
1926 + $doc_result = MxChat_Pinecone_Documents::delete_ids($doc_stragglers, $docs_cfg);
1927 + if (is_wp_error($doc_result) && class_exists('MxChat_Admin') && method_exists('MxChat_Admin', 'mxchat_log_debug')) {
1928 + 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)));
1929 + }
1930 + }
1931 + return;
1932 + }
1555 1933
1556 - // Add the original single-vector ID (for non-chunked content)
1557 - $vectors_to_delete[] = $base_vector_id;
1934 + $stragglers = array();
1558 1935
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.
1936 + // Pinecone /vectors/list is a GET endpoint with query-string parameters (a POST
1937 + // answers 200-with-an-empty-body, which reads as "no stragglers").
1561 1938 $query_params = array(
1562 - 'prefix' => $base_vector_id . '_chunk_',
1939 + 'prefix' => md5($source_url) . '_chunk_',
1563 1940 'limit' => 100,
1564 1941 );
1565 1942 if (!empty($namespace)) {
1566 1943 $query_params['namespace'] = $namespace;
@@ -1567,9 +1944,8 @@
1567 1944 }
1568 1945
1569 1946 $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
1570 1947
1571 - // Paginate in case a URL has more than 100 chunks.
1572 1948 do {
1573 1949 $list_response = wp_remote_get($list_url, array(
1574 1950 'headers' => array(
1575 1951 'Api-Key' => $api_key,
@@ -1585,9 +1961,9 @@
1585 1961 $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
1586 1962 if (!empty($list_data['vectors'])) {
1587 1963 foreach ($list_data['vectors'] as $vector) {
1588 1964 if (isset($vector['id'])) {
1589 - $vectors_to_delete[] = $vector['id'];
1965 + $stragglers[] = $vector['id'];
1590 1966 }
1591 1967 }
1592 1968 }
1593 1969
@@ -1598,8 +1974,188 @@
1598 1974
1599 1975 $query_params['paginationToken'] = $next_token;
1600 1976 $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
1601 1977 } while (true);
1978 +
1979 + if (empty($stragglers)) {
1980 + return;
1981 + }
1982 +
1983 + $delete_body = array('ids' => $stragglers);
1984 + if (!empty($namespace)) {
1985 + $delete_body['namespace'] = $namespace;
1986 + }
1987 +
1988 + $delete_response = wp_remote_post("https://{$host}/vectors/delete", array(
1989 + 'headers' => array(
1990 + 'Api-Key' => $api_key,
1991 + 'accept' => 'application/json',
1992 + 'content-type' => 'application/json'
1993 + ),
1994 + 'body' => wp_json_encode($delete_body),
1995 + 'timeout' => 30
1996 + ));
1997 +
1998 + if ((is_wp_error($delete_response) || wp_remote_retrieve_response_code($delete_response) !== 200)
1999 + && class_exists('MxChat_Admin') && method_exists('MxChat_Admin', 'mxchat_log_debug')) {
2000 + 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)));
2001 + }
2002 +}
2003 +
2004 +/**
2005 + * Resolve the Pinecone credentials for a bot: the bot's own config when the
2006 + * multi-bot add-on supplies one, else the site-wide options. Extracted so the
2007 + * delete path and the count path can never resolve different credentials.
2008 + *
2009 + * @param string $bot_id
2010 + * @return array{api_key:string,host:string,namespace:string}
2011 + */
2012 +private static function pinecone_config_for_bot($bot_id) {
2013 + if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
2014 + $bot_pinecone_config = array();
2015 + } else {
2016 + $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
2017 + }
2018 +
2019 + if (empty($bot_pinecone_config)) {
2020 + $pinecone_options = get_option('mxchat_pinecone_addon_options');
2021 + return array(
2022 + 'api_key' => $pinecone_options['mxchat_pinecone_api_key'] ?? '',
2023 + 'host' => $pinecone_options['mxchat_pinecone_host'] ?? '',
2024 + 'namespace' => $pinecone_options['mxchat_pinecone_namespace'] ?? '',
2025 + // 'vector' | 'document' (plan 362c31) — the delete / count paths branch on it.
2026 + 'index_type' => (($pinecone_options['mxchat_pinecone_index_type'] ?? 'vector') === 'document') ? 'document' : 'vector',
2027 + );
2028 + }
2029 +
2030 + return array(
2031 + 'api_key' => $bot_pinecone_config['api_key'] ?? '',
2032 + 'host' => $bot_pinecone_config['host'] ?? '',
2033 + 'namespace' => $bot_pinecone_config['namespace'] ?? '',
2034 + 'index_type' => (($bot_pinecone_config['index_type'] ?? 'vector') === 'document') ? 'document' : 'vector',
2035 + );
2036 +}
2037 +
2038 +/**
2039 + * List every Pinecone vector id under a prefix, following pagination.
2040 + *
2041 + * Pinecone /vectors/list is a GET endpoint with query-string parameters; a POST here returns a
2042 + * non-200 silently and we end up seeing only the base vector, leaving chunks orphaned.
2043 + *
2044 + * @param string $prefix Vector-id prefix to match.
2045 + * @param array $cfg From pinecone_config_for_bot().
2046 + * @return array List of vector ids (empty when none match or the API is unreachable).
2047 + */
2048 +private static function pinecone_list_ids_by_prefix($prefix, array $cfg) {
2049 + // Document index (plan 362c31): the documents API lists by prefix too.
2050 + if (($cfg['index_type'] ?? 'vector') === 'document' && class_exists('MxChat_Pinecone_Documents')) {
2051 + return MxChat_Pinecone_Documents::list_ids($prefix, $cfg);
2052 + }
2053 +
2054 + $ids = array();
2055 +
2056 + $query_params = array(
2057 + 'prefix' => $prefix,
2058 + 'limit' => 100,
2059 + );
2060 + if (!empty($cfg['namespace'])) {
2061 + $query_params['namespace'] = $cfg['namespace'];
2062 + }
2063 +
2064 + $list_url = "https://{$cfg['host']}/vectors/list?" . http_build_query($query_params);
2065 +
2066 + // Paginate in case a URL has more than 100 chunks.
2067 + do {
2068 + $list_response = wp_remote_get($list_url, array(
2069 + 'headers' => array(
2070 + 'Api-Key' => $cfg['api_key'],
2071 + 'accept' => 'application/json',
2072 + ),
2073 + 'timeout' => 30,
2074 + ));
2075 +
2076 + if (is_wp_error($list_response) || wp_remote_retrieve_response_code($list_response) !== 200) {
2077 + break;
2078 + }
2079 +
2080 + $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
2081 + if (!empty($list_data['vectors'])) {
2082 + foreach ($list_data['vectors'] as $vector) {
2083 + if (isset($vector['id'])) {
2084 + $ids[] = $vector['id'];
2085 + }
2086 + }
2087 + }
2088 +
2089 + $next_token = $list_data['pagination']['next'] ?? '';
2090 + if (empty($next_token)) {
2091 + break;
2092 + }
2093 +
2094 + $query_params['paginationToken'] = $next_token;
2095 + $list_url = "https://{$cfg['host']}/vectors/list?" . http_build_query($query_params);
2096 + } while (true);
2097 +
2098 + return $ids;
2099 +}
2100 +
2101 +/**
2102 + * How many stored pieces a URL currently has, in whichever backend holds it.
2103 + *
2104 + * Branches on the SAME condition delete_chunks_for_url() branches on, so the
2105 + * number reported is the number that call removes. Used by the REST DELETE
2106 + * route to answer "was anything actually there?" without a second delete path.
2107 + *
2108 + * @param string $source_url
2109 + * @param string $bot_id
2110 + * @return int
2111 + */
2112 +public static function count_chunks_for_url($source_url, $bot_id = 'default') {
2113 + if (self::is_pinecone_enabled_for_bot($bot_id)) {
2114 + $cfg = self::pinecone_config_for_bot($bot_id);
2115 + if (empty($cfg['host']) || empty($cfg['api_key'])) {
2116 + return 0;
2117 + }
2118 + // The base id and every "<base>_chunk_N" share the base as a prefix,
2119 + // so one prefix listing covers chunked and non-chunked content alike.
2120 + return count(self::pinecone_list_ids_by_prefix(md5($source_url), $cfg));
2121 + }
2122 +
2123 + global $wpdb;
2124 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
2125 +
2126 + return (int) $wpdb->get_var($wpdb->prepare(
2127 + "SELECT COUNT(*) FROM {$table_name} WHERE source_url = %s",
2128 + $source_url
2129 + ));
2130 +}
2131 +
2132 +private static function delete_pinecone_chunks_by_url($source_url, $bot_id) {
2133 + $cfg = self::pinecone_config_for_bot($bot_id);
2134 + $api_key = $cfg['api_key'];
2135 + $host = $cfg['host'];
2136 + $namespace = $cfg['namespace'];
2137 +
2138 + if (empty($host) || empty($api_key)) {
2139 + return new WP_Error('pinecone_config', 'Pinecone is not properly configured');
2140 + }
2141 +
2142 + // Document index (plan 362c31): base id + every <md5>_chunk_N, documents API.
2143 + if (($cfg['index_type'] ?? 'vector') === 'document' && class_exists('MxChat_Pinecone_Documents')) {
2144 + $doc_ids = array_merge(array(md5($source_url)), MxChat_Pinecone_Documents::list_ids(md5($source_url) . '_chunk_', $cfg));
2145 + return MxChat_Pinecone_Documents::delete_ids($doc_ids, $cfg);
2146 + }
2147 +
2148 + $base_vector_id = md5($source_url);
2149 + $vectors_to_delete = array();
2150 +
2151 + // Add the original single-vector ID (for non-chunked content)
2152 + $vectors_to_delete[] = $base_vector_id;
2153 +
2154 + $vectors_to_delete = array_merge(
2155 + $vectors_to_delete,
2156 + self::pinecone_list_ids_by_prefix($base_vector_id . '_chunk_', $cfg)
2157 + );
1602 2158
1603 2159 if (empty($vectors_to_delete)) {
1604 2160 //error_log('[MXCHAT-CHUNK-DELETE] No vectors found to delete');
1605 2161 return true;