PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.24
MxChat – AI Chatbot & Content Generation for WordPress v3.2.24
3.2.25 3.2.24 3.2.23 3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 All 156 releases
mxchat-basic / admin / class-pinecone-manager.php

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

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