PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.23
MxChat – AI Chatbot & Content Generation for WordPress v3.2.23
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.23, at admin/class-pinecone-manager.php

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