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 +418 -69 3.2.21 → 3.2.23 View file →
@@ -185,8 +185,78 @@
185 185 unset(self::$history_cache[(string) $session_id]);
186 186 }
187 187
188 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 +/**
189 259 * Most recipients the Notification Email field will accept (plan 2f131a).
190 260 * A settings field is not a mailing list.
191 261 */
192 262 const NOTIFICATION_EMAIL_MAX = 5;
@@ -465,9 +535,9 @@
465 535 * @param string $bot_id The bot ID for multi-bot support
466 536 * @param string $content_type The type of content (post, page, pdf, url, manual, product, etc.)
467 537 * @return bool|WP_Error True on success, WP_Error on failure
468 538 */
469 -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 = '') {
470 540 global $wpdb;
471 541 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
472 542
473 543 //error_log('[MXCHAT-DB] Starting database submission for URL: ' . $source_url . ' (Bot: ' . $bot_id . ', Type: ' . $content_type . ')');
@@ -489,8 +559,14 @@
489 559 if (empty($content_type)) {
490 560 $content_type = 'content'; // Fallback for backwards compatibility
491 561 }
492 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 +
493 569 // Just ensure UTF-8 validity without aggressive escaping
494 570 $safe_content = wp_check_invalid_utf8($content);
495 571 // Remove only null bytes and other control characters, but preserve newlines (\n = \x0A) and carriage returns (\r = \x0D)
496 572 $safe_content = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $safe_content);
@@ -550,9 +626,150 @@
550 626 return $result;
551 627 }
552 628 }
553 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 +
554 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 +/**
555 772 * UPDATED: Check if Pinecone is enabled and properly configured for a specific bot
556 773 */
557 774 private static function is_pinecone_enabled_for_bot($bot_id = 'default') {
558 775 // For default bot or when multi-bot is not active, use original method
@@ -693,8 +910,13 @@
693 910 //error_log('[MXCHAT-DB] Manual content - will create new entry (no duplicate check)');
694 911 }
695 912 // ===== END FIX =====
696 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 +
697 919 // Progressive fallback mechanism for problematic content
698 920 $attempt = 1;
699 921 $max_attempts = 3;
700 922 $current_content = $safe_content;
@@ -705,39 +927,41 @@
705 927 if ($existing_id) {
706 928 //error_log('[MXCHAT-DB] Found existing entry (ID: ' . $existing_id . '). Updating... (Attempt ' . $attempt . ')');
707 929
708 930 // Update the existing row - UPDATED 2.5.6: Added content_type
709 - $result = $wpdb->update(
710 - $table_name,
711 - array(
712 - 'url' => $source_url,
713 - 'article_content' => $current_content,
714 - 'embedding_vector' => $embedding_vector_serialized,
715 - 'source_url' => $source_url,
716 - 'content_type' => $content_type,
717 - 'timestamp' => current_time('mysql'),
718 - ),
719 - array('id' => $existing_id),
720 - array('%s','%s','%s','%s','%s','%s'),
721 - 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'),
722 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'));
723 945 } else {
724 946 //error_log('[MXCHAT-DB] No existing entry found. Inserting new row... (Attempt ' . $attempt . ')');
725 947 //error_log('[MXCHAT-DB] Content sample: ' . substr($current_content, 0, 1000));
726 948
727 949 // Insert a new row - UPDATED 2.5.6: Added content_type
728 - $result = $wpdb->insert(
729 - $table_name,
730 - array(
731 - 'url' => $source_url, // Now unique for manual content
732 - 'article_content' => $current_content,
733 - 'embedding_vector' => $embedding_vector_serialized,
734 - 'source_url' => $source_url, // Now unique for manual content
735 - 'content_type' => $content_type,
736 - 'timestamp' => current_time('mysql'),
737 - ),
738 - 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'),
739 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);
740 964 }
741 965
742 966 if ($result === false) {
743 967 //error_log('[MXCHAT-DB] Database operation failed (Attempt ' . $attempt . ')');
@@ -861,9 +1085,23 @@
861 1085 'last_updated' => time(),
862 1086 'created_at' => time(), // Add creation timestamp
863 1087 'bot_id' => $bot_id, // Add bot identification
864 1088 );
865 -
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 +
866 1104 $vector_data = array(
867 1105 'id' => $vector_id,
868 1106 'values' => $embedding_vector,
869 1107 'metadata' => $metadata
@@ -1539,9 +1777,22 @@
1539 1777 'last_updated' => time(),
1540 1778 'created_at' => time(),
1541 1779 'bot_id' => $bot_id,
1542 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 + }
1543 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 +
1544 1795 $vector_data = array(
1545 1796 'id' => $vector_id,
1546 1797 'values' => $embedding_vector,
1547 1798 'metadata' => $metadata
@@ -1584,20 +1835,25 @@
1584 1835 global $wpdb;
1585 1836
1586 1837 // For chunks, we always insert new rows (no duplicate checking)
1587 1838 // The URL includes chunk info in the metadata, but source_url stays the same for grouping
1588 - $result = $wpdb->insert(
1589 - $table_name,
1590 - array(
1591 - 'url' => $source_url,
1592 - 'article_content' => $content_with_metadata,
1593 - 'embedding_vector' => $embedding_vector_serialized,
1594 - 'source_url' => $source_url,
1595 - 'content_type' => $content_type,
1596 - 'timestamp' => current_time('mysql')
1597 - ),
1598 - 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')
1599 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);
1600 1856
1601 1857 if ($result === false) {
1602 1858 return new WP_Error('database_failed', 'Failed to insert chunk: ' . $wpdb->last_error);
1603 1859 }
@@ -1661,8 +1917,21 @@
1661 1917 if (empty($host) || empty($api_key)) {
1662 1918 return;
1663 1919 }
1664 1920
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 + }
1933 +
1665 1934 $stragglers = array();
1666 1935
1667 1936 // Pinecone /vectors/list is a GET endpoint with query-string parameters (a POST
1668 1937 // answers 200-with-an-empty-body, which reads as "no stragglers").
@@ -1731,56 +2000,75 @@
1731 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)));
1732 2001 }
1733 2002 }
1734 2003
1735 -private static function delete_pinecone_chunks_by_url($source_url, $bot_id) {
1736 - // Get Pinecone configuration
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) {
1737 2013 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1738 - $pinecone_options = get_option('mxchat_pinecone_addon_options');
1739 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1740 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1741 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
2014 + $bot_pinecone_config = array();
1742 2015 } else {
1743 2016 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1744 - if (empty($bot_pinecone_config)) {
1745 - $pinecone_options = get_option('mxchat_pinecone_addon_options');
1746 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1747 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1748 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1749 - } else {
1750 - $api_key = $bot_pinecone_config['api_key'] ?? '';
1751 - $host = $bot_pinecone_config['host'] ?? '';
1752 - $namespace = $bot_pinecone_config['namespace'] ?? '';
1753 - }
1754 2017 }
1755 2018
1756 - if (empty($host) || empty($api_key)) {
1757 - return new WP_Error('pinecone_config', 'Pinecone is not properly configured');
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 + );
1758 2028 }
1759 2029
1760 - $base_vector_id = md5($source_url);
1761 - $vectors_to_delete = array();
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 +}
1762 2037
1763 - // Add the original single-vector ID (for non-chunked content)
1764 - $vectors_to_delete[] = $base_vector_id;
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 + }
1765 2053
1766 - // Pinecone /vectors/list is a GET endpoint with query-string parameters; a POST here returns a
1767 - // non-200 silently and we end up only deleting the base vector, leaving chunks orphaned.
2054 + $ids = array();
2055 +
1768 2056 $query_params = array(
1769 - 'prefix' => $base_vector_id . '_chunk_',
1770 - 'limit' => 100,
2057 + 'prefix' => $prefix,
2058 + 'limit' => 100,
1771 2059 );
1772 - if (!empty($namespace)) {
1773 - $query_params['namespace'] = $namespace;
2060 + if (!empty($cfg['namespace'])) {
2061 + $query_params['namespace'] = $cfg['namespace'];
1774 2062 }
1775 2063
1776 - $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
2064 + $list_url = "https://{$cfg['host']}/vectors/list?" . http_build_query($query_params);
1777 2065
1778 2066 // Paginate in case a URL has more than 100 chunks.
1779 2067 do {
1780 2068 $list_response = wp_remote_get($list_url, array(
1781 2069 'headers' => array(
1782 - 'Api-Key' => $api_key,
2070 + 'Api-Key' => $cfg['api_key'],
1783 2071 'accept' => 'application/json',
1784 2072 ),
1785 2073 'timeout' => 30,
1786 2074 ));
@@ -1792,9 +2080,9 @@
1792 2080 $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
1793 2081 if (!empty($list_data['vectors'])) {
1794 2082 foreach ($list_data['vectors'] as $vector) {
1795 2083 if (isset($vector['id'])) {
1796 - $vectors_to_delete[] = $vector['id'];
2084 + $ids[] = $vector['id'];
1797 2085 }
1798 2086 }
1799 2087 }
1800 2088
@@ -1803,10 +2091,71 @@
1803 2091 break;
1804 2092 }
1805 2093
1806 2094 $query_params['paginationToken'] = $next_token;
1807 - $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
2095 + $list_url = "https://{$cfg['host']}/vectors/list?" . http_build_query($query_params);
1808 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 + );
1809 2158
1810 2159 if (empty($vectors_to_delete)) {
1811 2160 //error_log('[MXCHAT-CHUNK-DELETE] No vectors found to delete');
1812 2161 return true;