PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 1.0.8
MxChat – AI Chatbot & Content Generation for WordPress v1.0.8
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
← All changes | includes/class-mxchat-utils.php +93 -1252 3.2.161.0.8 View file →
@@ -1,1252 +1,93 @@
1 -<?php
2 -if (!defined('ABSPATH')) {
3 - exit; // Exit if accessed directly
4 -}
5 -
6 -class MxChat_Utils {
7 -
8 -/**
9 - * Validate a client-supplied session id (plan-mxchat-20260731-d42bec).
10 - *
11 - * sanitize_text_field() — which every session_id read site used before this —
12 - * preserves '/' and '..'. Harmless where the value is only an option or
13 - * transient key suffix, but mxchat_send_delayed_transcript() interpolates it
14 - * into a filesystem path, so '../../../../path/x' wrote, emailed and deleted a
15 - * file outside the uploads dir.
16 - *
17 - * REJECTS rather than rewrites: a silently-stripped id would orphan the
18 - * conversation it belongs to, which is harder to diagnose than a clean refusal.
19 - * Returns '' for anything malformed, so call sites fall into the empty-session
20 - * error paths they already have.
21 - *
22 - * The generator only ever emits 'mxchat_chat_' + 32 hex chars
23 - * (class-mxchat-integrator.php, js/chat-script.js), so this is not restrictive
24 - * in practice. Length ceiling is deliberate — session ids are also used as
25 - * option-name suffixes, and WP option names cap at 191 chars.
26 - *
27 - * @param mixed $raw Raw request value.
28 - * @return string The id if well-formed, '' otherwise.
29 - */
30 -public static function sanitize_session_id($raw) {
31 - if (!is_scalar($raw)) {
32 - return '';
33 - }
34 - $val = trim((string) $raw);
35 - if ($val === '') {
36 - return '';
37 - }
38 - return preg_match('/\A[A-Za-z0-9_-]{1,128}\z/', $val) ? $val : '';
39 -}
40 -
41 -/**
42 - * Centralized embedding model registry. Single source of truth for dimensions
43 - * and provider, so model-switch protection logic doesn't drift across files.
44 - */
45 -public static function embedding_model_registry() {
46 - return array(
47 - 'text-embedding-ada-002' => array('dims' => 1536, 'provider' => 'openai', 'label' => 'Ada 2'),
48 - 'text-embedding-3-small' => array('dims' => 1536, 'provider' => 'openai', 'label' => 'TE3 Small'),
49 - 'text-embedding-3-large' => array('dims' => 3072, 'provider' => 'openai', 'label' => 'TE3 Large'),
50 - 'voyage-3-large' => array('dims' => 2048, 'provider' => 'voyage', 'label' => 'Voyage-3 Large'),
51 - 'gemini-embedding-001' => array('dims' => 1536, 'provider' => 'gemini', 'label' => 'Gemini Embedding'),
52 - );
53 -}
54 -
55 -public static function embedding_model_dimensions($model) {
56 - $registry = self::embedding_model_registry();
57 - return isset($registry[$model]) ? (int) $registry[$model]['dims'] : 0;
58 -}
59 -
60 -public static function embedding_model_label($model) {
61 - $registry = self::embedding_model_registry();
62 - return isset($registry[$model]) ? $registry[$model]['label'] : $model;
63 -}
64 -
65 -/**
66 - * Returns the model that was last used to actually write embeddings into the
67 - * KB. Differs from the user-selected setting once a switch has happened but
68 - * no re-embed has occurred yet — that's the mismatch state we warn about.
69 - */
70 -public static function get_active_embedding_model() {
71 - return get_option('mxchat_active_embedding_model', '');
72 -}
73 -
74 -/**
75 - * Stamp the model that produced the most recent successful embedding. Called
76 - * from generate_embedding() right after the API responds with a valid vector.
77 - */
78 -public static function stamp_active_embedding_model($model) {
79 - if (!empty($model) && $model !== self::get_active_embedding_model()) {
80 - update_option('mxchat_active_embedding_model', $model, false);
81 - }
82 -}
83 -
84 -/**
85 - * Extract the 11-character YouTube video ID from a URL, or '' if the URL is
86 - * not a single-video YouTube link. Single source of truth for both the KB
87 - * ingestion side and the chat render side — do not duplicate this parsing.
88 - * Channel, playlist, and search URLs deliberately return '' (only a URL that
89 - * identifies one video can be embedded).
90 - */
91 -public static function parse_youtube_id($url) {
92 - if (!is_string($url) || $url === '' || !preg_match('#^https?://#i', $url)) {
93 - return '';
94 - }
95 - $host = strtolower((string) wp_parse_url($url, PHP_URL_HOST));
96 - $host = preg_replace('/^(www|m)\./', '', $host);
97 - $path = (string) wp_parse_url($url, PHP_URL_PATH);
98 - $id = '';
99 - if ($host === 'youtu.be') {
100 - $segments = explode('/', ltrim($path, '/'));
101 - $id = $segments[0] ?? '';
102 - } elseif (in_array($host, array('youtube.com', 'youtube-nocookie.com'), true)) {
103 - if (preg_match('#^/(?:shorts|embed|live|v)/([A-Za-z0-9_-]+)#', $path, $m)) {
104 - $id = $m[1];
105 - } elseif ($path === '/watch') {
106 - parse_str((string) wp_parse_url($url, PHP_URL_QUERY), $query_vars);
107 - $id = isset($query_vars['v']) ? (string) $query_vars['v'] : '';
108 - }
109 - }
110 - $id = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $id);
111 - return (strlen($id) === 11) ? $id : '';
112 -}
113 -
114 -/**
115 - * UPDATED: Submit or update content (and its embedding) in the database.
116 - * Stores in Pinecone if enabled, otherwise stores in WordPress DB.
117 - *
118 - * @param string $content The content to be embedded.
119 - * @param string $source_url The source URL of the content.
120 - * @param string $api_key The API key used for generating embeddings.
121 - * @param string $vector_id Optional vector ID for Pinecone (if not provided, will use md5 of URL)
122 - * @param string $bot_id The bot ID for multi-bot support
123 - * @param string $content_type The type of content (post, page, pdf, url, manual, product, etc.)
124 - * @return bool|WP_Error True on success, WP_Error on failure
125 - */
126 -public static function submit_content_to_db($content, $source_url, $api_key, $vector_id = null, $bot_id = 'default', $content_type = 'content') {
127 - global $wpdb;
128 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
129 -
130 - //error_log('[MXCHAT-DB] Starting database submission for URL: ' . $source_url . ' (Bot: ' . $bot_id . ', Type: ' . $content_type . ')');
131 - //error_log('[MXCHAT-DB] Content length: ' . strlen($content) . ' bytes');
132 -
133 - // Sanitize the source URL
134 - $source_url = esc_url_raw($source_url);
135 -
136 - // Sanitize content_type
137 - $content_type = sanitize_key($content_type);
138 - if (empty($content_type)) {
139 - $content_type = 'content'; // Fallback for backwards compatibility
140 - }
141 -
142 - // Just ensure UTF-8 validity without aggressive escaping
143 - $safe_content = wp_check_invalid_utf8($content);
144 - // Remove only null bytes and other control characters, but preserve newlines (\n = \x0A) and carriage returns (\r = \x0D)
145 - $safe_content = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $safe_content);
146 -
147 - // Check if chunking should be applied
148 - $chunker = MxChat_Chunker::from_settings();
149 - if ($chunker->should_chunk($safe_content)) {
150 - //error_log('[MXCHAT-DB] Content exceeds chunk threshold, using chunked submission');
151 - return self::submit_chunked_content($safe_content, $source_url, $api_key, $bot_id, $content_type, $chunker);
152 - }
153 -
154 - // UPDATED: Generate the embedding using bot-specific configuration
155 - $embedding_vector = self::generate_embedding($content, $api_key, $bot_id);
156 -
157 - if (!is_array($embedding_vector)) {
158 - // Surface the provider's real reason instead of a fixed string (4a7c0a).
159 - $reason = is_wp_error($embedding_vector)
160 - ? $embedding_vector->get_error_message()
161 - : 'Failed to generate embedding for content';
162 - return new WP_Error('embedding_failed', $reason);
163 - }
164 -
165 - //error_log('[MXCHAT-DB] Embedding generated successfully');
166 -
167 - // UPDATED: Check if Pinecone is enabled for this specific bot
168 - if (self::is_pinecone_enabled_for_bot($bot_id)) {
169 - //error_log('[MXCHAT-DB] Pinecone is enabled for bot ' . $bot_id . ' - using Pinecone storage');
170 - // Store in Pinecone only
171 - return self::store_in_pinecone_only($embedding_vector, $content, $source_url, $vector_id, $bot_id, $content_type);
172 - } else {
173 - //error_log('[MXCHAT-DB] Pinecone not enabled for bot ' . $bot_id . ' - using WordPress storage');
174 - // Store in WordPress database only
175 - $embedding_vector_serialized = maybe_serialize($embedding_vector);
176 - return self::store_in_wordpress_db($safe_content, $source_url, $embedding_vector_serialized, $table_name, $content_type);
177 - }
178 -}
179 -
180 -/**
181 - * UPDATED: Check if Pinecone is enabled and properly configured for a specific bot
182 - */
183 -private static function is_pinecone_enabled_for_bot($bot_id = 'default') {
184 - // For default bot or when multi-bot is not active, use original method
185 - if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
186 - return self::is_pinecone_enabled();
187 - }
188 -
189 - // Get bot-specific Pinecone configuration
190 - $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
191 -
192 - if (empty($bot_pinecone_config)) {
193 - // Fallback to default configuration
194 - return self::is_pinecone_enabled();
195 - }
196 -
197 - $enabled_check = !empty($bot_pinecone_config['use_pinecone']) && $bot_pinecone_config['use_pinecone'];
198 - $api_key_check = !empty($bot_pinecone_config['api_key']);
199 - $host_check = !empty($bot_pinecone_config['host']);
200 -
201 - return $enabled_check && $api_key_check && $host_check;
202 -}
203 -
204 -/**
205 - * Check if Pinecone is enabled and properly configured (original method for default bot)
206 - */
207 -private static function is_pinecone_enabled() {
208 - $pinecone_options = get_option('mxchat_pinecone_addon_options');
209 -
210 - if (empty($pinecone_options)) {
211 - return false;
212 - }
213 -
214 - $enabled_check = !empty($pinecone_options['mxchat_use_pinecone']) && $pinecone_options['mxchat_use_pinecone'] !== '0';
215 - $api_key_check = !empty($pinecone_options['mxchat_pinecone_api_key']);
216 - $host_check = !empty($pinecone_options['mxchat_pinecone_host']);
217 -
218 - return $enabled_check && $api_key_check && $host_check;
219 -}
220 -
221 -/**
222 - * UPDATED: Store content in Pinecone only with bot support
223 - */
224 -private static function store_in_pinecone_only($embedding_vector, $content, $source_url, $vector_id = null, $bot_id = 'default', $content_type = 'content') {
225 - //error_log('[MXCHAT-PINECONE] ===== Using Pinecone-only storage for bot ' . $bot_id . ' =====');
226 -
227 - // Get bot-specific Pinecone configuration
228 - if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
229 - $pinecone_options = get_option('mxchat_pinecone_addon_options');
230 - $api_key = $pinecone_options['mxchat_pinecone_api_key'];
231 - $environment = $pinecone_options['mxchat_pinecone_environment'] ?? '';
232 - $index_name = $pinecone_options['mxchat_pinecone_index'] ?? '';
233 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
234 - } else {
235 - $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
236 - if (empty($bot_pinecone_config)) {
237 - // Fallback to default configuration
238 - $pinecone_options = get_option('mxchat_pinecone_addon_options');
239 - $api_key = $pinecone_options['mxchat_pinecone_api_key'];
240 - $environment = $pinecone_options['mxchat_pinecone_environment'] ?? '';
241 - $index_name = $pinecone_options['mxchat_pinecone_index'] ?? '';
242 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
243 - } else {
244 - $api_key = $bot_pinecone_config['api_key'];
245 - $environment = ''; // Not used in new Pinecone API
246 - $index_name = ''; // Not used in new Pinecone API
247 - $namespace = $bot_pinecone_config['namespace'] ?? '';
248 - }
249 - }
250 -
251 - $result = self::store_in_pinecone_main(
252 - $embedding_vector,
253 - $content,
254 - $source_url,
255 - $api_key,
256 - $environment,
257 - $index_name,
258 - $vector_id,
259 - $bot_id,
260 - $namespace,
261 - $content_type
262 - );
263 -
264 - if (is_wp_error($result)) {
265 - //error_log('[MXCHAT-PINECONE] Pinecone storage failed for bot ' . $bot_id . ': ' . $result->get_error_message());
266 - return $result;
267 - }
268 -
269 - //error_log('[MXCHAT-PINECONE] Pinecone storage completed successfully for bot ' . $bot_id);
270 - return true;
271 -}
272 -
273 -/**
274 - * Store content in WordPress database with progressive fallback
275 - * UPDATED 2.5.6: Now includes content_type parameter
276 - */
277 -private static function store_in_wordpress_db($safe_content, $source_url, $embedding_vector_serialized, $table_name, $content_type = 'content') {
278 - global $wpdb;
279 -
280 - //error_log('[MXCHAT-DB] ===== Using WordPress-only storage =====');
281 -
282 - // Sanitize content_type
283 - $content_type = sanitize_key($content_type);
284 - if (empty($content_type)) {
285 - $content_type = 'content'; // Fallback for backwards compatibility
286 - }
287 -
288 - // ===== FIXED: Generate unique identifier for manual content =====
289 - $original_source_url = $source_url;
290 - // Check if this is truly manual content (no URL at all) vs a real URL that filter_var rejects
291 - // filter_var(FILTER_VALIDATE_URL) rejects valid URLs with encoded chars, non-ASCII, fragments, etc.
292 - // Use a looser check: if it starts with http(s):// or has a scheme, it's a URL
293 - $has_url_scheme = !empty($source_url) && preg_match('#^https?://#i', $source_url);
294 - // Treat legacy mxchat.ai source URLs as manual — old bug assigned the site URL to manual entries
295 - $is_legacy_mxchat_url = $has_url_scheme && strpos($source_url, 'mxchat.ai') !== false;
296 - $is_manual_content = empty($source_url) || $source_url === '' || !$has_url_scheme || $is_legacy_mxchat_url;
297 -
298 - if ($is_manual_content) {
299 - // Generate unique identifier for manual content to prevent overwrites
300 - $source_url = 'mxchat://manual-content/' . time() . '-' . wp_generate_password(8, false);
301 - //error_log('[MXCHAT-DB] Generated unique ID for manual content: ' . $source_url);
302 - }
303 -
304 - // Only check for duplicates if we have a valid source URL (not manual content)
305 - $existing_id = null;
306 - if (!$is_manual_content) {
307 - $existing_id = $wpdb->get_var(
308 - $wpdb->prepare(
309 - "SELECT id FROM {$table_name} WHERE source_url = %s LIMIT 1",
310 - $source_url
311 - )
312 - );
313 - //error_log('[MXCHAT-DB] Checked for existing URL, found ID: ' . ($existing_id ?: 'none'));
314 - } else {
315 - //error_log('[MXCHAT-DB] Manual content - will create new entry (no duplicate check)');
316 - }
317 - // ===== END FIX =====
318 -
319 - // Progressive fallback mechanism for problematic content
320 - $attempt = 1;
321 - $max_attempts = 3;
322 - $current_content = $safe_content;
323 - $result = false;
324 -
325 - while ($attempt <= $max_attempts && $result === false) {
326 - try {
327 - if ($existing_id) {
328 - //error_log('[MXCHAT-DB] Found existing entry (ID: ' . $existing_id . '). Updating... (Attempt ' . $attempt . ')');
329 -
330 - // Update the existing row - UPDATED 2.5.6: Added content_type
331 - $result = $wpdb->update(
332 - $table_name,
333 - array(
334 - 'url' => $source_url,
335 - 'article_content' => $current_content,
336 - 'embedding_vector' => $embedding_vector_serialized,
337 - 'source_url' => $source_url,
338 - 'content_type' => $content_type,
339 - 'timestamp' => current_time('mysql'),
340 - ),
341 - array('id' => $existing_id),
342 - array('%s','%s','%s','%s','%s','%s'),
343 - array('%d')
344 - );
345 - } else {
346 - //error_log('[MXCHAT-DB] No existing entry found. Inserting new row... (Attempt ' . $attempt . ')');
347 - //error_log('[MXCHAT-DB] Content sample: ' . substr($current_content, 0, 1000));
348 -
349 - // Insert a new row - UPDATED 2.5.6: Added content_type
350 - $result = $wpdb->insert(
351 - $table_name,
352 - array(
353 - 'url' => $source_url, // Now unique for manual content
354 - 'article_content' => $current_content,
355 - 'embedding_vector' => $embedding_vector_serialized,
356 - 'source_url' => $source_url, // Now unique for manual content
357 - 'content_type' => $content_type,
358 - 'timestamp' => current_time('mysql'),
359 - ),
360 - array('%s','%s','%s','%s','%s','%s')
361 - );
362 - }
363 -
364 - if ($result === false) {
365 - //error_log('[MXCHAT-DB] Database operation failed (Attempt ' . $attempt . ')');
366 - //error_log('[MXCHAT-DB] MySQL Error: ' . $wpdb->last_error);
367 - //error_log('[MXCHAT-DB] MySQL Error Number: ' . $wpdb->last_errno);
368 - //error_log('[MXCHAT-DB] Last Query: ' . substr($wpdb->last_query, 0, 500));
369 - //error_log('[MXCHAT-DB] Content length: ' . strlen($current_content) . ' bytes');
370 - //error_log('[MXCHAT-DB] Embedding vector length: ' . strlen($embedding_vector_serialized) . ' bytes');
371 -
372 - // Progressively apply more aggressive sanitization on failure
373 - if ($attempt === 1) {
374 - // First fallback: Use a more aggressive character filter and shorten
375 - $current_content = preg_replace('/[^\p{L}\p{N}\s.,;:!?()-]/u', '', $current_content);
376 - $current_content = substr($current_content, 0, 50000);
377 - } else if ($attempt === 2) {
378 - // Second fallback: Keep only alphanumeric and basic punctuation, shorten further
379 - $current_content = preg_replace('/[^a-zA-Z0-9\s.,;:!?()-]/u', '', $current_content);
380 - $current_content = substr($current_content, 0, 30000);
381 - }
382 -
383 - $attempt++;
384 - }
385 - } catch (Exception $e) {
386 - //error_log('[MXCHAT-DB] Exception during database operation: ' . $e->getMessage());
387 - $attempt++;
388 - }
389 - }
390 -
391 -if ($result === false) {
392 - //error_log('[MXCHAT-DB] All database operation attempts failed');
393 - //error_log('[MXCHAT-DB] Final MySQL Error: ' . $wpdb->last_error);
394 -
395 - $detailed_error = sprintf(
396 - 'Failed to store content in WordPress database after %d attempts. MySQL Error: %s (Error #%d). Content size: %d bytes, Embedding size: %d bytes',
397 - $max_attempts,
398 - $wpdb->last_error,
399 - $wpdb->last_errno,
400 - strlen($current_content),
401 - strlen($embedding_vector_serialized)
402 - );
403 -
404 - return new WP_Error('database_failed', $detailed_error);
405 -}
406 -
407 - //error_log('[MXCHAT-DB] WordPress database operation completed successfully (Attempt ' . ($attempt - 1) . ')');
408 - return true;
409 -}
410 -
411 -/**
412 - * UPDATED: Store content in Pinecone database with bot support
413 - * UPDATED 2.5.6: Now accepts content_type parameter
414 - */
415 -private static function store_in_pinecone_main($embedding_vector, $content, $url, $api_key, $environment, $index_name, $vector_id = null, $bot_id = 'default', $namespace = '', $content_type = 'content') {
416 - //error_log('[MXCHAT-PINECONE-MAIN] ===== Starting Pinecone storage for bot ' . $bot_id . ' =====');
417 -
418 - // ===== UPDATED: Handle manual content with unique vector IDs =====
419 - if ($vector_id) {
420 - // Use provided vector ID
421 - //error_log('[MXCHAT-PINECONE-MAIN] Using provided vector ID: ' . $vector_id);
422 - } elseif (!empty($url) && preg_match('#^https?://#i', $url)) {
423 - // For URLs, use URL-based ID (existing behavior)
424 - $vector_id = md5($url);
425 - //error_log('[MXCHAT-PINECONE-MAIN] Generated vector ID from URL: ' . $vector_id);
426 - } else {
427 - // For manual content (empty/no URL scheme), generate unique ID
428 - $vector_id = 'manual_' . time() . '_' . substr(md5($content . microtime(true)), 0, 8);
429 - //error_log('[MXCHAT-PINECONE-MAIN] Generated unique vector ID for manual content: ' . $vector_id);
430 - }
431 - // ===== END UPDATE =====
432 -
433 - // Get host from bot-specific config or fallback to default
434 - if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
435 - $options = get_option('mxchat_pinecone_addon_options');
436 - $host = $options['mxchat_pinecone_host'] ?? '';
437 - } else {
438 - $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
439 - if (!empty($bot_pinecone_config)) {
440 - $host = $bot_pinecone_config['host'] ?? '';
441 - } else {
442 - $options = get_option('mxchat_pinecone_addon_options');
443 - $host = $options['mxchat_pinecone_host'] ?? '';
444 - }
445 - }
446 -
447 - //error_log('[MXCHAT-PINECONE-MAIN] Host: ' . $host);
448 - //error_log('[MXCHAT-PINECONE-MAIN] API key length: ' . strlen($api_key));
449 - //error_log('[MXCHAT-PINECONE-MAIN] Bot ID: ' . $bot_id);
450 - //error_log('[MXCHAT-PINECONE-MAIN] Namespace: ' . $namespace);
451 -
452 - if (empty($host)) {
453 - //error_log('[MXCHAT-PINECONE-MAIN] ERROR: Host is empty');
454 - return new WP_Error('pinecone_config', 'Pinecone host is not configured. Please set the host in your bot settings.');
455 - }
456 -
457 - // ===== UPDATED 2.5.6: Use passed content_type or determine from URL if not provided =====
458 - // Sanitize content_type
459 - $content_type = sanitize_key($content_type);
460 - if (empty($content_type)) {
461 - // Fallback to old detection logic for backwards compatibility
462 - $is_product = false;
463 - $content_type = 'manual'; // Default for manual content
464 -
465 - if (!empty($url) && preg_match('#^https?://#i', $url)) {
466 - $is_product = (strpos($url, '/product/') !== false || strpos($url, '/shop/') !== false);
467 - $content_type = $is_product ? 'product' : 'content';
468 - }
469 - }
470 -
471 - //error_log('[MXCHAT-PINECONE-MAIN] Content type: ' . $content_type);
472 - // ===== END UPDATE =====
473 -
474 - $api_endpoint = "https://{$host}/vectors/upsert";
475 - //error_log('[MXCHAT-PINECONE-MAIN] API endpoint: ' . $api_endpoint);
476 -
477 - // UPDATED 2.5.6: Use provided content_type in metadata
478 - $metadata = array(
479 - 'text' => $content,
480 - 'source_url' => $url, // Can be empty for manual content
481 - 'type' => $content_type, // Now supports: post, page, pdf, url, manual, product, etc.
482 - 'last_updated' => time(),
483 - 'created_at' => time(), // Add creation timestamp
484 - 'bot_id' => $bot_id, // Add bot identification
485 - );
486 -
487 - $vector_data = array(
488 - 'id' => $vector_id,
489 - 'values' => $embedding_vector,
490 - 'metadata' => $metadata
491 - );
492 -
493 - $request_body = array(
494 - 'vectors' => array($vector_data)
495 - );
496 -
497 - // Add namespace if specified for multi-bot separation
498 - if (!empty($namespace)) {
499 - $request_body['namespace'] = $namespace;
500 - //error_log('[MXCHAT-PINECONE-MAIN] Using namespace: ' . $namespace);
501 - }
502 -
503 - //error_log('[MXCHAT-PINECONE-MAIN] Request body prepared (embedding dimensions: ' . count($embedding_vector) . ')');
504 -
505 - $response = wp_remote_post($api_endpoint, array(
506 - 'headers' => array(
507 - 'Api-Key' => $api_key,
508 - 'accept' => 'application/json',
509 - 'content-type' => 'application/json'
510 - ),
511 - 'body' => wp_json_encode($request_body),
512 - 'timeout' => 30,
513 - 'data_format' => 'body'
514 - ));
515 -
516 - if (is_wp_error($response)) {
517 - //error_log('[MXCHAT-PINECONE-MAIN] WordPress request error: ' . $response->get_error_message());
518 - return new WP_Error('pinecone_request', $response->get_error_message());
519 - }
520 -
521 - $response_code = wp_remote_retrieve_response_code($response);
522 - //error_log('[MXCHAT-PINECONE-MAIN] Response code: ' . $response_code);
523 -
524 - if ($response_code !== 200) {
525 - $body = wp_remote_retrieve_body($response);
526 - //error_log('[MXCHAT-PINECONE-MAIN] API error - Response body: ' . $body);
527 - return new WP_Error('pinecone_api', sprintf(
528 - 'Pinecone API error (HTTP %d): %s',
529 - $response_code,
530 - $body
531 - ));
532 - }
533 -
534 - $response_body = wp_remote_retrieve_body($response);
535 - //error_log('[MXCHAT-PINECONE-MAIN] Success response: ' . $response_body);
536 - //error_log('[MXCHAT-PINECONE-MAIN] Successfully stored in Pinecone for bot ' . $bot_id);
537 - //error_log('[MXCHAT-PINECONE-MAIN] ===== Pinecone storage complete =====');
538 -
539 - return true;
540 -}
541 -
542 -/**
543 - * UPDATED: Generate an embedding for the given text using bot-specific configuration.
544 - *
545 - * @param string $text The text to be embedded.
546 - * @param string $api_key The API key used for generating embeddings.
547 - * @param string $bot_id The bot ID for multi-bot support
548 - * @return array|null The embedding vector or null on failure.
549 - */
550 -private static function generate_embedding($text, $api_key, $bot_id = 'default') {
551 - // Get bot-specific options
552 - if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
553 - $options = get_option('mxchat_options');
554 - } else {
555 - $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
556 - $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options');
557 - }
558 -
559 - // Opt-in: when the custom provider is selected for embeddings, route the KB
560 - // INDEX side through the same custom endpoint the query side uses, so stored
561 - // vectors and query vectors come from the same model. Default-off behavior
562 - // below is untouched.
563 - if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
564 - $custom = self::generate_embedding_custom($text, $options);
565 - // The custom path already returns a human-readable error string —
566 - // carry it instead of collapsing to null (plan 4a7c0a).
567 - return is_array($custom) ? $custom : new WP_Error('embedding_failed', (string) $custom);
568 - }
569 -
570 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
571 -
572 - // Determine endpoint and API key based on model
573 - if (strpos($selected_model, 'voyage') === 0) {
574 - $endpoint = 'https://api.voyageai.com/v1/embeddings';
575 - $api_key = $options['voyage_api_key'] ?? '';
576 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
577 - $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
578 - $api_key = $options['gemini_api_key'] ?? '';
579 - } else {
580 - $endpoint = 'https://api.openai.com/v1/embeddings';
581 - // Use the bot-specific API key or fallback to passed API key
582 - $api_key = $options['api_key'] ?? $api_key;
583 - }
584 -
585 - // Prepare request body based on provider
586 - if (strpos($selected_model, 'gemini-embedding') === 0) {
587 - // Gemini API format
588 - $request_body = [
589 - 'model' => 'models/' . $selected_model,
590 - 'content' => [
591 - 'parts' => [
592 - ['text' => $text]
593 - ]
594 - ],
595 - 'outputDimensionality' => 1536
596 - ];
597 -
598 - // Prepare headers for Gemini (API key as query parameter)
599 - $endpoint .= '?key=' . $api_key;
600 - $headers = [
601 - 'Content-Type' => 'application/json'
602 - ];
603 - } else {
604 - // OpenAI/Voyage API format
605 - $request_body = [
606 - 'input' => $text,
607 - 'model' => $selected_model
608 - ];
609 -
610 - // Add output_dimension for voyage-3-large
611 - if ($selected_model === 'voyage-3-large') {
612 - $request_body['output_dimension'] = 2048;
613 - }
614 -
615 - // Prepare headers for OpenAI/Voyage
616 - $headers = [
617 - 'Content-Type' => 'application/json',
618 - 'Authorization' => 'Bearer ' . $api_key
619 - ];
620 - }
621 -
622 - $args = [
623 - 'body' => wp_json_encode($request_body),
624 - 'headers' => $headers,
625 - 'timeout' => 60,
626 - 'redirection' => 5,
627 - 'blocking' => true,
628 - 'httpversion' => '1.0',
629 - 'sslverify' => true,
630 - ];
631 -
632 - $response = wp_remote_post($endpoint, $args);
633 -
634 - if (is_wp_error($response)) {
635 - $message = 'Embedding request failed (connection): ' . $response->get_error_message();
636 - if (class_exists('MxChat_Admin')) {
637 - MxChat_Admin::mxchat_log_debug('embedding_error', $message, array('model' => $selected_model, 'bot_id' => $bot_id));
638 - }
639 - return new WP_Error('embedding_failed', $message);
640 - }
641 -
642 - $response_body = json_decode(wp_remote_retrieve_body($response), true);
643 -
644 - // Handle different response formats based on provider
645 - if (strpos($selected_model, 'gemini-embedding') === 0) {
646 - // Gemini API response format
647 - if (isset($response_body['embedding']['values']) && is_array($response_body['embedding']['values'])) {
648 - self::stamp_active_embedding_model($selected_model);
649 - return $response_body['embedding']['values'];
650 - } else {
651 - return self::embedding_failure_error($response, $selected_model, $api_key, $bot_id);
652 - }
653 - } else {
654 - // OpenAI/Voyage API response format
655 - if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
656 - self::stamp_active_embedding_model($selected_model);
657 - return $response_body['data'][0]['embedding'];
658 - } else {
659 - return self::embedding_failure_error($response, $selected_model, $api_key, $bot_id);
660 - }
661 - }
662 -}
663 -
664 -/**
665 - * Build a WP_Error carrying the embedding provider's REAL failure reason,
666 - * and record it in the Debug Mode log. Previously every failure path
667 - * returned bare null, so customers saw only "Failed to generate embedding
668 - * for content" / "Failed to store any chunks" with no cause (plan 4a7c0a).
669 - *
670 - * The API key never appears in provider response bodies (it travels in the
671 - * request headers), but the reason is scrubbed for it anyway before it can
672 - * reach a notice or the debug log.
673 - */
674 -private static function embedding_failure_error($response, $selected_model, $api_key, $bot_id) {
675 - $status = (int) wp_remote_retrieve_response_code($response);
676 - $raw = (string) wp_remote_retrieve_body($response);
677 - $decoded = json_decode($raw, true);
678 -
679 - // Provider error shapes: OpenAI + Gemini use {"error":{"message":…}};
680 - // Voyage uses {"detail":…}.
681 - $reason = '';
682 - if (is_array($decoded)) {
683 - if (isset($decoded['error']['message']) && is_string($decoded['error']['message'])) {
684 - $reason = $decoded['error']['message'];
685 - } elseif (isset($decoded['detail']) && is_string($decoded['detail'])) {
686 - $reason = $decoded['detail'];
687 - }
688 - }
689 - if ($reason === '') {
690 - $reason = ($raw !== '') ? substr($raw, 0, 200) : 'empty or malformed response';
691 - }
692 - if (is_string($api_key) && $api_key !== '') {
693 - $reason = str_replace($api_key, '[redacted]', $reason);
694 - }
695 - $reason = substr($reason, 0, 300);
696 - $message = sprintf('Embedding failed (%s, HTTP %d): %s', $selected_model, $status, $reason);
697 -
698 - if (class_exists('MxChat_Admin')) {
699 - MxChat_Admin::mxchat_log_debug('embedding_error', $message, array(
700 - 'model' => $selected_model,
701 - 'status' => $status,
702 - 'bot_id' => $bot_id,
703 - ));
704 - }
705 -
706 - return new WP_Error('embedding_failed', $message);
707 -}
708 -
709 -/**
710 - * Generate an embedding via a Custom (OpenAI-compatible) provider's /embeddings route.
711 - * Shared by every embedding entry point so the KNOWLEDGE-BASE INDEX side and the
712 - * QUERY side route through the same model when the opt-in
713 - * 'custom_provider_for_embeddings' setting is on. Mirrors the query-path logic in
714 - * MxChat_Integrator::mxchat_generate_embedding_custom() but takes an explicit
715 - * $options array so it is callable statically from utils + knowledge-manager.
716 - *
717 - * Returns a numeric array (the embedding vector) on success, or a human-readable
718 - * error string on failure (so callers expecting a string error, like the
719 - * knowledge-manager, can surface it directly; callers expecting array|null wrap it).
720 - *
721 - * @param string $text Text to embed.
722 - * @param array $options The resolved mxchat options (must contain the custom_provider_* keys).
723 - * @return array|string Embedding vector on success; error string on failure.
724 - */
725 -public static function generate_embedding_custom($text, $options) {
726 - if (empty($text)) {
727 - return 'No text provided for embedding generation';
728 - }
729 -
730 - $base_url = isset($options['custom_provider_base_url']) ? rtrim(trim((string) $options['custom_provider_base_url']), '/') : '';
731 - if (empty($base_url)) {
732 - return 'Custom provider Base URL is not configured.';
733 - }
734 -
735 - $api_key = isset($options['custom_provider_api_key']) ? trim((string) $options['custom_provider_api_key']) : '';
736 - $auth_scheme = isset($options['custom_provider_auth_scheme']) ? $options['custom_provider_auth_scheme'] : 'bearer';
737 - $api_version = isset($options['custom_provider_api_version']) ? trim((string) $options['custom_provider_api_version']) : '';
738 -
739 - // Embedding model: prefer the dedicated custom_provider_embedding_model, fall back to the chat model.
740 - $model = (isset($options['custom_provider_embedding_model']) && trim((string) $options['custom_provider_embedding_model']) !== '')
741 - ? trim((string) $options['custom_provider_embedding_model'])
742 - : ((isset($options['custom_provider_model']) && trim((string) $options['custom_provider_model']) !== '') ? trim((string) $options['custom_provider_model']) : 'default');
743 -
744 - $embed_url = $base_url . '/embeddings';
745 - if (!empty($api_version)) {
746 - $embed_url .= (strpos($embed_url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($api_version);
747 - }
748 -
749 - $headers = ['Content-Type' => 'application/json'];
750 - if (!empty($api_key)) {
751 - if ($auth_scheme === 'api-key') {
752 - $headers['api-key'] = $api_key;
753 - } else {
754 - $headers['Authorization'] = 'Bearer ' . $api_key;
755 - }
756 - }
757 -
758 - $response = wp_remote_post($embed_url, [
759 - 'headers' => $headers,
760 - 'body' => wp_json_encode(['input' => $text, 'model' => $model]),
761 - 'timeout' => 60,
762 - ]);
763 - if (is_wp_error($response)) {
764 - return 'Connection error when generating embeddings (custom provider): ' . $response->get_error_message();
765 - }
766 -
767 - $status = wp_remote_retrieve_response_code($response);
768 - $body = json_decode(wp_remote_retrieve_body($response), true);
769 - if ($status !== 200) {
770 - $msg = isset($body['error']['message']) ? $body['error']['message'] : 'HTTP ' . $status;
771 - return 'Custom embedding endpoint error: ' . $msg;
772 - }
773 - if (isset($body['data'][0]['embedding']) && is_array($body['data'][0]['embedding'])) {
774 - // Stamp the custom model identity so the active-embedding-model mismatch
775 - // warning reflects the real (custom) model rather than the built-in setting.
776 - self::stamp_active_embedding_model('custom:' . $model);
777 - return $body['data'][0]['embedding'];
778 - }
779 - return 'Invalid embedding response from custom provider.';
780 -}
781 -
782 -/**
783 - * Submit content as multiple chunks
784 - *
785 - * Splits large content into chunks, generates embeddings for each,
786 - * and stores them with chunk metadata for later reassembly.
787 - *
788 - * @param string $content The content to chunk and store
789 - * @param string $source_url The source URL
790 - * @param string $api_key The API key for embeddings
791 - * @param string $bot_id The bot ID
792 - * @param string $content_type The content type
793 - * @param MxChat_Chunker $chunker The chunker instance
794 - * @return bool|WP_Error True on success, WP_Error on failure
795 - */
796 -private static function submit_chunked_content($content, $source_url, $api_key, $bot_id, $content_type, $chunker) {
797 - global $wpdb;
798 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
799 -
800 - //error_log('[MXCHAT-CHUNK-DEBUG] Starting chunked submission for: ' . $source_url);
801 - //error_log('[MXCHAT-CHUNK-DEBUG] Content length: ' . strlen($content) . ' chars');
802 -
803 - // First, delete any existing chunks for this URL (clean slate)
804 - $delete_result = self::delete_chunks_for_url($source_url, $bot_id);
805 - if (is_wp_error($delete_result)) {
806 - //error_log('[MXCHAT-CHUNK-DEBUG] Warning: Failed to delete existing chunks: ' . $delete_result->get_error_message());
807 - // Continue anyway - we'll overwrite with upsert
808 - }
809 -
810 - // Split content into chunks
811 - $chunks = $chunker->chunk_text($content);
812 - $total_chunks = count($chunks);
813 -
814 - //error_log('[MXCHAT-CHUNK-DEBUG] Created ' . $total_chunks . ' chunks');
815 - foreach ($chunks as $i => $chunk) {
816 - //error_log('[MXCHAT-CHUNK-DEBUG] Chunk ' . $i . ' length: ' . strlen($chunk) . ' chars, preview: ' . substr($chunk, 0, 100));
817 - }
818 -
819 - //error_log('[MXCHAT-CHUNK] Split content into ' . $total_chunks . ' chunks');
820 -
821 - if ($total_chunks === 0) {
822 - return new WP_Error('chunking_failed', 'Content could not be split into chunks');
823 - }
824 -
825 - $errors = array();
826 - $embed_failures = 0;
827 - $first_embed_reason = '';
828 - $first_store_reason = '';
829 - $is_pinecone = self::is_pinecone_enabled_for_bot($bot_id);
830 -
831 - foreach ($chunks as $index => $chunk_text) {
832 - // Generate chunk metadata
833 - $chunk_metadata = MxChat_Chunker::create_chunk_metadata($index, $total_chunks, $source_url);
834 -
835 - // AI-Engine-style aliases so external consumers (Pinecone/Qdrant/Chroma) can rely on
836 - // a stable shorthand ('source'/'part_index'/'part_total') without parsing our internal names.
837 - $chunk_metadata['source'] = $source_url;
838 - $chunk_metadata['part_index'] = (int) $index;
839 - $chunk_metadata['part_total'] = (int) $total_chunks;
840 -
841 - /**
842 - * Filter the per-chunk metadata blob before it's written to the KB store.
843 - *
844 - * @param array $chunk_metadata Metadata array (source, part_index, part_total, chunk_index, total_chunks, source_url, parent_url_hash, document_type, ...).
845 - * @param string $chunk_text The chunk text being stored.
846 - * @param array $context ['bot_id' => string, 'content_type' => string, 'source_url' => string, 'part_index' => int, 'part_total' => int]
847 - * @return array Updated metadata array.
848 - */
849 - $chunk_metadata = apply_filters(
850 - 'mxchat_embedding_chunk_metadata',
851 - $chunk_metadata,
852 - $chunk_text,
853 - array(
854 - 'bot_id' => $bot_id,
855 - 'content_type' => $content_type,
856 - 'source_url' => $source_url,
857 - 'part_index' => (int) $index,
858 - 'part_total' => (int) $total_chunks,
859 - )
860 - );
861 -
862 - $chunk_vector_id = MxChat_Chunker::generate_chunk_vector_id($source_url, $index);
863 -
864 - //error_log('[MXCHAT-CHUNK] Processing chunk ' . ($index + 1) . '/' . $total_chunks . ' (ID: ' . $chunk_vector_id . ')');
865 -
866 - // Generate embedding for this chunk
867 - $embedding_vector = self::generate_embedding($chunk_text, $api_key, $bot_id);
868 -
869 - if (!is_array($embedding_vector)) {
870 - // Track embedding failures separately from storage failures, and
871 - // keep the first provider reason seen — the two failure classes
872 - // have opposite remedies (API key vs Pinecone/DB) (plan 4a7c0a).
873 - $embed_failures++;
874 - $reason = is_wp_error($embedding_vector) ? $embedding_vector->get_error_message() : '';
875 - if ($reason !== '' && $first_embed_reason === '') {
876 - $first_embed_reason = $reason;
877 - }
878 - $errors[] = new WP_Error('embedding_failed', 'Failed to generate embedding for chunk ' . $index . ($reason !== '' ? ' — ' . $reason : ''));
879 - continue;
880 - }
881 -
882 - if ($is_pinecone) {
883 - // Store in Pinecone with chunk metadata
884 - $result = self::store_chunk_in_pinecone(
885 - $embedding_vector,
886 - $chunk_text,
887 - $source_url,
888 - $chunk_vector_id,
889 - $bot_id,
890 - $content_type,
891 - $chunk_metadata
892 - );
893 - } else {
894 - // Store in WordPress DB with chunk metadata
895 - $content_with_metadata = MxChat_Chunker::format_chunk_for_storage($chunk_text, $chunk_metadata);
896 - $embedding_vector_serialized = maybe_serialize($embedding_vector);
897 -
898 - $result = self::store_chunk_in_wordpress_db(
899 - $content_with_metadata,
900 - $source_url,
901 - $embedding_vector_serialized,
902 - $table_name,
903 - $content_type,
904 - $chunk_metadata
905 - );
906 - }
907 -
908 - if (is_wp_error($result)) {
909 - $errors[] = $result;
910 - if ($first_store_reason === '') {
911 - $first_store_reason = $result->get_error_message();
912 - }
913 - }
914 - }
915 -
916 - if (count($errors) === $total_chunks) {
917 - // Say WHICH stage failed — "failed to store" used to cover pure
918 - // embedding failures too, sending customers to debug Pinecone when
919 - // the problem was their embedding API key (plan 4a7c0a).
920 - if ($embed_failures === $total_chunks) {
921 - return new WP_Error('chunking_failed',
922 - 'Failed to store any chunks — every chunk failed to embed'
923 - . ($first_embed_reason !== '' ? ': ' . $first_embed_reason : '')
924 - . ' Check the embedding provider API key and model under MxChat Settings.');
925 - }
926 - if ($embed_failures === 0) {
927 - return new WP_Error('chunking_failed',
928 - 'Failed to store any chunks — embeddings generated but storage failed'
929 - . ($first_store_reason !== '' ? ': ' . $first_store_reason : '')
930 - . ' Check the knowledge base storage (Pinecone index or database).');
931 - }
932 - return new WP_Error('chunking_failed', sprintf(
933 - 'Failed to store any chunks — %d failed to embed%s and %d failed to store%s',
934 - $embed_failures,
935 - $first_embed_reason !== '' ? ' (' . $first_embed_reason . ')' : '',
936 - $total_chunks - $embed_failures,
937 - $first_store_reason !== '' ? ' (' . $first_store_reason . ')' : ''
938 - ));
939 - }
940 -
941 - if (!empty($errors)) {
942 - $detail = $first_embed_reason !== '' ? $first_embed_reason : $first_store_reason;
943 - return new WP_Error('chunking_partial_failure',
944 - sprintf('Failed to store %d of %d chunks', count($errors), $total_chunks)
945 - . ($detail !== '' ? ' — first error: ' . $detail : ''));
946 - }
947 -
948 - //error_log('[MXCHAT-CHUNK] Successfully stored all ' . $total_chunks . ' chunks');
949 - return true;
950 -}
951 -
952 -/**
953 - * Store a single chunk in Pinecone with chunk-specific metadata
954 - */
955 -private static function store_chunk_in_pinecone($embedding_vector, $chunk_text, $source_url, $vector_id, $bot_id, $content_type, $chunk_metadata) {
956 - // Get Pinecone configuration
957 - if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
958 - $pinecone_options = get_option('mxchat_pinecone_addon_options');
959 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
960 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
961 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
962 - } else {
963 - $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
964 - if (empty($bot_pinecone_config)) {
965 - $pinecone_options = get_option('mxchat_pinecone_addon_options');
966 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
967 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
968 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
969 - } else {
970 - $api_key = $bot_pinecone_config['api_key'] ?? '';
971 - $host = $bot_pinecone_config['host'] ?? '';
972 - $namespace = $bot_pinecone_config['namespace'] ?? '';
973 - }
974 - }
975 -
976 - if (empty($host) || empty($api_key)) {
977 - return new WP_Error('pinecone_config', 'Pinecone is not properly configured');
978 - }
979 -
980 - $api_endpoint = "https://{$host}/vectors/upsert";
981 -
982 - // Build metadata with chunk information
983 - $metadata = array(
984 - 'text' => $chunk_text,
985 - 'source_url' => $source_url,
986 - 'type' => $content_type,
987 - 'is_chunked' => true,
988 - 'chunk_index' => $chunk_metadata['chunk_index'],
989 - 'total_chunks' => $chunk_metadata['total_chunks'],
990 - 'parent_url_hash' => $chunk_metadata['parent_url_hash'],
991 - 'last_updated' => time(),
992 - 'created_at' => time(),
993 - 'bot_id' => $bot_id,
994 - );
995 -
996 - $vector_data = array(
997 - 'id' => $vector_id,
998 - 'values' => $embedding_vector,
999 - 'metadata' => $metadata
1000 - );
1001 -
1002 - $request_body = array(
1003 - 'vectors' => array($vector_data)
1004 - );
1005 -
1006 - if (!empty($namespace)) {
1007 - $request_body['namespace'] = $namespace;
1008 - }
1009 -
1010 - $response = wp_remote_post($api_endpoint, array(
1011 - 'headers' => array(
1012 - 'Api-Key' => $api_key,
1013 - 'accept' => 'application/json',
1014 - 'content-type' => 'application/json'
1015 - ),
1016 - 'body' => wp_json_encode($request_body),
1017 - 'timeout' => 30
1018 - ));
1019 -
1020 - if (is_wp_error($response)) {
1021 - return $response;
1022 - }
1023 -
1024 - $response_code = wp_remote_retrieve_response_code($response);
1025 - if ($response_code !== 200) {
1026 - return new WP_Error('pinecone_api', 'Pinecone API error: HTTP ' . $response_code);
1027 - }
1028 -
1029 - return true;
1030 -}
1031 -
1032 -/**
1033 - * Store a single chunk in WordPress database
1034 - */
1035 -private static function store_chunk_in_wordpress_db($content_with_metadata, $source_url, $embedding_vector_serialized, $table_name, $content_type, $chunk_metadata) {
1036 - global $wpdb;
1037 -
1038 - // For chunks, we always insert new rows (no duplicate checking)
1039 - // The URL includes chunk info in the metadata, but source_url stays the same for grouping
1040 - $result = $wpdb->insert(
1041 - $table_name,
1042 - array(
1043 - 'url' => $source_url,
1044 - 'article_content' => $content_with_metadata,
1045 - 'embedding_vector' => $embedding_vector_serialized,
1046 - 'source_url' => $source_url,
1047 - 'content_type' => $content_type,
1048 - 'timestamp' => current_time('mysql')
1049 - ),
1050 - array('%s', '%s', '%s', '%s', '%s', '%s')
1051 - );
1052 -
1053 - if ($result === false) {
1054 - return new WP_Error('database_failed', 'Failed to insert chunk: ' . $wpdb->last_error);
1055 - }
1056 -
1057 - return true;
1058 -}
1059 -
1060 -/**
1061 - * Delete all chunks for a given URL
1062 - *
1063 - * @param string $source_url The source URL
1064 - * @param string $bot_id The bot ID
1065 - * @return bool|WP_Error True on success, WP_Error on failure
1066 - */
1067 -public static function delete_chunks_for_url($source_url, $bot_id = 'default') {
1068 - //error_log('[MXCHAT-CHUNK-DELETE] Deleting chunks for URL: ' . $source_url);
1069 -
1070 - if (self::is_pinecone_enabled_for_bot($bot_id)) {
1071 - return self::delete_pinecone_chunks_by_url($source_url, $bot_id);
1072 - } else {
1073 - return self::delete_wordpress_chunks_by_url($source_url);
1074 - }
1075 -}
1076 -
1077 -/**
1078 - * Delete all chunks for a URL from Pinecone
1079 - */
1080 -private static function delete_pinecone_chunks_by_url($source_url, $bot_id) {
1081 - // Get Pinecone configuration
1082 - if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1083 - $pinecone_options = get_option('mxchat_pinecone_addon_options');
1084 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1085 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1086 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1087 - } else {
1088 - $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1089 - if (empty($bot_pinecone_config)) {
1090 - $pinecone_options = get_option('mxchat_pinecone_addon_options');
1091 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1092 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1093 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1094 - } else {
1095 - $api_key = $bot_pinecone_config['api_key'] ?? '';
1096 - $host = $bot_pinecone_config['host'] ?? '';
1097 - $namespace = $bot_pinecone_config['namespace'] ?? '';
1098 - }
1099 - }
1100 -
1101 - if (empty($host) || empty($api_key)) {
1102 - return new WP_Error('pinecone_config', 'Pinecone is not properly configured');
1103 - }
1104 -
1105 - $base_vector_id = md5($source_url);
1106 - $vectors_to_delete = array();
1107 -
1108 - // Add the original single-vector ID (for non-chunked content)
1109 - $vectors_to_delete[] = $base_vector_id;
1110 -
1111 - // Pinecone /vectors/list is a GET endpoint with query-string parameters; a POST here returns a
1112 - // non-200 silently and we end up only deleting the base vector, leaving chunks orphaned.
1113 - $query_params = array(
1114 - 'prefix' => $base_vector_id . '_chunk_',
1115 - 'limit' => 100,
1116 - );
1117 - if (!empty($namespace)) {
1118 - $query_params['namespace'] = $namespace;
1119 - }
1120 -
1121 - $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
1122 -
1123 - // Paginate in case a URL has more than 100 chunks.
1124 - do {
1125 - $list_response = wp_remote_get($list_url, array(
1126 - 'headers' => array(
1127 - 'Api-Key' => $api_key,
1128 - 'accept' => 'application/json',
1129 - ),
1130 - 'timeout' => 30,
1131 - ));
1132 -
1133 - if (is_wp_error($list_response) || wp_remote_retrieve_response_code($list_response) !== 200) {
1134 - break;
1135 - }
1136 -
1137 - $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
1138 - if (!empty($list_data['vectors'])) {
1139 - foreach ($list_data['vectors'] as $vector) {
1140 - if (isset($vector['id'])) {
1141 - $vectors_to_delete[] = $vector['id'];
1142 - }
1143 - }
1144 - }
1145 -
1146 - $next_token = $list_data['pagination']['next'] ?? '';
1147 - if (empty($next_token)) {
1148 - break;
1149 - }
1150 -
1151 - $query_params['paginationToken'] = $next_token;
1152 - $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
1153 - } while (true);
1154 -
1155 - if (empty($vectors_to_delete)) {
1156 - //error_log('[MXCHAT-CHUNK-DELETE] No vectors found to delete');
1157 - return true;
1158 - }
1159 -
1160 - //error_log('[MXCHAT-CHUNK-DELETE] Deleting ' . count($vectors_to_delete) . ' vectors from Pinecone');
1161 -
1162 - // Delete vectors
1163 - $delete_url = "https://{$host}/vectors/delete";
1164 -
1165 - $delete_body = array(
1166 - 'ids' => $vectors_to_delete
1167 - );
1168 -
1169 - if (!empty($namespace)) {
1170 - $delete_body['namespace'] = $namespace;
1171 - }
1172 -
1173 - $delete_response = wp_remote_post($delete_url, array(
1174 - 'headers' => array(
1175 - 'Api-Key' => $api_key,
1176 - 'accept' => 'application/json',
1177 - 'content-type' => 'application/json'
1178 - ),
1179 - 'body' => wp_json_encode($delete_body),
1180 - 'timeout' => 30
1181 - ));
1182 -
1183 - if (is_wp_error($delete_response)) {
1184 - return $delete_response;
1185 - }
1186 -
1187 - $response_code = wp_remote_retrieve_response_code($delete_response);
1188 - if ($response_code !== 200) {
1189 - return new WP_Error('pinecone_delete', 'Failed to delete vectors: HTTP ' . $response_code);
1190 - }
1191 -
1192 - return true;
1193 -}
1194 -
1195 -/**
1196 - * Delete all chunks for a URL from WordPress database
1197 - */
1198 -private static function delete_wordpress_chunks_by_url($source_url) {
1199 - global $wpdb;
1200 - $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
1201 -
1202 - // Delete all rows with this source_url (handles both chunked and non-chunked)
1203 - $result = $wpdb->delete(
1204 - $table_name,
1205 - array('source_url' => $source_url),
1206 - array('%s')
1207 - );
1208 -
1209 - if ($result === false) {
1210 - return new WP_Error('database_delete', 'Failed to delete chunks: ' . $wpdb->last_error);
1211 - }
1212 -
1213 - //error_log('[MXCHAT-CHUNK-DELETE] Deleted ' . $result . ' rows from WordPress DB');
1214 - return true;
1215 -}
1216 -
1217 -/**
1218 - * Hybrid keyword boost (plan-38ffa1): detect whether the WP-DB knowledge
1219 - * table can serve the keyword leg via a MySQL FULLTEXT index, creating the
1220 - * index if needed. Detection runs once and caches the answer in the
1221 - * mxchat_hybrid_keyword_capability option ('fulltext' | 'like'); pass
1222 - * $force to re-detect. LIKE is the graceful fallback for shared hosts
1223 - * whose ALTER fails — the feature works either way, FULLTEXT just ranks
1224 - * better and scales.
1225 - *
1226 - * @param bool $force Re-run detection even if a cached answer exists.
1227 - * @return string 'fulltext' or 'like'
1228 - */
1229 -public static function mxchat_hybrid_detect_capability($force = false) {
1230 - $cached = get_option('mxchat_hybrid_keyword_capability', '');
1231 - if (!$force && in_array($cached, array('fulltext', 'like'), true)) {
1232 - return $cached;
1233 - }
1234 -
1235 - global $wpdb;
1236 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1237 -
1238 - $index_exists = $wpdb->get_var("SHOW INDEX FROM {$table} WHERE Key_name = 'mxchat_content_ft'");
1239 - if (!$index_exists) {
1240 - // Suppress the visible error on hosts where this is not permitted —
1241 - // failure is an expected, handled outcome (LIKE fallback).
1242 - $suppress = $wpdb->suppress_errors(true);
1243 - $wpdb->query("ALTER TABLE {$table} ADD FULLTEXT INDEX mxchat_content_ft (article_content)");
1244 - $wpdb->suppress_errors($suppress);
1245 - $index_exists = $wpdb->get_var("SHOW INDEX FROM {$table} WHERE Key_name = 'mxchat_content_ft'");
1246 - }
1247 -
1248 - $capability = $index_exists ? 'fulltext' : 'like';
1249 - update_option('mxchat_hybrid_keyword_capability', $capability);
1250 - return $capability;
1251 -}
1252 -}
1 +<?php
2 +if (!defined('ABSPATH')) {
3 + exit; // Exit if accessed directly
4 +}
5 +
6 +class MxChat_Utils {
7 +
8 + /**
9 + * Submit content and its embedding to the database.
10 + *
11 + * @param string $content The content to be embedded.
12 + * @param string $source_url The source URL of the content.
13 + * @param string $api_key The API key used for generating embeddings.
14 + */
15 + public static function submit_content_to_db($content, $source_url, $api_key) {
16 + global $wpdb;
17 + $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
18 +
19 + // Sanitize the source URL
20 + $source_url = esc_url_raw($source_url);
21 +
22 + // Generate the embedding using the API key
23 + $embedding_vector = self::generate_embedding($content, $api_key);
24 +
25 + if (is_array($embedding_vector)) {
26 + $embedding_vector_serialized = serialize($embedding_vector);
27 +
28 + // Insert or update the record in the database
29 + $wpdb->replace(
30 + $table_name,
31 + array(
32 + 'article_content' => $content,
33 + 'embedding_vector' => $embedding_vector_serialized,
34 + 'source_url' => $source_url,
35 + ),
36 + array(
37 + '%s',
38 + '%s',
39 + '%s',
40 + )
41 + );
42 + } else {
43 + //error_log('Embedding generation failed for content from ' . $source_url);
44 + }
45 + }
46 +
47 + /**
48 + * Generate an embedding for the given text.
49 + *
50 + * @param string $text The text to be embedded.
51 + * @param string $api_key The API key used for generating embeddings.
52 + * @return array|null The embedding vector or null on failure.
53 + */
54 + private static function generate_embedding($text, $api_key) {
55 + $endpoint = 'https://api.openai.com/v1/embeddings';
56 +
57 + $body = wp_json_encode([
58 + 'input' => $text,
59 + 'model' => 'text-embedding-ada-002'
60 + ]);
61 +
62 + $args = [
63 + 'body' => $body,
64 + 'headers' => [
65 + 'Content-Type' => 'application/json',
66 + 'Authorization' => 'Bearer ' . $api_key,
67 + ],
68 + 'timeout' => 60,
69 + 'redirection' => 5,
70 + 'blocking' => true,
71 + 'httpversion' => '1.0',
72 + 'sslverify' => true,
73 + ];
74 +
75 + $response = wp_remote_post($endpoint, $args);
76 +
77 + if (is_wp_error($response)) {
78 + //error_log('Error generating embedding: ' . $response->get_error_message());
79 + return null;
80 + }
81 +
82 + $response_body = json_decode(wp_remote_retrieve_body($response), true);
83 +
84 + if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
85 + return $response_body['data'][0]['embedding'];
86 + } else {
87 + //error_log('Invalid response received from embedding API.');
88 + return null;
89 + }
90 + }
91 +}
92 +
93 +?>