PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / trunk
MxChat – AI Chatbot & Content Generation for WordPress vtrunk
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 / admin / class-pinecone-manager.php

class-pinecone-manager.php in MxChat – AI Chatbot & Content Generation for WordPress trunk, at admin/class-pinecone-manager.php

1,911 lines 70.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * File: admin/class-pinecone-manager.php
4 *
5 * Handles all Pinecone vector database operations for MxChat
6 */
7
8 if (!defined('ABSPATH')) {
9 exit; // Exit if accessed directly
10 }
11
12 class MxChat_Pinecone_Manager {
13
14 /**
15 * Constructor
16 */
17 public function __construct() {
18 }
19
20 // ========================================
21 // PINECONE FETCH OPERATIONS
22 // ========================================
23
24
25 /**
26 * Fetches records from Pinecone with bot-specific filtering
27 * UPDATED 2.6.1: Optimized for large datasets - uses server-side pagination
28 */
29 public function mxchat_fetch_pinecone_records($pinecone_options, $search_query = '', $page = 1, $per_page = 20, $bot_id = 'default', $content_type = '') {
30 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
31 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
32 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
33
34 if (empty($api_key) || empty($host)) {
35 return array('data' => array(), 'total' => 0, 'total_in_database' => 0, 'showing_recent_only' => false);
36 }
37
38 try {
39 // Get total count for the banner message (bot-specific) - lightweight call
40 $total_in_database = $this->mxchat_get_pinecone_total_count($pinecone_options, $bot_id);
41
42 // For large databases, use optimized paginated fetching
43 // Only fetch what we need for the current page, not all 1K records
44 if ($total_in_database > 500) {
45 $result = $this->mxchat_fetch_pinecone_page_optimized($pinecone_options, $search_query, $page, $per_page, $bot_id, $content_type);
46 $result['total_in_database'] = $total_in_database;
47 $result['showing_recent_only'] = true; // Always show banner when we're limiting results
48 return $result;
49 }
50
51 // For smaller databases, use the existing approach but with safety limits
52 $all_records = $this->mxchat_get_recent_entries_safe($pinecone_options, $bot_id, 500);
53
54 // Filter by content type if provided
55 if (!empty($content_type)) {
56 $all_records = array_filter($all_records, function($record) use ($content_type) {
57 $record_type = $record->type ?? 'content';
58 return $record_type === $content_type;
59 });
60 $all_records = array_values($all_records); // Re-index array
61 }
62
63 // Filter by search query if provided
64 if (!empty($search_query)) {
65 $all_records = array_filter($all_records, function($record) use ($search_query) {
66 $content = $record->article_content ?? '';
67 $source_url = $record->source_url ?? '';
68 return stripos($content, $search_query) !== false || stripos($source_url, $search_query) !== false;
69 });
70 $all_records = array_values($all_records); // Re-index array
71 }
72
73 // UPDATED 2.6.3: Group records by source_url for chunk-aware pagination
74 // This ensures pagination shows X entries per page, not X chunks
75 $grouped_by_url = array();
76 $empty_url_records = array();
77
78 foreach ($all_records as $record) {
79 $source_url = $record->source_url ?? '';
80 if (empty($source_url)) {
81 $empty_url_records[] = $record;
82 } else {
83 if (!isset($grouped_by_url[$source_url])) {
84 $grouped_by_url[$source_url] = array();
85 }
86 $grouped_by_url[$source_url][] = $record;
87 }
88 }
89
90 // Count unique entries (unique URLs + individual empty-URL records)
91 $total_unique_entries = count($grouped_by_url) + count($empty_url_records);
92
93 // Paginate by unique entries
94 $offset = ($page - 1) * $per_page;
95
96 // Build ordered list of URL groups (newest first based on first record)
97 $url_groups_ordered = array_keys($grouped_by_url);
98
99 // Get the URLs for this page
100 $page_urls = array_slice($url_groups_ordered, $offset, $per_page);
101
102 // Collect all records for this page's URLs
103 $paged_records = array();
104 foreach ($page_urls as $url) {
105 foreach ($grouped_by_url[$url] as $record) {
106 $paged_records[] = $record;
107 }
108 }
109
110 // Add empty-URL records if they fall within this page's range
111 $remaining_slots = $per_page - count($page_urls);
112 $empty_offset = max(0, $offset - count($grouped_by_url));
113 if ($remaining_slots > 0 && $empty_offset < count($empty_url_records)) {
114 $empty_page_records = array_slice($empty_url_records, $empty_offset, $remaining_slots);
115 $paged_records = array_merge($paged_records, $empty_page_records);
116 }
117
118 return array(
119 'data' => $paged_records,
120 'total' => $total_unique_entries,
121 'total_in_database' => $total_in_database,
122 'showing_recent_only' => ($total_in_database > 500)
123 );
124
125 } catch (Exception $e) {
126 //error_log('MxChat Pinecone fetch error: ' . $e->getMessage());
127 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Pinecone fetch error: ' . $e->getMessage());
128 return array('data' => array(), 'total' => 0, 'total_in_database' => 0, 'showing_recent_only' => false);
129 }
130 }
131
132 /**
133 * Optimized fetch for large Pinecone databases
134 * ADDED 2.6.1: Prevents crashes with large datasets
135 * UPDATED 2.6.1: When searching, uses semantic search with embedded query for accurate results across all 13K+ records
136 */
137 private function mxchat_fetch_pinecone_page_optimized($pinecone_options, $search_query = '', $page = 1, $per_page = 20, $bot_id = 'default', $content_type = '') {
138 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
139 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
140 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
141
142 try {
143 // If user is searching, use semantic search with embedded query
144 // This searches ALL records in Pinecone, not just fetched ones
145 if (!empty($search_query)) {
146 return $this->mxchat_semantic_search_pinecone($pinecone_options, $search_query, $page, $per_page, $bot_id, $content_type);
147 }
148
149 // For browsing (no search), use Pinecone's list endpoint for true pagination
150 return $this->mxchat_list_pinecone_records($pinecone_options, $page, $per_page, $bot_id, $content_type);
151
152 } catch (Exception $e) {
153 //error_log('MxChat optimized fetch exception: ' . $e->getMessage());
154 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Pinecone optimized fetch error: ' . $e->getMessage());
155 return array('data' => array(), 'total' => 0);
156 }
157 }
158
159 /**
160 * Semantic search across ALL Pinecone records using embedded search query
161 * This allows users to find any of their 13K+ products by searching
162 * ADDED 2.6.1
163 */
164 private function mxchat_semantic_search_pinecone($pinecone_options, $search_query, $page = 1, $per_page = 20, $bot_id = 'default', $content_type = '') {
165 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
166 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
167 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
168
169 // Get embedding for the search query
170 $query_embedding = $this->mxchat_get_search_embedding($search_query);
171
172 if (empty($query_embedding)) {
173 // Fallback to text-based search if embedding fails
174 return $this->mxchat_text_search_fallback($pinecone_options, $search_query, $page, $per_page, $bot_id, $content_type);
175 }
176
177 $query_url = "https://{$host}/query";
178
179 // Fetch more results to allow for filtering and pagination
180 $fetch_limit = min(($page * $per_page) + 100, 500);
181
182 $query_data = array(
183 'includeMetadata' => true,
184 'includeValues' => false,
185 'topK' => $fetch_limit,
186 'vector' => $query_embedding
187 );
188
189 if (!empty($namespace)) {
190 $query_data['namespace'] = $namespace;
191 }
192
193 // Add metadata filter for content type if specified
194 if (!empty($content_type)) {
195 $query_data['filter'] = array(
196 'type' => array('$eq' => $content_type)
197 );
198 }
199
200 $response = wp_remote_post($query_url, array(
201 'headers' => array(
202 'Api-Key' => $api_key,
203 'Content-Type' => 'application/json'
204 ),
205 'body' => json_encode($query_data),
206 'timeout' => 20
207 ));
208
209 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
210 return $this->mxchat_text_search_fallback($pinecone_options, $search_query, $page, $per_page, $bot_id, $content_type);
211 }
212
213 $body = wp_remote_retrieve_body($response);
214 $data = json_decode($body, true);
215
216 $records = array();
217 if (isset($data['matches'])) {
218 foreach ($data['matches'] as $match) {
219 $metadata = $match['metadata'] ?? array();
220 $created_at = $metadata['created_at'] ?? $metadata['last_updated'] ?? $metadata['timestamp'] ?? time();
221 if (!is_numeric($created_at)) {
222 $created_at = strtotime($created_at) ?: time();
223 }
224
225 $records[] = (object) array(
226 'id' => $match['id'] ?? '',
227 'article_content' => $metadata['text'] ?? '',
228 'source_url' => $metadata['source_url'] ?? '',
229 'role_restriction' => $metadata['role_restriction'] ?? 'public',
230 'type' => $metadata['type'] ?? 'content',
231 'bot_id' => $bot_id,
232 'created_at' => $created_at,
233 'data_source' => 'pinecone',
234 'relevance_score' => $match['score'] ?? 0,
235 'chunk_index' => isset($metadata['chunk_index']) ? intval($metadata['chunk_index']) : null,
236 'total_chunks' => isset($metadata['total_chunks']) ? intval($metadata['total_chunks']) : null,
237 'is_chunked' => isset($metadata['is_chunked']) ? (bool) $metadata['is_chunked'] : false
238 );
239 }
240 }
241
242 // For semantic search, results are already sorted by relevance (score)
243 // No need to re-sort by date
244
245 $total = count($records);
246 $offset = ($page - 1) * $per_page;
247 $paged_records = array_slice($records, $offset, $per_page);
248
249 $this->mxchat_batch_fetch_role_restrictions($paged_records, $bot_id);
250
251 return array(
252 'data' => $paged_records,
253 'total' => $total
254 );
255 }
256
257 /**
258 * Get embedding vector for a search query
259 * Uses the same embedding model configured for the knowledge base
260 * ADDED 2.6.1
261 */
262 private function mxchat_get_search_embedding($search_query) {
263 $options = get_option('mxchat_options', array());
264 $embedding_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
265
266 // Determine which API to use based on model
267 if (strpos($embedding_model, 'voyage-') === 0) {
268 return $this->mxchat_get_voyage_embedding($search_query, $options, $embedding_model);
269 } elseif (strpos($embedding_model, 'gemini-') === 0) {
270 return $this->mxchat_get_gemini_embedding($search_query, $options, $embedding_model);
271 } else {
272 return $this->mxchat_get_openai_embedding($search_query, $options, $embedding_model);
273 }
274 }
275
276 /**
277 * Get OpenAI embedding for search query
278 */
279 private function mxchat_get_openai_embedding($text, $options, $model) {
280 $api_key = $options['api_key'] ?? '';
281 if (empty($api_key)) {
282 return null;
283 }
284
285 $response = wp_remote_post('https://api.openai.com/v1/embeddings', array(
286 'headers' => array(
287 'Authorization' => 'Bearer ' . $api_key,
288 'Content-Type' => 'application/json'
289 ),
290 'body' => json_encode(array(
291 'model' => $model,
292 'input' => $text
293 )),
294 'timeout' => 15
295 ));
296
297 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
298 return null;
299 }
300
301 $body = json_decode(wp_remote_retrieve_body($response), true);
302 return $body['data'][0]['embedding'] ?? null;
303 }
304
305 /**
306 * Get Voyage AI embedding for search query
307 */
308 private function mxchat_get_voyage_embedding($text, $options, $model) {
309 $api_key = $options['voyage_api_key'] ?? '';
310 if (empty($api_key)) {
311 return null;
312 }
313
314 $request_body = array(
315 'model' => $model,
316 'input' => $text,
317 'input_type' => 'query'
318 );
319
320 // Add output dimensions for voyage-3-large if configured
321 if (strpos($model, 'voyage-3-large') === 0 && !empty($options['voyage_output_dimension'])) {
322 $request_body['output_dimension'] = intval($options['voyage_output_dimension']);
323 }
324
325 $response = wp_remote_post('https://api.voyageai.com/v1/embeddings', array(
326 'headers' => array(
327 'Authorization' => 'Bearer ' . $api_key,
328 'Content-Type' => 'application/json'
329 ),
330 'body' => json_encode($request_body),
331 'timeout' => 15
332 ));
333
334 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
335 return null;
336 }
337
338 $body = json_decode(wp_remote_retrieve_body($response), true);
339 return $body['data'][0]['embedding'] ?? null;
340 }
341
342 /**
343 * Get Google Gemini embedding for search query
344 */
345 private function mxchat_get_gemini_embedding($text, $options, $model) {
346 $api_key = $options['gemini_api_key'] ?? '';
347 if (empty($api_key)) {
348 return null;
349 }
350
351 $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:embedContent?key={$api_key}";
352
353 $request_body = array(
354 'model' => "models/{$model}",
355 'content' => array(
356 'parts' => array(
357 array('text' => $text)
358 )
359 ),
360 'taskType' => 'RETRIEVAL_QUERY'
361 );
362
363 // Add output dimensions if configured
364 if (!empty($options['gemini_output_dimension'])) {
365 $request_body['outputDimensionality'] = intval($options['gemini_output_dimension']);
366 }
367
368 $response = wp_remote_post($url, array(
369 'headers' => array(
370 'Content-Type' => 'application/json'
371 ),
372 'body' => json_encode($request_body),
373 'timeout' => 15
374 ));
375
376 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
377 return null;
378 }
379
380 $body = json_decode(wp_remote_retrieve_body($response), true);
381 return $body['embedding']['values'] ?? null;
382 }
383
384 /**
385 * Fallback text search when embedding fails
386 * Fetches more records and filters by text match
387 * ADDED 2.6.1
388 */
389 private function mxchat_text_search_fallback($pinecone_options, $search_query, $page, $per_page, $bot_id, $content_type) {
390 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
391 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
392 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
393
394 $query_url = "https://{$host}/query";
395 $query_vector = $this->mxchat_generate_optimized_query_vector();
396
397 // Fetch more records to search through
398 $query_data = array(
399 'includeMetadata' => true,
400 'includeValues' => false,
401 'topK' => 1000, // Fetch more for text search
402 'vector' => $query_vector
403 );
404
405 if (!empty($namespace)) {
406 $query_data['namespace'] = $namespace;
407 }
408
409 $response = wp_remote_post($query_url, array(
410 'headers' => array(
411 'Api-Key' => $api_key,
412 'Content-Type' => 'application/json'
413 ),
414 'body' => json_encode($query_data),
415 'timeout' => 20
416 ));
417
418 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
419 return array('data' => array(), 'total' => 0);
420 }
421
422 $body = wp_remote_retrieve_body($response);
423 $data = json_decode($body, true);
424
425 $records = array();
426 $search_lower = strtolower($search_query);
427
428 if (isset($data['matches'])) {
429 foreach ($data['matches'] as $match) {
430 $metadata = $match['metadata'] ?? array();
431
432 // Filter by content type
433 if (!empty($content_type)) {
434 $record_type = $metadata['type'] ?? 'content';
435 if ($record_type !== $content_type) {
436 continue;
437 }
438 }
439
440 // Text search filter
441 $content = strtolower($metadata['text'] ?? '');
442 $source_url = strtolower($metadata['source_url'] ?? '');
443 if (strpos($content, $search_lower) === false && strpos($source_url, $search_lower) === false) {
444 continue;
445 }
446
447 $created_at = $metadata['created_at'] ?? $metadata['last_updated'] ?? $metadata['timestamp'] ?? time();
448 if (!is_numeric($created_at)) {
449 $created_at = strtotime($created_at) ?: time();
450 }
451
452 $records[] = (object) array(
453 'id' => $match['id'] ?? '',
454 'article_content' => $metadata['text'] ?? '',
455 'source_url' => $metadata['source_url'] ?? '',
456 'role_restriction' => $metadata['role_restriction'] ?? 'public',
457 'type' => $metadata['type'] ?? 'content',
458 'bot_id' => $bot_id,
459 'created_at' => $created_at,
460 'data_source' => 'pinecone',
461 'chunk_index' => isset($metadata['chunk_index']) ? intval($metadata['chunk_index']) : null,
462 'total_chunks' => isset($metadata['total_chunks']) ? intval($metadata['total_chunks']) : null,
463 'is_chunked' => isset($metadata['is_chunked']) ? (bool) $metadata['is_chunked'] : false
464 );
465 }
466 }
467
468 // Sort by date for text search results
469 usort($records, function($a, $b) {
470 return $b->created_at - $a->created_at;
471 });
472
473 $total = count($records);
474 $offset = ($page - 1) * $per_page;
475 $paged_records = array_slice($records, $offset, $per_page);
476
477 $this->mxchat_batch_fetch_role_restrictions($paged_records, $bot_id);
478
479 return array(
480 'data' => $paged_records,
481 'total' => $total
482 );
483 }
484
485 /**
486 * List Pinecone records using the list endpoint for true pagination (no search)
487 * This allows browsing through all 13K+ records page by page
488 * ADDED 2.6.1
489 */
490 private function mxchat_list_pinecone_records($pinecone_options, $page = 1, $per_page = 20, $bot_id = 'default', $content_type = '') {
491 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
492 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
493 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
494
495 // Pinecone's list endpoint returns vector IDs with pagination
496 // We then fetch the metadata for those specific IDs
497 // NOTE (plan 793b82): /vectors/list is a GET endpoint with query parameters.
498 // A POST is answered 200-with-an-empty-body, which reads as "no vectors" —
499 // this call used to POST and only ever "worked" because the empty result
500 // tripped the query-based fallback below.
501 $list_url = "https://{$host}/vectors/list";
502
503 // Calculate pagination token from page number
504 // Pinecone uses cursor-based pagination, so we need to handle this differently
505 $limit = $per_page * 2; // Fetch extra to account for filtering
506
507 $list_params = array(
508 'limit' => $limit
509 );
510
511 if (!empty($namespace)) {
512 $list_params['namespace'] = $namespace;
513 }
514
515 // For pages beyond first, we need to use pagination_token
516 // Store/retrieve pagination tokens in transients
517 $pagination_key = 'mxchat_pinecone_page_' . md5($host . $namespace . $content_type);
518
519 if ($page > 1) {
520 $stored_tokens = get_transient($pagination_key);
521 if ($stored_tokens && isset($stored_tokens[$page])) {
522 $list_params['paginationToken'] = $stored_tokens[$page];
523 }
524 }
525
526 $response = wp_remote_get($list_url . '?' . http_build_query($list_params), array(
527 'headers' => array(
528 'Api-Key' => $api_key,
529 'accept' => 'application/json'
530 ),
531 'timeout' => 15
532 ));
533
534 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
535 // Genuine transport/API failure — fall back to the query-based approach,
536 // but say so: a silent fallback is what masked the POST bug for a year.
537 error_log('MxChat Pinecone: /vectors/list GET failed (' . (is_wp_error($response) ? $response->get_error_message() : wp_remote_retrieve_response_code($response)) . ') — falling back to query-based listing.');
538 return $this->mxchat_query_based_list($pinecone_options, $page, $per_page, $bot_id, $content_type);
539 }
540
541 $body = wp_remote_retrieve_body($response);
542 $data = json_decode($body, true);
543
544 // Store pagination token for next page
545 if (!empty($data['pagination']['next'])) {
546 $stored_tokens = get_transient($pagination_key) ?: array();
547 $stored_tokens[$page + 1] = $data['pagination']['next'];
548 set_transient($pagination_key, $stored_tokens, 300); // 5 minute cache
549 }
550
551 $vector_ids = array();
552 if (isset($data['vectors'])) {
553 foreach ($data['vectors'] as $vector) {
554 $vector_ids[] = $vector['id'];
555 }
556 }
557
558 // A 200 with no vector ids is a REAL answer now (the namespace is empty),
559 // not the failure signature it was under POST — return the empty listing
560 // rather than routing into the fallback and masking a broken primary.
561 if (empty($vector_ids)) {
562 return array('data' => array(), 'total' => 0);
563 }
564
565 // Fetch metadata for these vector IDs
566 $fetched = $this->mxchat_fetch_vectors_by_ids_for_list($pinecone_options, $vector_ids, $page, $per_page, $bot_id, $content_type);
567
568 // null = the fetch itself failed in transport (distinct from "ids had no
569 // metadata matches") — log and fall back so the screen still lists.
570 if ($fetched === null) {
571 error_log('MxChat Pinecone: /vectors/fetch GET failed for the listing — falling back to query-based listing.');
572 return $this->mxchat_query_based_list($pinecone_options, $page, $per_page, $bot_id, $content_type);
573 }
574
575 return $fetched;
576 }
577
578 /**
579 * Query-based listing fallback when list endpoint fails
580 * ADDED 2.6.1
581 */
582 private function mxchat_query_based_list($pinecone_options, $page, $per_page, $bot_id, $content_type) {
583 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
584 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
585 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
586
587 $query_url = "https://{$host}/query";
588 $query_vector = $this->mxchat_generate_optimized_query_vector();
589
590 // Fetch records - use higher limit to cover large databases
591 // Pinecone query API supports up to 10,000 topK
592 // We fetch more than needed to get accurate total count and enable pagination
593 $fetch_limit = 5000;
594
595 $query_data = array(
596 'includeMetadata' => true,
597 'includeValues' => false,
598 'topK' => $fetch_limit,
599 'vector' => $query_vector
600 );
601
602 if (!empty($namespace)) {
603 $query_data['namespace'] = $namespace;
604 }
605
606 // Add content type filter if specified
607 if (!empty($content_type)) {
608 $query_data['filter'] = array(
609 'type' => array('$eq' => $content_type)
610 );
611 }
612
613 $response = wp_remote_post($query_url, array(
614 'headers' => array(
615 'Api-Key' => $api_key,
616 'Content-Type' => 'application/json'
617 ),
618 'body' => json_encode($query_data),
619 'timeout' => 30
620 ));
621
622 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
623 return array('data' => array(), 'total' => 0);
624 }
625
626 $body = wp_remote_retrieve_body($response);
627 $data = json_decode($body, true);
628
629 $records = array();
630 if (isset($data['matches'])) {
631 foreach ($data['matches'] as $match) {
632 $metadata = $match['metadata'] ?? array();
633
634 $created_at = $metadata['created_at'] ?? $metadata['last_updated'] ?? $metadata['timestamp'] ?? time();
635 if (!is_numeric($created_at)) {
636 $created_at = strtotime($created_at) ?: time();
637 }
638
639 $records[] = (object) array(
640 'id' => $match['id'] ?? '',
641 'article_content' => $metadata['text'] ?? '',
642 'source_url' => $metadata['source_url'] ?? '',
643 'role_restriction' => $metadata['role_restriction'] ?? 'public',
644 'type' => $metadata['type'] ?? 'content',
645 'bot_id' => $bot_id,
646 'created_at' => $created_at,
647 'data_source' => 'pinecone',
648 'chunk_index' => isset($metadata['chunk_index']) ? intval($metadata['chunk_index']) : null,
649 'total_chunks' => isset($metadata['total_chunks']) ? intval($metadata['total_chunks']) : null,
650 'is_chunked' => isset($metadata['is_chunked']) ? (bool) $metadata['is_chunked'] : false
651 );
652 }
653 }
654
655 // Sort by created_at (newest first)
656 usort($records, function($a, $b) {
657 return $b->created_at - $a->created_at;
658 });
659
660 $total = count($records);
661 $offset = ($page - 1) * $per_page;
662 $paged_records = array_slice($records, $offset, $per_page);
663
664 $this->mxchat_batch_fetch_role_restrictions($paged_records, $bot_id);
665
666 return array(
667 'data' => $paged_records,
668 'total' => $total
669 );
670 }
671
672 /**
673 * Fetch specific vectors by their IDs and format for display
674 * ADDED 2.6.1
675 */
676 private function mxchat_fetch_vectors_by_ids_for_list($pinecone_options, $vector_ids, $page, $per_page, $bot_id, $content_type) {
677 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
678 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
679 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
680
681 // NOTE (plan 793b82): /vectors/fetch is a GET endpoint, and Pinecone expects
682 // the ids repeated (ids=a&ids=b) — http_build_query would emit ids[0]=a.
683 // A POST here is answered 200-with-an-empty-body, which reads as "no vectors".
684 $fetch_query = array();
685 foreach ($vector_ids as $fetch_vid) {
686 $fetch_query[] = 'ids=' . rawurlencode($fetch_vid);
687 }
688 if (!empty($namespace)) {
689 $fetch_query[] = 'namespace=' . rawurlencode($namespace);
690 }
691
692 $response = wp_remote_get("https://{$host}/vectors/fetch?" . implode('&', $fetch_query), array(
693 'headers' => array(
694 'Api-Key' => $api_key,
695 'accept' => 'application/json'
696 ),
697 'timeout' => 15
698 ));
699
700 // Transport/API failure is distinct from "nothing matched": return null so
701 // the caller can tell the difference (plan 793b82's empty-vs-failed rule).
702 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
703 return null;
704 }
705
706 $body = wp_remote_retrieve_body($response);
707 $data = json_decode($body, true);
708
709 $records = array();
710 if (isset($data['vectors'])) {
711 foreach ($data['vectors'] as $vector_id => $vector_data) {
712 $metadata = $vector_data['metadata'] ?? array();
713
714 // Filter by content type if specified
715 if (!empty($content_type)) {
716 $record_type = $metadata['type'] ?? 'content';
717 if ($record_type !== $content_type) {
718 continue;
719 }
720 }
721
722 $created_at = $metadata['created_at'] ?? $metadata['last_updated'] ?? $metadata['timestamp'] ?? time();
723 if (!is_numeric($created_at)) {
724 $created_at = strtotime($created_at) ?: time();
725 }
726
727 $records[] = (object) array(
728 'id' => $vector_id,
729 'article_content' => $metadata['text'] ?? '',
730 'source_url' => $metadata['source_url'] ?? '',
731 'role_restriction' => $metadata['role_restriction'] ?? 'public',
732 'type' => $metadata['type'] ?? 'content',
733 'bot_id' => $bot_id,
734 'created_at' => $created_at,
735 'data_source' => 'pinecone',
736 'chunk_index' => isset($metadata['chunk_index']) ? intval($metadata['chunk_index']) : null,
737 'total_chunks' => isset($metadata['total_chunks']) ? intval($metadata['total_chunks']) : null,
738 'is_chunked' => isset($metadata['is_chunked']) ? (bool) $metadata['is_chunked'] : false
739 );
740 }
741 }
742
743 // Sort by date
744 usort($records, function($a, $b) {
745 return $b->created_at - $a->created_at;
746 });
747
748 $total = count($records);
749 $paged_records = array_slice($records, 0, $per_page);
750
751 $this->mxchat_batch_fetch_role_restrictions($paged_records, $bot_id);
752
753 return array(
754 'data' => $paged_records,
755 'total' => $total
756 );
757 }
758
759 /**
760 * Generate a single optimized query vector for fetching records
761 * Uses a center-weighted approach for best coverage
762 */
763 private function mxchat_generate_optimized_query_vector() {
764 $dimensions = $this->mxchat_get_embedding_dimensions();
765 $vector = array();
766
767 // Create a normalized center-weighted vector
768 $center = $dimensions / 2;
769 for ($i = 0; $i < $dimensions; $i++) {
770 $distance = abs($i - $center) / $center;
771 $vector[] = (1 - $distance) * 0.5;
772 }
773
774 // Normalize to unit length
775 $magnitude = sqrt(array_sum(array_map(function($x) { return $x * $x; }, $vector)));
776 if ($magnitude > 0) {
777 $vector = array_map(function($x) use ($magnitude) { return $x / $magnitude; }, $vector);
778 }
779
780 return $vector;
781 }
782
783 /**
784 * Batch fetch role restrictions for a set of records
785 * Uses a single query instead of N queries
786 * ADDED 2.6.1: Prevents N+1 query problem
787 */
788 private function mxchat_batch_fetch_role_restrictions(&$records, $bot_id = 'default') {
789 if (empty($records)) {
790 return;
791 }
792
793 global $wpdb;
794 $roles_table = $wpdb->prefix . 'mxchat_pinecone_roles';
795
796 // Check if table exists
797 $table_exists = $wpdb->get_var("SHOW TABLES LIKE '{$roles_table}'");
798 if (!$table_exists) {
799 return;
800 }
801
802 // Get all vector IDs that need role lookup
803 $vector_ids = array();
804 foreach ($records as $record) {
805 if (empty($record->role_restriction) || $record->role_restriction === 'public') {
806 $vector_ids[] = $record->id;
807 }
808 }
809
810 if (empty($vector_ids)) {
811 return;
812 }
813
814 // Check if bot_id column exists
815 $columns = $wpdb->get_col("SHOW COLUMNS FROM {$roles_table}");
816 $has_bot_id = in_array('bot_id', $columns);
817
818 // Build single query with IN clause
819 $placeholders = implode(',', array_fill(0, count($vector_ids), '%s'));
820
821 if ($has_bot_id) {
822 $query = $wpdb->prepare(
823 "SELECT vector_id, role_restriction FROM {$roles_table} WHERE vector_id IN ({$placeholders}) AND bot_id = %s",
824 array_merge($vector_ids, array($bot_id))
825 );
826 } else {
827 $query = $wpdb->prepare(
828 "SELECT vector_id, role_restriction FROM {$roles_table} WHERE vector_id IN ({$placeholders})",
829 $vector_ids
830 );
831 }
832
833 $results = $wpdb->get_results($query, OBJECT_K);
834
835 // Apply role restrictions to records
836 foreach ($records as &$record) {
837 if (isset($results[$record->id])) {
838 $record->role_restriction = $results[$record->id]->role_restriction;
839 }
840 }
841 }
842
843 /**
844 * Safe version of get_recent_entries with memory limits
845 * ADDED 2.6.1: Prevents memory exhaustion
846 */
847 private function mxchat_get_recent_entries_safe($pinecone_options, $bot_id = 'default', $limit = 500) {
848 global $wpdb;
849
850 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
851 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
852 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
853
854 if (empty($api_key) || empty($host)) {
855 return array();
856 }
857
858 try {
859 $all_records = array();
860 $seen_ids = array();
861 $query_url = "https://{$host}/query";
862
863 // Use only 2 query vectors instead of 5 for better performance
864 $fixed_vectors = array_slice($this->mxchat_generate_fixed_query_vectors(), 0, 2);
865
866 foreach ($fixed_vectors as $query_vector) {
867 // Limit topK to prevent memory issues
868 $topK = min(500, $limit);
869
870 $query_data = array(
871 'includeMetadata' => true,
872 'includeValues' => false,
873 'topK' => $topK,
874 'vector' => $query_vector
875 );
876
877 if (!empty($namespace)) {
878 $query_data['namespace'] = $namespace;
879 }
880
881 $response = wp_remote_post($query_url, array(
882 'headers' => array(
883 'Api-Key' => $api_key,
884 'Content-Type' => 'application/json'
885 ),
886 'body' => json_encode($query_data),
887 'timeout' => 15
888 ));
889
890 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
891 $body = wp_remote_retrieve_body($response);
892 $data = json_decode($body, true);
893
894 if (isset($data['matches'])) {
895 foreach ($data['matches'] as $match) {
896 $match_id = $match['id'] ?? '';
897 if (!empty($match_id) && !isset($seen_ids[$match_id])) {
898 $metadata = $match['metadata'] ?? array();
899
900 $created_at = $metadata['created_at'] ?? $metadata['last_updated'] ?? $metadata['timestamp'] ?? time();
901 if (!is_numeric($created_at)) {
902 $created_at = strtotime($created_at) ?: time();
903 }
904
905 $all_records[] = (object) array(
906 'id' => $match_id,
907 'article_content' => $metadata['text'] ?? '',
908 'source_url' => $metadata['source_url'] ?? '',
909 'role_restriction' => $metadata['role_restriction'] ?? 'public',
910 'type' => $metadata['type'] ?? 'content',
911 'bot_id' => $bot_id,
912 'created_at' => $created_at,
913 'data_source' => 'pinecone',
914 'chunk_index' => isset($metadata['chunk_index']) ? intval($metadata['chunk_index']) : null,
915 'total_chunks' => isset($metadata['total_chunks']) ? intval($metadata['total_chunks']) : null,
916 'is_chunked' => isset($metadata['is_chunked']) ? (bool) $metadata['is_chunked'] : false
917 );
918
919 $seen_ids[$match_id] = true;
920
921 // Stop if we've reached our limit
922 if (count($all_records) >= $limit) {
923 break 2;
924 }
925 }
926 }
927 }
928 }
929
930 // Minimal delay between requests
931 usleep(50000); // 0.05 second delay
932 }
933
934 // Sort by created_at (newest first) and apply limit
935 usort($all_records, function($a, $b) {
936 return $b->created_at - $a->created_at;
937 });
938
939 $limited_records = array_slice($all_records, 0, $limit);
940
941 // Batch fetch role restrictions
942 $this->mxchat_batch_fetch_role_restrictions($limited_records, $bot_id);
943
944 return $limited_records;
945
946 } catch (Exception $e) {
947 //error_log('MxChat safe fetch exception: ' . $e->getMessage());
948 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Pinecone safe fetch error: ' . $e->getMessage());
949 return array();
950 }
951 }
952 /**
953 * Get embedding dimensions based on the selected model
954 * ADD THIS NEW FUNCTION
955 */
956 private function mxchat_get_embedding_dimensions() {
957 $options = get_option('mxchat_options', array());
958 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
959
960 // Define dimensions for different models
961 $model_dimensions = array(
962 'text-embedding-ada-002' => 1536,
963 'text-embedding-3-small' => 1536,
964 'text-embedding-3-large' => 3072,
965 'voyage-2' => 1024,
966 'voyage-large-2' => 1536,
967 'voyage-3-large' => 2048,
968 'gemini-embedding-001' => 1536,
969 );
970
971 // Check if it's a voyage model with custom dimensions
972 if (strpos($selected_model, 'voyage-3-large') === 0) {
973 $custom_dimensions = $options['voyage_output_dimension'] ?? 2048;
974 return intval($custom_dimensions);
975 }
976
977 // Check if it's a gemini model with custom dimensions
978 if (strpos($selected_model, 'gemini-embedding') === 0) {
979 $custom_dimensions = $options['gemini_output_dimension'] ?? 1536;
980 return intval($custom_dimensions);
981 }
982
983 // Return known dimensions or default to 1536
984 return $model_dimensions[$selected_model] ?? 1536;
985 }
986
987 /**
988 * Generate random unit vector with correct dimensions
989 * ADD THIS NEW FUNCTION
990 */
991 private function mxchat_generate_random_vector() {
992 $dimensions = $this->mxchat_get_embedding_dimensions();
993
994 $random_vector = array();
995 for ($i = 0; $i < $dimensions; $i++) {
996 $random_vector[] = (rand(-1000, 1000) / 1000.0);
997 }
998
999 // Normalize the vector to unit length
1000 $magnitude = sqrt(array_sum(array_map(function($x) { return $x * $x; }, $random_vector)));
1001 if ($magnitude > 0) {
1002 $random_vector = array_map(function($x) use ($magnitude) { return $x / $magnitude; }, $random_vector);
1003 }
1004
1005 return $random_vector;
1006 }
1007
1008
1009 /**
1010 * Get recent entries from Pinecone
1011 * UPDATED 2.6.1: Now uses safe version with memory limits to prevent crashes
1012 * @deprecated Use mxchat_get_recent_entries_safe() instead for new code
1013 */
1014 private function mxchat_get_recent_1k_entries($pinecone_options, $bot_id = 'default') {
1015 // Delegate to the safe version with a reasonable limit
1016 // This prevents crashes with large datasets (13K+ products)
1017 return $this->mxchat_get_recent_entries_safe($pinecone_options, $bot_id, 500);
1018 }
1019 /**
1020 * Generate fixed query vectors for consistent results
1021 */
1022 private function mxchat_generate_fixed_query_vectors() {
1023 $dimensions = $this->mxchat_get_embedding_dimensions();
1024 $vectors = array();
1025
1026 // Create 5 fixed vectors with different patterns for better coverage
1027 $patterns = array(
1028 'zeros_with_ones' => 0.1, // Mostly zeros with some 1s
1029 'ascending' => 0.2, // Ascending pattern
1030 'descending' => 0.3, // Descending pattern
1031 'alternating' => 0.4, // Alternating positive/negative
1032 'center_weighted' => 0.5 // Higher values in center
1033 );
1034
1035 foreach ($patterns as $pattern_name => $seed) {
1036 $vector = array();
1037
1038 for ($i = 0; $i < $dimensions; $i++) {
1039 switch ($pattern_name) {
1040 case 'zeros_with_ones':
1041 $vector[] = ($i % 10 === 0) ? 1.0 : 0.0;
1042 break;
1043 case 'ascending':
1044 $vector[] = ($i / $dimensions) * 2 - 1; // Range -1 to 1
1045 break;
1046 case 'descending':
1047 $vector[] = (($dimensions - $i) / $dimensions) * 2 - 1;
1048 break;
1049 case 'alternating':
1050 $vector[] = ($i % 2 === 0) ? $seed : -$seed;
1051 break;
1052 case 'center_weighted':
1053 $center = $dimensions / 2;
1054 $distance = abs($i - $center) / $center;
1055 $vector[] = (1 - $distance) * $seed;
1056 break;
1057 }
1058 }
1059
1060 // Normalize the vector to unit length
1061 $magnitude = sqrt(array_sum(array_map(function($x) { return $x * $x; }, $vector)));
1062 if ($magnitude > 0) {
1063 $vector = array_map(function($x) use ($magnitude) { return $x / $magnitude; }, $vector);
1064 }
1065
1066 $vectors[] = $vector;
1067 }
1068
1069 return $vectors;
1070 }
1071
1072
1073 /**
1074 * Scan Pinecone for processed content
1075 * UPDATED 2.6.2: Uses direct ID lookup via fetch API instead of random vector scanning
1076 * This removes the 10K record limit and scales to any database size
1077 *
1078 * @param array $pinecone_options Pinecone configuration options
1079 * @param array $post_ids Optional array of specific post IDs to check (if empty, checks all published posts)
1080 * @return array Processed data keyed by post ID
1081 */
1082 public function mxchat_scan_pinecone_for_processed_content($pinecone_options, $post_ids = array()) {
1083 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1084 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1085
1086 if (empty($api_key) || empty($host)) {
1087 return array();
1088 }
1089
1090 try {
1091 // If no specific post IDs provided, get all published posts
1092 if (empty($post_ids)) {
1093 $posts = get_posts(array(
1094 'post_type' => 'any',
1095 'post_status' => 'publish',
1096 'posts_per_page' => -1,
1097 'fields' => 'ids',
1098 'no_found_rows' => true,
1099 'update_post_meta_cache' => false,
1100 'update_post_term_cache' => false,
1101 ));
1102 $post_ids = $posts;
1103 }
1104
1105 if (empty($post_ids)) {
1106 return array();
1107 }
1108
1109 // Build a map of vector_id => post data for lookup
1110 $vector_id_map = array();
1111 foreach ($post_ids as $post_id) {
1112 $permalink = get_permalink($post_id);
1113 if ($permalink) {
1114 $vector_id = md5($permalink);
1115 $vector_id_map[$vector_id] = array(
1116 'post_id' => $post_id,
1117 'url' => $permalink
1118 );
1119 }
1120 }
1121
1122 if (empty($vector_id_map)) {
1123 return array();
1124 }
1125
1126 // Batch check Pinecone using fetch API.
1127 // NOTE (plan 793b82): /vectors/fetch is a GET endpoint with the ids
1128 // repeated in the query string (ids=a&ids=b); the old POST here was
1129 // answered 200-with-an-empty-body, so this scan saw NOTHING as indexed
1130 // and callers re-embedded content Pinecone already had. Chunk size 100:
1131 // measured on a live serverless index, ~9KB of URL is accepted and
1132 // ~18KB draws HTTP 414, so 100 32-char ids (~4KB) leaves real margin.
1133 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1134 $all_vector_ids = array_keys($vector_id_map);
1135 $chunks = array_chunk($all_vector_ids, 100);
1136 $processed_data = array();
1137
1138 foreach ($chunks as $chunk) {
1139 $fetch_query = array();
1140 foreach ($chunk as $fetch_vid) {
1141 $fetch_query[] = 'ids=' . rawurlencode($fetch_vid);
1142 }
1143 if (!empty($namespace)) {
1144 $fetch_query[] = 'namespace=' . rawurlencode($namespace);
1145 }
1146
1147 $response = wp_remote_get("https://{$host}/vectors/fetch?" . implode('&', $fetch_query), array(
1148 'headers' => array(
1149 'Api-Key' => $api_key,
1150 'accept' => 'application/json'
1151 ),
1152 'timeout' => 30
1153 ));
1154
1155 if (is_wp_error($response)) {
1156 error_log('MxChat Pinecone: processed-content scan /vectors/fetch GET failed: ' . $response->get_error_message());
1157 continue;
1158 }
1159
1160 $response_code = wp_remote_retrieve_response_code($response);
1161 if ($response_code !== 200) {
1162 error_log('MxChat Pinecone: processed-content scan /vectors/fetch GET returned HTTP ' . $response_code);
1163 continue;
1164 }
1165
1166 $body = wp_remote_retrieve_body($response);
1167 $data = json_decode($body, true);
1168
1169 // Process returned vectors
1170 if (isset($data['vectors']) && is_array($data['vectors'])) {
1171 foreach ($data['vectors'] as $vector_id => $vector_data) {
1172 if (isset($vector_id_map[$vector_id])) {
1173 $post_info = $vector_id_map[$vector_id];
1174 $post_id = $post_info['post_id'];
1175 $metadata = $vector_data['metadata'] ?? array();
1176
1177 $created_at = $metadata['created_at'] ?? '';
1178 $processed_date = 'Recently';
1179 $timestamp = current_time('timestamp');
1180
1181 if (!empty($created_at)) {
1182 $ts = is_numeric($created_at) ? $created_at : strtotime($created_at);
1183 if ($ts) {
1184 $timestamp = $ts;
1185 $processed_date = human_time_diff($ts, current_time('timestamp')) . ' ago';
1186 }
1187 }
1188
1189 $processed_data[$post_id] = array(
1190 'db_id' => $vector_id,
1191 'processed_date' => $processed_date,
1192 'url' => $post_info['url'],
1193 'source' => 'pinecone',
1194 'timestamp' => $timestamp
1195 );
1196 }
1197 }
1198 }
1199 }
1200
1201 return $processed_data;
1202
1203 } catch (Exception $e) {
1204 return array();
1205 }
1206 }
1207
1208 /**
1209 * Get total count from Pinecone stats API
1210 * UPDATED: Removed cache fallback reference
1211 */
1212 private function mxchat_get_pinecone_total_count($pinecone_options, $bot_id = 'default') {
1213 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1214 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1215 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1216
1217 if (empty($api_key) || empty($host)) {
1218 return 0;
1219 }
1220
1221 try {
1222 $stats_url = "https://{$host}/describe_index_stats";
1223
1224 // describe_index_stats doesn't need a body, just the POST request
1225 $response = wp_remote_post($stats_url, array(
1226 'headers' => array(
1227 'Api-Key' => $api_key,
1228 'Content-Type' => 'application/json'
1229 ),
1230 'body' => '{}',
1231 'timeout' => 15
1232 ));
1233
1234 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
1235 $body = wp_remote_retrieve_body($response);
1236 $stats_data = json_decode($body, true);
1237
1238 // If namespace is specified, get count from that specific namespace
1239 // Pinecone stats response format: { namespaces: { "ns": { vectorCount: N } }, totalVectorCount: N }
1240 if (!empty($namespace) && isset($stats_data['namespaces'][$namespace]['vectorCount'])) {
1241 $namespace_count = intval($stats_data['namespaces'][$namespace]['vectorCount']);
1242 //error_log('DEBUG: Got namespace-specific count: ' . $namespace_count . ' for namespace: ' . $namespace);
1243 return $namespace_count;
1244 }
1245
1246 // If no namespace specified or namespace not found in response, use total
1247 $total_count = $stats_data['totalVectorCount'] ?? 0;
1248 if ($total_count > 0) {
1249 //error_log('DEBUG: Got total count from stats API: ' . $total_count);
1250 return intval($total_count);
1251 }
1252 }
1253
1254 // If stats API fails, return 0 instead of using cache
1255 return 0;
1256
1257 } catch (Exception $e) {
1258 //error_log('DEBUG: Exception getting total count: ' . $e->getMessage());
1259 return 0;
1260 }
1261 }
1262
1263 /**
1264 * Get bot-specific Pinecone configuration for database operations
1265 */
1266 public function mxchat_get_bot_pinecone_options($bot_id = 'default') {
1267 //error_log('DEBUG: Getting Pinecone options for bot: ' . $bot_id);
1268
1269 // If default bot or multi-bot add-on not active, use default Pinecone config
1270 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1271 $addon_options = get_option('mxchat_pinecone_addon_options', array());
1272 //error_log('DEBUG: Using default Pinecone options');
1273 return $addon_options;
1274 }
1275
1276 // Get bot-specific configuration using the filter
1277 $bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1278
1279 //error_log('DEBUG: Bot config from filter: ' . print_r($bot_config, true));
1280
1281 // Check if we got valid bot-specific config
1282 if (!empty($bot_config) && isset($bot_config['use_pinecone']) && $bot_config['use_pinecone']) {
1283 // Convert bot config to the format expected by fetch functions
1284 $pinecone_options = array(
1285 'mxchat_use_pinecone' => '1',
1286 'mxchat_pinecone_api_key' => $bot_config['api_key'] ?? '',
1287 'mxchat_pinecone_host' => $bot_config['host'] ?? '',
1288 'mxchat_pinecone_namespace' => $bot_config['namespace'] ?? '',
1289 'mxchat_pinecone_environment' => '',
1290 'mxchat_pinecone_index' => ''
1291 );
1292
1293 //error_log('DEBUG: Returning bot-specific Pinecone options for bot: ' . $bot_id);
1294 return $pinecone_options;
1295 }
1296
1297 // Fallback to default options if bot-specific config is invalid
1298 //error_log('DEBUG: Bot-specific config invalid, falling back to default');
1299 return get_option('mxchat_pinecone_addon_options', array());
1300 }
1301
1302 /**
1303 * Get bot-specific Pinecone configuration
1304 * Used in the knowledge retrieval functions
1305 */
1306 private function get_bot_pinecone_config($bot_id = 'default') {
1307 //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
1308
1309 // If default bot or multi-bot add-on not active, use default Pinecone config
1310 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1311 //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
1312 $addon_options = get_option('mxchat_pinecone_addon_options', array());
1313 $config = array(
1314 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
1315 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
1316 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
1317 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
1318 );
1319 //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
1320 return $config;
1321 }
1322
1323 //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
1324
1325 // Hook for multi-bot add-on to provide bot-specific Pinecone config
1326 $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1327
1328 if (!empty($bot_pinecone_config)) {
1329 //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
1330 //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
1331 //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
1332 //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
1333 } else {
1334 //error_log("MXCHAT DEBUG: Filter returned empty config!");
1335 }
1336
1337 return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
1338 }
1339
1340
1341 /**
1342 * Fetches vectors from Pinecone using provided IDs (for content selection feature)
1343 */
1344 public function fetch_pinecone_vectors_by_ids($pinecone_options, $vector_ids) {
1345 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1346 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1347 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1348
1349 if (empty($api_key) || empty($host) || empty($vector_ids)) {
1350 return array();
1351 }
1352
1353 try {
1354 // NOTE (plan 793b82): /vectors/fetch is a GET endpoint with the ids
1355 // repeated in the query string; the old POST here was answered
1356 // 200-with-an-empty-body. Chunked at 100 ids to stay well under the
1357 // measured HTTP 414 URL-length boundary.
1358 $vectors = array();
1359 foreach (array_chunk(array_values($vector_ids), 100) as $chunk) {
1360 $fetch_query = array();
1361 foreach ($chunk as $fetch_vid) {
1362 $fetch_query[] = 'ids=' . rawurlencode($fetch_vid);
1363 }
1364 if (!empty($namespace)) {
1365 $fetch_query[] = 'namespace=' . rawurlencode($namespace);
1366 }
1367
1368 $response = wp_remote_get("https://{$host}/vectors/fetch?" . implode('&', $fetch_query), array(
1369 'headers' => array(
1370 'Api-Key' => $api_key,
1371 'accept' => 'application/json'
1372 ),
1373 'timeout' => 30
1374 ));
1375
1376 if (is_wp_error($response)) {
1377 error_log('MxChat Pinecone: fetch_pinecone_vectors_by_ids GET failed: ' . $response->get_error_message());
1378 continue;
1379 }
1380
1381 if (wp_remote_retrieve_response_code($response) !== 200) {
1382 error_log('MxChat Pinecone: fetch_pinecone_vectors_by_ids GET returned HTTP ' . wp_remote_retrieve_response_code($response));
1383 continue;
1384 }
1385
1386 $data = json_decode(wp_remote_retrieve_body($response), true);
1387 if (isset($data['vectors']) && is_array($data['vectors'])) {
1388 $vectors += $data['vectors'];
1389 }
1390 }
1391
1392 if (empty($vectors)) {
1393 return array();
1394 }
1395
1396 $processed_data = array();
1397
1398 foreach ($vectors as $vector_id => $vector_data) {
1399 $metadata = $vector_data['metadata'] ?? array();
1400 $source_url = $metadata['source_url'] ?? '';
1401
1402 if (!empty($source_url)) {
1403 $post_id = url_to_postid($source_url);
1404 if ($post_id) {
1405 $created_at = $metadata['created_at'] ?? '';
1406 $processed_date = 'Recently'; // Default
1407
1408 if (!empty($created_at)) {
1409 $timestamp = is_numeric($created_at) ? $created_at : strtotime($created_at);
1410 if ($timestamp) {
1411 $processed_date = human_time_diff($timestamp, current_time('timestamp')) . ' ago';
1412 }
1413 }
1414
1415 $processed_data[$post_id] = array(
1416 'db_id' => $vector_id,
1417 'processed_date' => $processed_date,
1418 'url' => $source_url,
1419 'source' => 'pinecone',
1420 'timestamp' => $timestamp ?? current_time('timestamp')
1421 );
1422 }
1423 }
1424 }
1425
1426 return $processed_data;
1427
1428 } catch (Exception $e) {
1429 return array();
1430 }
1431 }
1432 // ========================================
1433 // PINECONE DELETE OPERATIONS
1434 // ========================================
1435
1436 /**
1437 * Delete all vectors from Pinecone
1438 * Loops until all vectors are deleted (handles large databases)
1439 */
1440 public function mxchat_delete_all_from_pinecone($pinecone_options, $content_type_filter = '') {
1441 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1442 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1443 $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1444
1445 if (empty($api_key) || empty($host)) {
1446 return array(
1447 'success' => false,
1448 'message' => 'Missing Pinecone API credentials'
1449 );
1450 }
1451
1452 try {
1453 $total_deleted = 0;
1454 $failed_batches = 0;
1455 $max_iterations = 100; // Safety limit to prevent infinite loops
1456 $iteration = 0;
1457
1458 // Loop until no more vectors are found
1459 do {
1460 $iteration++;
1461
1462 // Get a batch of vector IDs from Pinecone
1463 $records = $this->mxchat_get_recent_1k_entries($pinecone_options);
1464 $vector_ids = array();
1465
1466 foreach ($records as $record) {
1467 if (!empty($record->id)) {
1468 // If content type filter is active, only include matching records
1469 if (!empty($content_type_filter)) {
1470 $record_type = isset($record->type) ? $record->type : '';
1471 if ($record_type !== $content_type_filter) {
1472 continue;
1473 }
1474 }
1475 $vector_ids[] = $record->id;
1476 }
1477 }
1478
1479 // If no matching vectors found, we're done
1480 // (either no records at all, or all remaining records are non-matching types)
1481 if (empty($vector_ids)) {
1482 break;
1483 }
1484
1485 // Delete vectors in batches (Pinecone has limits on batch operations)
1486 $batch_size = 100;
1487 $batches = array_chunk($vector_ids, $batch_size);
1488
1489 foreach ($batches as $batch) {
1490 $result = $this->mxchat_delete_pinecone_batch($batch, $api_key, $host, $namespace);
1491 if ($result['success']) {
1492 $total_deleted += count($batch);
1493 } else {
1494 $failed_batches++;
1495 }
1496 }
1497
1498 // Small delay to avoid rate limiting
1499 usleep(100000); // 100ms
1500
1501 } while ($iteration < $max_iterations);
1502
1503 if ($total_deleted === 0) {
1504 return array(
1505 'success' => true,
1506 'message' => 'No vectors found to delete'
1507 );
1508 }
1509
1510 if ($failed_batches > 0) {
1511 return array(
1512 'success' => false,
1513 'message' => sprintf('Deleted %d vectors, but %d batches failed', $total_deleted, $failed_batches)
1514 );
1515 }
1516
1517 return array(
1518 'success' => true,
1519 'message' => "Successfully deleted {$total_deleted} vectors from Pinecone"
1520 );
1521
1522 } catch (Exception $e) {
1523 return array(
1524 'success' => false,
1525 'message' => $e->getMessage()
1526 );
1527 }
1528 }
1529
1530 /**
1531 * Deletes batch of vectors from Pinecone database
1532 */
1533 public function mxchat_delete_pinecone_batch($vector_ids, $api_key, $host, $namespace = '') {
1534 // Build the API endpoint
1535 $api_endpoint = "https://{$host}/vectors/delete";
1536
1537 // Prepare the request body with the IDs
1538 $request_body = array(
1539 'ids' => $vector_ids
1540 );
1541
1542 // Add namespace if provided — omitting it targets the default namespace
1543 if (!empty($namespace)) {
1544 $request_body['namespace'] = $namespace;
1545 }
1546
1547 // Make the deletion request
1548 $response = wp_remote_post($api_endpoint, array(
1549 'headers' => array(
1550 'Api-Key' => $api_key,
1551 'accept' => 'application/json',
1552 'content-type' => 'application/json'
1553 ),
1554 'body' => wp_json_encode($request_body),
1555 'timeout' => 60, // Increased timeout for batch operations
1556 'method' => 'POST'
1557 ));
1558
1559 // Handle WordPress HTTP API errors
1560 if (is_wp_error($response)) {
1561 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Pinecone batch deletion failed: ' . $response->get_error_message());
1562 return array(
1563 'success' => false,
1564 'message' => $response->get_error_message()
1565 );
1566 }
1567
1568 // Check response status
1569 $response_code = wp_remote_retrieve_response_code($response);
1570 $response_body = wp_remote_retrieve_body($response);
1571
1572 // Pinecone returns 200 for successful deletion
1573 if ($response_code !== 200) {
1574 MxChat_Admin::mxchat_log_debug('pinecone_error', 'Pinecone batch deletion failed (HTTP ' . $response_code . ')', array('response' => substr($response_body, 0, 200)));
1575 return array(
1576 'success' => false,
1577 'message' => sprintf(
1578 'Pinecone API error (HTTP %d): %s',
1579 $response_code,
1580 $response_body
1581 )
1582 );
1583 }
1584
1585 return array(
1586 'success' => true,
1587 'message' => 'Batch deleted successfully from Pinecone'
1588 );
1589 }
1590
1591
1592 /**
1593 * Deletes vector from Pinecone using API request
1594 */
1595 public function mxchat_delete_from_pinecone_by_vector_id($vector_id, $api_key, $host, $namespace = '') {
1596 //error_log('=== PINECONE DELETE OPERATION ===');
1597 //error_log('Vector ID: ' . $vector_id);
1598 //error_log('Host: ' . $host);
1599 //error_log('API Key: ' . (empty($api_key) ? 'EMPTY' : 'SET'));
1600
1601 // First, let's verify the vector exists before trying to delete
1602 $fetch_url = "https://{$host}/vectors/fetch";
1603
1604 $fetch_params = array(
1605 'ids' => array($vector_id)
1606 );
1607
1608 // Add namespace if provided (though you said you're not using namespaces)
1609 if (!empty($namespace)) {
1610 $fetch_params['namespace'] = $namespace;
1611 }
1612
1613 // Construct URL with query parameters for GET request
1614 $fetch_url_with_params = $fetch_url . '?' . http_build_query($fetch_params);
1615
1616 $fetch_response = wp_remote_get($fetch_url_with_params, array(
1617 'headers' => array(
1618 'Api-Key' => $api_key,
1619 'accept' => 'application/json'
1620 ),
1621 'timeout' => 15
1622 ));
1623
1624 if (!is_wp_error($fetch_response) && wp_remote_retrieve_response_code($fetch_response) === 200) {
1625 $fetch_body = wp_remote_retrieve_body($fetch_response);
1626 $fetch_data = json_decode($fetch_body, true);
1627
1628 //error_log('DEBUG: Fetch response: ' . print_r($fetch_data, true));
1629
1630 if (isset($fetch_data['vectors']) && isset($fetch_data['vectors'][$vector_id])) {
1631 //error_log('DEBUG: Vector EXISTS in this index before deletion');
1632 } else {
1633 //error_log('WARNING: Vector NOT FOUND in this index! It may be in a different bot\'s index');
1634 // You might want to return an error here
1635 }
1636 } else {
1637 //error_log('DEBUG: Could not fetch vector to verify existence');
1638 }
1639
1640 // Now proceed with deletion
1641 $api_endpoint = "https://{$host}/vectors/delete";
1642
1643 // Prepare the request body with the ID
1644 $request_body = array(
1645 'ids' => array($vector_id)
1646 );
1647
1648 // Add namespace if provided
1649 if (!empty($namespace)) {
1650 $request_body['namespace'] = $namespace;
1651 }
1652
1653 //error_log('DEBUG: Delete request body: ' . json_encode($request_body));
1654 //error_log('DEBUG: Delete endpoint: ' . $api_endpoint);
1655
1656 // Make the deletion request
1657 $response = wp_remote_post($api_endpoint, array(
1658 'headers' => array(
1659 'Api-Key' => $api_key,
1660 'accept' => 'application/json',
1661 'content-type' => 'application/json'
1662 ),
1663 'body' => wp_json_encode($request_body),
1664 'timeout' => 30
1665 ));
1666
1667 // Handle WordPress HTTP API errors
1668 if (is_wp_error($response)) {
1669 //error_log('DEBUG: WP Error: ' . $response->get_error_message());
1670 return array(
1671 'success' => false,
1672 'message' => $response->get_error_message()
1673 );
1674 }
1675
1676 // Check response status
1677 $response_code = wp_remote_retrieve_response_code($response);
1678 $response_body = wp_remote_retrieve_body($response);
1679
1680 //error_log('DEBUG: Delete response code: ' . $response_code);
1681 //error_log('DEBUG: Delete response body: ' . $response_body);
1682
1683 // Pinecone returns 200 for successful deletion (even if vector didn't exist)
1684 if ($response_code !== 200) {
1685 //error_log('DEBUG: Non-200 response from Pinecone');
1686 return array(
1687 'success' => false,
1688 'message' => sprintf(
1689 'Pinecone API error (HTTP %d): %s',
1690 $response_code,
1691 $response_body
1692 )
1693 );
1694 }
1695
1696 // After deletion, verify it's actually gone
1697 sleep(1); // Give Pinecone a moment to process
1698
1699 $verify_response = wp_remote_get($fetch_url_with_params, array(
1700 'headers' => array(
1701 'Api-Key' => $api_key,
1702 'accept' => 'application/json'
1703 ),
1704 'timeout' => 15
1705 ));
1706
1707 if (!is_wp_error($verify_response) && wp_remote_retrieve_response_code($verify_response) === 200) {
1708 $verify_body = wp_remote_retrieve_body($verify_response);
1709 $verify_data = json_decode($verify_body, true);
1710
1711 if (isset($verify_data['vectors']) && isset($verify_data['vectors'][$vector_id])) {
1712 //error_log('ERROR: Vector STILL EXISTS after deletion attempt!');
1713 return array(
1714 'success' => false,
1715 'message' => 'Vector still exists after deletion attempt'
1716 );
1717 } else {
1718 //error_log('SUCCESS: Vector confirmed deleted (or never existed)');
1719 }
1720 }
1721
1722 //error_log('=== END PINECONE DELETE OPERATION ===');
1723
1724 return array(
1725 'success' => true,
1726 'message' => 'Vector deleted successfully from Pinecone'
1727 );
1728 }
1729
1730 /**
1731 * Deletes data from Pinecone index using API key
1732 */
1733 private function mxchat_delete_from_pinecone($urls, $api_key, $environment, $index_name) {
1734 // Get the Pinecone host from options (matching your store_in_pinecone_main pattern)
1735 $options = get_option('mxchat_pinecone_addon_options');
1736 $host = $options['mxchat_pinecone_host'] ?? '';
1737
1738 if (empty($host)) {
1739 return array(
1740 'success' => false,
1741 'message' => 'Pinecone host is not configured. Please set the host in your settings.'
1742 );
1743 }
1744
1745 // Build API endpoint using the configured host
1746 $api_endpoint = "https://{$host}/vectors/delete";
1747
1748 // Create vector IDs from URLs (matching your store method's ID generation)
1749 $vector_ids = array_map('md5', $urls);
1750
1751 // Prepare the delete request body
1752 $request_body = array(
1753 'ids' => $vector_ids,
1754 'filter' => array(
1755 'source_url' => array(
1756 '$in' => $urls
1757 )
1758 )
1759 );
1760
1761 // Make the deletion request
1762 $response = wp_remote_post($api_endpoint, array(
1763 'headers' => array(
1764 'Api-Key' => $api_key,
1765 'accept' => 'application/json',
1766 'content-type' => 'application/json'
1767 ),
1768 'body' => wp_json_encode($request_body),
1769 'timeout' => 30,
1770 'data_format' => 'body'
1771 ));
1772
1773 // Handle WordPress HTTP API errors
1774 if (is_wp_error($response)) {
1775 return array(
1776 'success' => false,
1777 'message' => $response->get_error_message()
1778 );
1779 }
1780
1781 // Check response status
1782 $response_code = wp_remote_retrieve_response_code($response);
1783 if ($response_code !== 200) {
1784 $body = wp_remote_retrieve_body($response);
1785 return array(
1786 'success' => false,
1787 'message' => sprintf(
1788 'Pinecone API error (HTTP %d): %s',
1789 $response_code,
1790 $body
1791 )
1792 );
1793 }
1794
1795 // Parse response body
1796 $body = wp_remote_retrieve_body($response);
1797 $response_data = json_decode($body, true);
1798
1799 // Final validation of the response
1800 if (json_last_error() !== JSON_ERROR_NONE) {
1801 return array(
1802 'success' => false,
1803 'message' => 'Failed to parse Pinecone response: ' . json_last_error_msg()
1804 );
1805 }
1806
1807 return array(
1808 'success' => true,
1809 'message' => sprintf('Successfully deleted %d vectors from Pinecone', count($vector_ids))
1810 );
1811 }
1812
1813
1814
1815 /**
1816 * Retrieves processed content from Pinecone API
1817 *
1818 * @param array $pinecone_options Pinecone configuration options
1819 * @param array $post_ids Optional array of specific post IDs to check (if empty, checks all)
1820 * @return array Processed data keyed by post ID
1821 */
1822 public function mxchat_get_pinecone_processed_content($pinecone_options, $post_ids = array()) {
1823 $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1824 $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1825
1826 if (empty($api_key) || empty($host)) {
1827 return array();
1828 }
1829
1830 $pinecone_data = array();
1831
1832 try {
1833 // Always get fresh data from Pinecone
1834 $pinecone_data = $this->mxchat_scan_pinecone_for_processed_content($pinecone_options, $post_ids);
1835
1836 // Method 2: Final fallback - try stats endpoint (if available)
1837 if (empty($pinecone_data)) {
1838 $stats_url = "https://{$host}/describe_index_stats";
1839
1840 $response = wp_remote_post($stats_url, array(
1841 'headers' => array(
1842 'Api-Key' => $api_key,
1843 'Content-Type' => 'application/json'
1844 ),
1845 'body' => json_encode(array()),
1846 'timeout' => 30
1847 ));
1848
1849 if (!is_wp_error($response) && wp_remote_retrieve_response_code($response) === 200) {
1850 $body = wp_remote_retrieve_body($response);
1851 $stats_data = json_decode($body, true);
1852 }
1853 }
1854
1855 } catch (Exception $e) {
1856 // Log error but don't return cached data
1857 }
1858
1859 return $pinecone_data;
1860 }
1861 // ========================================
1862 // HELPER METHODS
1863 // ========================================
1864
1865 /**
1866 * Validates Pinecone API credentials
1867 */
1868 private function mxchat_validate_pinecone_credentials($api_key, $host) {
1869 if (empty($api_key) || empty($host)) {
1870 return false;
1871 }
1872 return true;
1873 }
1874
1875 /**
1876 * Get Pinecone API credentials from options
1877 */
1878 private function mxchat_get_pinecone_credentials() {
1879 $options = get_option('mxchat_options', array());
1880 return array(
1881 'api_key' => isset($options['pinecone_api_key']) ? $options['pinecone_api_key'] : '',
1882 'host' => isset($options['pinecone_host']) ? $options['pinecone_host'] : ''
1883 );
1884 }
1885
1886 /**
1887 * Log Pinecone operation errors
1888 */
1889 private function log_pinecone_error($operation, $error_message) {
1890 //error_log("MxChat Pinecone {$operation} Error: " . $error_message);
1891 }
1892
1893 // ========================================
1894 // STATIC ACCESS METHODS (for backward compatibility)
1895 // ========================================
1896
1897 /**
1898 * Get singleton instance
1899 */
1900 public static function get_instance() {
1901 static $instance = null;
1902 if ($instance === null) {
1903 $instance = new self();
1904 }
1905 return $instance;
1906 }
1907 }
1908
1909 // Initialize the Pinecone manager
1910 $mxchat_pinecone_manager = MxChat_Pinecone_Manager::get_instance();
1911