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 +543 -38 3.2.22 → 3.2.24 View file →
@@ -457,8 +457,100 @@
457 457 return $options['embedding_model'] ?? '';
458 458 }
459 459
460 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 +/**
461 553 * Extract the 11-character YouTube video ID from a URL, or '' if the URL is
462 554 * not a single-video YouTube link. Single source of truth for both the KB
463 555 * ingestion side and the chat render side — do not duplicate this parsing.
464 556 * Channel, playlist, and search URLs deliberately return '' (only a URL that
@@ -535,9 +627,9 @@
535 627 * @param string $bot_id The bot ID for multi-bot support
536 628 * @param string $content_type The type of content (post, page, pdf, url, manual, product, etc.)
537 629 * @return bool|WP_Error True on success, WP_Error on failure
538 630 */
539 -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 = '') {
540 632 global $wpdb;
541 633 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
542 634
543 635 //error_log('[MXCHAT-DB] Starting database submission for URL: ' . $source_url . ' (Bot: ' . $bot_id . ', Type: ' . $content_type . ')');
@@ -559,8 +651,14 @@
559 651 if (empty($content_type)) {
560 652 $content_type = 'content'; // Fallback for backwards compatibility
561 653 }
562 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 +
563 661 // Just ensure UTF-8 validity without aggressive escaping
564 662 $safe_content = wp_check_invalid_utf8($content);
565 663 // Remove only null bytes and other control characters, but preserve newlines (\n = \x0A) and carriage returns (\r = \x0D)
566 664 $safe_content = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $safe_content);
@@ -620,9 +718,150 @@
620 718 return $result;
621 719 }
622 720 }
623 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 +
624 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 +/**
625 864 * UPDATED: Check if Pinecone is enabled and properly configured for a specific bot
626 865 */
627 866 private static function is_pinecone_enabled_for_bot($bot_id = 'default') {
628 867 // For default bot or when multi-bot is not active, use original method
@@ -763,8 +1002,13 @@
763 1002 //error_log('[MXCHAT-DB] Manual content - will create new entry (no duplicate check)');
764 1003 }
765 1004 // ===== END FIX =====
766 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 +
767 1011 // Progressive fallback mechanism for problematic content
768 1012 $attempt = 1;
769 1013 $max_attempts = 3;
770 1014 $current_content = $safe_content;
@@ -775,39 +1019,41 @@
775 1019 if ($existing_id) {
776 1020 //error_log('[MXCHAT-DB] Found existing entry (ID: ' . $existing_id . '). Updating... (Attempt ' . $attempt . ')');
777 1021
778 1022 // Update the existing row - UPDATED 2.5.6: Added content_type
779 - $result = $wpdb->update(
780 - $table_name,
781 - array(
782 - 'url' => $source_url,
783 - 'article_content' => $current_content,
784 - 'embedding_vector' => $embedding_vector_serialized,
785 - 'source_url' => $source_url,
786 - 'content_type' => $content_type,
787 - 'timestamp' => current_time('mysql'),
788 - ),
789 - array('id' => $existing_id),
790 - array('%s','%s','%s','%s','%s','%s'),
791 - 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'),
792 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'));
793 1037 } else {
794 1038 //error_log('[MXCHAT-DB] No existing entry found. Inserting new row... (Attempt ' . $attempt . ')');
795 1039 //error_log('[MXCHAT-DB] Content sample: ' . substr($current_content, 0, 1000));
796 1040
797 1041 // Insert a new row - UPDATED 2.5.6: Added content_type
798 - $result = $wpdb->insert(
799 - $table_name,
800 - array(
801 - 'url' => $source_url, // Now unique for manual content
802 - 'article_content' => $current_content,
803 - 'embedding_vector' => $embedding_vector_serialized,
804 - 'source_url' => $source_url, // Now unique for manual content
805 - 'content_type' => $content_type,
806 - 'timestamp' => current_time('mysql'),
807 - ),
808 - 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'),
809 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);
810 1056 }
811 1057
812 1058 if ($result === false) {
813 1059 //error_log('[MXCHAT-DB] Database operation failed (Attempt ' . $attempt . ')');
@@ -931,8 +1177,12 @@
931 1177 'last_updated' => time(),
932 1178 'created_at' => time(), // Add creation timestamp
933 1179 'bot_id' => $bot_id, // Add bot identification
934 1180 );
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 + }
935 1185
936 1186 // Document index (plan 362c31): same id, same metadata, through the
937 1187 // documents API. A site on the classic vector index never enters here.
938 1188 if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_index($bot_id)) {
@@ -1619,8 +1869,12 @@
1619 1869 'last_updated' => time(),
1620 1870 'created_at' => time(),
1621 1871 'bot_id' => $bot_id,
1622 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 + }
1623 1877
1624 1878 // Document index (plan 362c31): same chunk id and metadata, documents API.
1625 1879 if (class_exists('MxChat_Pinecone_Documents') && MxChat_Pinecone_Documents::is_document_index($bot_id)) {
1626 1880 return MxChat_Pinecone_Documents::upsert_one($vector_id, $chunk_text, $embedding_vector, $metadata, array(
@@ -1673,20 +1927,25 @@
1673 1927 global $wpdb;
1674 1928
1675 1929 // For chunks, we always insert new rows (no duplicate checking)
1676 1930 // The URL includes chunk info in the metadata, but source_url stays the same for grouping
1677 - $result = $wpdb->insert(
1678 - $table_name,
1679 - array(
1680 - 'url' => $source_url,
1681 - 'article_content' => $content_with_metadata,
1682 - 'embedding_vector' => $embedding_vector_serialized,
1683 - 'source_url' => $source_url,
1684 - 'content_type' => $content_type,
1685 - 'timestamp' => current_time('mysql')
1686 - ),
1687 - 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')
1688 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);
1689 1948
1690 1949 if ($result === false) {
1691 1950 return new WP_Error('database_failed', 'Failed to insert chunk: ' . $wpdb->last_error);
1692 1951 }
@@ -1694,8 +1953,142 @@
1694 1953 return true;
1695 1954 }
1696 1955
1697 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 +/**
1698 2091 * Delete all chunks for a given URL
1699 2092 *
1700 2093 * @param string $source_url The source URL
1701 2094 * @param string $bot_id The bot ID
@@ -1710,12 +2103,124 @@
1710 2103 MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
1711 2104 }
1712 2105
1713 2106 if (self::is_pinecone_enabled_for_bot($bot_id)) {
1714 - return self::delete_pinecone_chunks_by_url($source_url, $bot_id);
2107 + $result = self::delete_pinecone_chunks_by_url($source_url, $bot_id);
1715 2108 } else {
1716 - return self::delete_wordpress_chunks_by_url($source_url);
2109 + $result = self::delete_wordpress_chunks_by_url($source_url);
1717 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;
2120 +}
2121 +
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'];
1718 2223 }
1719 2224
1720 2225 /**
1721 2226 * Delete all chunks for a URL from Pinecone