PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.18
MxChat – AI Chatbot & Content Generation for WordPress v3.2.18
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
mxchat-basic / includes / class-mxchat-utils.php

class-mxchat-utils.php in MxChat – AI Chatbot & Content Generation for WordPress 3.2.18, at includes/class-mxchat-utils.php

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