PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.16
MxChat – AI Chatbot & Content Generation for WordPress v3.2.16
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.16, at includes/class-mxchat-utils.php

1,252 lines 52.1 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 $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 }