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