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

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

1,340 lines 64.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Pinecone document index adapter (plan 362c31).
4 *
5 * A Pinecone "document index" is a schema-based index (API version 2026-07)
6 * whose string fields can carry a BM25 full-text index. MxChat offers it as a
7 * second, OPT-IN index type beside the classic vector index: same API key,
8 * host and namespace fields, an "Index type" choice on the Pinecone card, and
9 * a copy-migration that moves an existing classic index across with zero
10 * embedding calls (the classic index already holds the chunk text in metadata
11 * and the vector in values).
12 *
13 * Everything the plugin does against a document index goes through this class:
14 * the data plane (documents/upsert, /search, /fetch, /list, /delete), the
15 * control plane (create / describe / delete index) and the migration. The
16 * classic vector code paths are NOT modified — each of them carries a one-line
17 * guard that hands a document-index configuration to the matching method here,
18 * so a site on the classic index runs exactly the code it ran before.
19 *
20 * Field layout of a document (fixed — the schema is created by this class):
21 * _id same ids as the classic index (md5(source_url), <md5>_chunk_N, manual_*)
22 * text the chunk text, full-text indexed (string field, language from settings)
23 * embedding the dense vector, dimension = the active embedding model's (client-supplied)
24 * everything else (source_url, type, bot_id, chunk_index, total_chunks, is_chunked,
25 * parent_url_hash, role_restriction, created_at, last_updated) = auto-indexed metadata.
26 *
27 * Response shapes measured 2026-09-19 against a live document index:
28 * search → {matches:[{_id,_score,<fields…>}], namespace, usage} (fields are FLAT on the hit)
29 * fetch → {documents:{<id>:{_id,<fields…>}}} (embedding included unless include_fields)
30 * list → POST body {prefix,limit,pagination_token} → {documents:[{_id}], pagination:{next}}
31 * upsert → 202 {upserted_count}; delete → 202 {}; a classic /query on a document index → 400.
32 * Numeric fields come back as floats (chunk_index 0.0) — normalize_metadata() casts them.
33 * A dense score_by cannot be combined with a text score_by in one search; hybrid is
34 * a dense search plus a text-match FILTER, or two searches fused client-side.
35 */
36
37 if (!defined('ABSPATH')) {
38 exit;
39 }
40
41 class MxChat_Pinecone_Documents {
42
43 const API_VERSION = '2026-07';
44 const TEXT_FIELD = 'text';
45 const VECTOR_FIELD = 'embedding';
46 const MIGRATION_KEY = 'mxchat_pinecone_docs_migration';
47 const UPSERT_BATCH = 40; // 1536-dim vectors are ~30 KB of JSON each; the request cap is 2 MB
48 const MIGRATE_BATCH = 40;
49 const OPTION = 'mxchat_pinecone_addon_options';
50
51 /** Fields the retrieval and listing paths ask for (never the vector unless needed). */
52 const RECORD_FIELDS = array('text', 'source_url', 'type', 'bot_id', 'is_chunked', 'chunk_index', 'total_chunks', 'parent_url_hash', 'role_restriction', 'created_at', 'last_updated');
53
54 public static function clouds() {
55 return array('aws' => 'AWS', 'gcp' => 'GCP', 'azure' => 'Azure');
56 }
57
58 public static function languages() {
59 return array(
60 'en' => 'English', 'de' => 'German', 'fr' => 'French', 'es' => 'Spanish', 'it' => 'Italian',
61 'nl' => 'Dutch', 'pt' => 'Portuguese', 'sv' => 'Swedish', 'da' => 'Danish', 'no' => 'Norwegian',
62 'fi' => 'Finnish', 'ru' => 'Russian', 'tr' => 'Turkish', 'ar' => 'Arabic', 'hu' => 'Hungarian',
63 'ro' => 'Romanian', 'el' => 'Greek',
64 );
65 }
66
67 // =====================================================================
68 // Configuration
69 // =====================================================================
70
71 /**
72 * 'vector' | 'document' from a mxchat_pinecone_addon_options-shaped array.
73 */
74 public static function options_index_type(array $options) {
75 return (isset($options['mxchat_pinecone_index_type']) && $options['mxchat_pinecone_index_type'] === 'document') ? 'document' : 'vector';
76 }
77
78 /**
79 * Resolve the Pinecone connection for a bot: the bot's own config when the
80 * Multi-Bot add-on supplies one (it may carry 'index_type'), else the
81 * site-wide option. Same precedence the classic paths use.
82 *
83 * @return array{api_key:string,host:string,namespace:string,index_type:string,use_pinecone:bool}
84 */
85 public static function config($bot_id = 'default') {
86 if ($bot_id === 'testing') {
87 $bot_id = 'default';
88 }
89 if ($bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager')) {
90 $bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
91 if (is_array($bot_config) && !empty($bot_config)) {
92 return array(
93 'api_key' => $bot_config['api_key'] ?? '',
94 'host' => $bot_config['host'] ?? '',
95 'namespace' => $bot_config['namespace'] ?? '',
96 'index_type' => (($bot_config['index_type'] ?? 'vector') === 'document') ? 'document' : 'vector',
97 'use_pinecone' => !empty($bot_config['use_pinecone']),
98 );
99 }
100 }
101 $options = get_option(self::OPTION, array());
102 if (!is_array($options)) {
103 $options = array();
104 }
105 return self::cfg_from_options($options);
106 }
107
108 /**
109 * Same shape as config(), from an options array (the KB screen passes those around).
110 */
111 public static function cfg_from_options(array $options) {
112 return array(
113 'api_key' => $options['mxchat_pinecone_api_key'] ?? '',
114 'host' => $options['mxchat_pinecone_host'] ?? '',
115 'namespace' => $options['mxchat_pinecone_namespace'] ?? '',
116 'index_type' => self::options_index_type($options),
117 'use_pinecone' => isset($options['mxchat_use_pinecone']) && (string) $options['mxchat_use_pinecone'] === '1',
118 );
119 }
120
121 /** Is this bot's Pinecone configuration a document index? */
122 public static function is_document_index($bot_id = 'default') {
123 $cfg = self::config($bot_id);
124 return $cfg['index_type'] === 'document' && !empty($cfg['host']) && !empty($cfg['api_key']);
125 }
126
127 /**
128 * Is this host the site's document index? For the delete helpers that
129 * receive bare credentials (no options array, no bot id).
130 */
131 public static function is_document_host($host) {
132 $host = strtolower(trim((string) $host, '/'));
133 if ($host === '') {
134 return false;
135 }
136 $options = get_option(self::OPTION, array());
137 $is = is_array($options)
138 && self::options_index_type($options) === 'document'
139 && strtolower(trim((string) ($options['mxchat_pinecone_host'] ?? ''), '/')) === $host;
140 return (bool) apply_filters('mxchat_pinecone_is_document_host', $is, $host);
141 }
142
143 /** The "processed content" row the Knowledge screens build per indexed post. */
144 public static function processed_row($vector_id, $url, array $metadata) {
145 $created_at = $metadata['created_at'] ?? '';
146 $processed_date = 'Recently';
147 $timestamp = current_time('timestamp');
148 if (!empty($created_at)) {
149 $ts = is_numeric($created_at) ? (int) $created_at : strtotime($created_at);
150 if ($ts) {
151 $timestamp = $ts;
152 $processed_date = human_time_diff($ts, current_time('timestamp')) . ' ago';
153 }
154 }
155 return array(
156 'db_id' => $vector_id,
157 'processed_date' => $processed_date,
158 'url' => $url,
159 'source' => 'pinecone',
160 'timestamp' => $timestamp,
161 );
162 }
163
164 /** Is this options array (mxchat_pinecone_addon_options shape) a document index? */
165 public static function is_document_options($options) {
166 if (!is_array($options)) {
167 return false;
168 }
169 $cfg = self::cfg_from_options($options);
170 return $cfg['index_type'] === 'document' && !empty($cfg['host']) && !empty($cfg['api_key']);
171 }
172
173 /** Namespace path segment: Pinecone addresses the default namespace as __default__. */
174 public static function namespace_path($namespace) {
175 $namespace = (string) $namespace;
176 return $namespace === '' ? '__default__' : rawurlencode($namespace);
177 }
178
179 /** Stats report the default namespace as "__default__" — the classic shape uses "". */
180 private static function namespace_stats_key($namespace) {
181 return (string) $namespace === '' ? '__default__' : (string) $namespace;
182 }
183
184 // =====================================================================
185 // Transport
186 // =====================================================================
187
188 /**
189 * One HTTP call with the document-API version header.
190 *
191 * @return array{code:int,data:mixed,raw:string,error:string}
192 */
193 public static function request($method, $url, $body, $api_key, $timeout = 30) {
194 $args = array(
195 'method' => $method,
196 'headers' => array(
197 'Api-Key' => $api_key,
198 'accept' => 'application/json',
199 'content-type' => 'application/json',
200 'X-Pinecone-Api-Version' => self::API_VERSION,
201 ),
202 'timeout' => $timeout,
203 );
204 if ($body !== null) {
205 $args['body'] = wp_json_encode($body);
206 }
207 $response = wp_remote_request($url, $args);
208 if (is_wp_error($response)) {
209 return array('code' => 0, 'data' => null, 'raw' => '', 'error' => $response->get_error_message());
210 }
211 $code = (int) wp_remote_retrieve_response_code($response);
212 $raw = (string) wp_remote_retrieve_body($response);
213 $data = $raw === '' ? array() : json_decode($raw, true);
214 $error = '';
215 if ($code < 200 || $code >= 300) {
216 $error = is_array($data) && isset($data['error']['message']) ? $data['error']['message']
217 : (is_array($data) && isset($data['message']) ? $data['message'] : substr($raw, 0, 300));
218 $error = 'Pinecone API error (HTTP ' . $code . '): ' . $error;
219 }
220 return array('code' => $code, 'data' => $data, 'raw' => $raw, 'error' => $error);
221 }
222
223 private static function data_url(array $cfg, $path) {
224 return 'https://' . $cfg['host'] . '/namespaces/' . self::namespace_path($cfg['namespace'] ?? '') . '/documents/' . ltrim($path, '/');
225 }
226
227 private static function require_cfg(array $cfg) {
228 if (empty($cfg['host']) || empty($cfg['api_key'])) {
229 return new WP_Error('pinecone_config', 'Pinecone is not properly configured (host or API key missing).');
230 }
231 return true;
232 }
233
234 // =====================================================================
235 // Document operations
236 // =====================================================================
237
238 /**
239 * Build one document from the pieces the classic upsert would have sent.
240 * Metadata keys travel 1:1 (except 'text', which is the schema field).
241 */
242 public static function build_document($id, $text, array $embedding, array $metadata) {
243 $doc = array('_id' => (string) $id);
244 foreach ($metadata as $key => $value) {
245 if ($key === self::TEXT_FIELD || $key === self::VECTOR_FIELD || $key === '_id') {
246 continue;
247 }
248 if (is_scalar($value) || $value === null) {
249 $doc[$key] = $value;
250 } elseif (is_array($value)) {
251 $doc[$key] = $value;
252 }
253 }
254 $doc[self::TEXT_FIELD] = (string) $text;
255 $doc[self::VECTOR_FIELD] = array_values(array_map('floatval', $embedding));
256 return $doc;
257 }
258
259 /**
260 * Upsert documents in batches. Same id → the document is replaced.
261 *
262 * @return true|WP_Error
263 */
264 public static function upsert(array $documents, array $cfg) {
265 $ok = self::require_cfg($cfg);
266 if (is_wp_error($ok)) {
267 return $ok;
268 }
269 if (empty($documents)) {
270 return true;
271 }
272 foreach (array_chunk(array_values($documents), self::UPSERT_BATCH) as $batch) {
273 $res = self::request('POST', self::data_url($cfg, 'upsert'), array('documents' => $batch), $cfg['api_key'], 60);
274 if ($res['error'] !== '') {
275 return new WP_Error('pinecone_api', $res['error']);
276 }
277 }
278 return true;
279 }
280
281 public static function upsert_one($id, $text, array $embedding, array $metadata, array $cfg) {
282 return self::upsert(array(self::build_document($id, $text, $embedding, $metadata)), $cfg);
283 }
284
285 /**
286 * Search. Returns matches normalized to the CLASSIC shape
287 * [{id, score, metadata:{…}}] so the grouping code can be shared.
288 *
289 * @return array|WP_Error
290 */
291 public static function search(array $score_by, $top_k, $filter, array $cfg, $include_fields = null) {
292 $ok = self::require_cfg($cfg);
293 if (is_wp_error($ok)) {
294 return $ok;
295 }
296 $top_k = max(1, min(10000, (int) $top_k));
297 $body = array(
298 'score_by' => $score_by,
299 'top_k' => $top_k,
300 'include_fields' => $include_fields === null ? self::RECORD_FIELDS : array_values($include_fields),
301 );
302 if (!empty($filter) && is_array($filter)) {
303 $body['filter'] = $filter;
304 }
305 $res = self::request('POST', self::data_url($cfg, 'search'), $body, $cfg['api_key'], 30);
306 if ($res['error'] !== '') {
307 return new WP_Error('pinecone_api', $res['error']);
308 }
309 $matches = array();
310 if (is_array($res['data']) && !empty($res['data']['matches'])) {
311 foreach ($res['data']['matches'] as $hit) {
312 $matches[] = self::normalize_hit($hit);
313 }
314 }
315 return $matches;
316 }
317
318 public static function search_dense(array $vector, $top_k, $filter, array $cfg, $include_fields = null) {
319 return self::search(
320 array(array('type' => 'dense_vector', 'fields' => array(self::VECTOR_FIELD), 'values' => array_values($vector))),
321 $top_k, $filter, $cfg, $include_fields
322 );
323 }
324
325 public static function search_text($query, $top_k, $filter, array $cfg, $include_fields = null) {
326 return self::search(
327 array(array('type' => 'text', 'fields' => array(self::TEXT_FIELD), 'query' => (string) $query)),
328 $top_k, $filter, $cfg, $include_fields
329 );
330 }
331
332 /**
333 * Fetch documents by id. Returns the classic /vectors/fetch shape:
334 * [id => ['id' => …, 'values' => [...] (only when asked), 'metadata' => [...]]].
335 */
336 public static function fetch(array $ids, array $cfg, $include_fields = null, $with_vector = false) {
337 $ok = self::require_cfg($cfg);
338 if (is_wp_error($ok) || empty($ids)) {
339 return array();
340 }
341 $fields = $include_fields === null ? self::RECORD_FIELDS : array_values($include_fields);
342 if ($with_vector && !in_array(self::VECTOR_FIELD, $fields, true)) {
343 $fields[] = self::VECTOR_FIELD;
344 }
345 $out = array();
346 foreach (array_chunk(array_values(array_unique(array_map('strval', $ids))), 1000) as $chunk) {
347 $res = self::request('POST', self::data_url($cfg, 'fetch'), array('ids' => $chunk, 'include_fields' => $fields), $cfg['api_key'], 30);
348 if ($res['error'] !== '' || !is_array($res['data']) || empty($res['data']['documents'])) {
349 continue;
350 }
351 foreach ($res['data']['documents'] as $doc_id => $doc) {
352 if (!is_array($doc)) {
353 continue;
354 }
355 $id = isset($doc['_id']) ? (string) $doc['_id'] : (string) $doc_id;
356 $row = array('id' => $id, 'metadata' => self::normalize_metadata($doc));
357 if (isset($doc[self::VECTOR_FIELD]) && is_array($doc[self::VECTOR_FIELD])) {
358 $row['values'] = $doc[self::VECTOR_FIELD];
359 }
360 $out[$id] = $row;
361 }
362 }
363 return $out;
364 }
365
366 /**
367 * One page of ids. @return array{ids:array,next:string}|WP_Error
368 */
369 public static function list_page($prefix, $limit, $token, array $cfg) {
370 $ok = self::require_cfg($cfg);
371 if (is_wp_error($ok)) {
372 return $ok;
373 }
374 $body = array('limit' => max(1, min(100, (int) $limit)));
375 if ((string) $prefix !== '') {
376 $body['prefix'] = (string) $prefix;
377 }
378 if ((string) $token !== '') {
379 $body['pagination_token'] = (string) $token;
380 }
381 $res = self::request('POST', self::data_url($cfg, 'list'), $body, $cfg['api_key'], 30);
382 if ($res['error'] !== '') {
383 return new WP_Error('pinecone_api', $res['error']);
384 }
385 $ids = array();
386 if (is_array($res['data']) && !empty($res['data']['documents'])) {
387 foreach ($res['data']['documents'] as $doc) {
388 if (isset($doc['_id'])) {
389 $ids[] = (string) $doc['_id'];
390 }
391 }
392 }
393 $next = is_array($res['data']) && isset($res['data']['pagination']['next']) ? (string) $res['data']['pagination']['next'] : '';
394 return array('ids' => $ids, 'next' => $next);
395 }
396
397 /** Every id under a prefix, following pagination (capped). */
398 public static function list_ids($prefix, array $cfg, $max = 10000) {
399 $ids = array();
400 $token = '';
401 do {
402 $page = self::list_page($prefix, 100, $token, $cfg);
403 if (is_wp_error($page)) {
404 break;
405 }
406 $ids = array_merge($ids, $page['ids']);
407 $token = $page['next'];
408 } while ($token !== '' && count($ids) < $max);
409 return $ids;
410 }
411
412 /** @return true|WP_Error */
413 public static function delete_ids(array $ids, array $cfg) {
414 $ok = self::require_cfg($cfg);
415 if (is_wp_error($ok)) {
416 return $ok;
417 }
418 $ids = array_values(array_unique(array_map('strval', $ids)));
419 if (empty($ids)) {
420 return true;
421 }
422 foreach (array_chunk($ids, 1000) as $chunk) {
423 $res = self::request('POST', self::data_url($cfg, 'delete'), array('ids' => $chunk), $cfg['api_key'], 60);
424 // 404 = the namespace does not exist yet — nothing to delete.
425 if ($res['error'] !== '' && $res['code'] !== 404) {
426 return new WP_Error('pinecone_api', $res['error']);
427 }
428 }
429 return true;
430 }
431
432 /** Delete every document in the namespace, or those matching a metadata filter. @return true|WP_Error */
433 public static function delete_all(array $cfg, $filter = null) {
434 $ok = self::require_cfg($cfg);
435 if (is_wp_error($ok)) {
436 return $ok;
437 }
438 $body = (!empty($filter) && is_array($filter)) ? array('filter' => $filter) : array('delete_all' => true);
439 $res = self::request('POST', self::data_url($cfg, 'delete'), $body, $cfg['api_key'], 60);
440 if ($res['error'] !== '' && $res['code'] !== 404) {
441 return new WP_Error('pinecone_api', $res['error']);
442 }
443 return true;
444 }
445
446 /**
447 * describe_index_stats, classic-shaped: namespaces keyed the classic way
448 * ('' for the default namespace), totalVectorCount, dimension.
449 *
450 * @return array|WP_Error
451 */
452 public static function stats(array $cfg) {
453 $ok = self::require_cfg($cfg);
454 if (is_wp_error($ok)) {
455 return $ok;
456 }
457 $res = self::request('POST', 'https://' . $cfg['host'] . '/describe_index_stats', new stdClass(), $cfg['api_key'], 15);
458 if ($res['error'] !== '' || !is_array($res['data'])) {
459 return new WP_Error('pinecone_api', $res['error'] !== '' ? $res['error'] : 'Empty stats response');
460 }
461 $namespaces = array();
462 if (!empty($res['data']['namespaces']) && is_array($res['data']['namespaces'])) {
463 foreach ($res['data']['namespaces'] as $name => $info) {
464 $key = ($name === '__default__') ? '' : (string) $name;
465 $namespaces[$key] = array('vectorCount' => (int) ($info['vectorCount'] ?? $info['recordCount'] ?? 0));
466 }
467 }
468 return array(
469 'namespaces' => $namespaces,
470 'totalVectorCount' => (int) ($res['data']['totalVectorCount'] ?? 0),
471 'dimension' => (int) ($res['data']['dimension'] ?? 0),
472 );
473 }
474
475 /** Record count for the configured namespace (0 when unknown / not listed). */
476 public static function count(array $cfg) {
477 $stats = self::stats($cfg);
478 if (is_wp_error($stats)) {
479 return 0;
480 }
481 $ns = (string) ($cfg['namespace'] ?? '');
482 if ($ns !== '') {
483 return isset($stats['namespaces'][$ns]['vectorCount']) ? (int) $stats['namespaces'][$ns]['vectorCount'] : 0;
484 }
485 return (int) $stats['totalVectorCount'];
486 }
487
488 /**
489 * Reassemble a chunked entry's text from its <md5>_chunk_N documents —
490 * the document-index twin of the integrator's reassemble_chunks_from_pinecone().
491 */
492 public static function reassemble_chunks($source_url, array $cfg, $max_chunks = 0, &$chunk_count = 0) {
493 $chunk_count = 0;
494 if (empty($cfg['host']) || empty($cfg['api_key'])) {
495 return '';
496 }
497 $ids = self::list_ids(md5($source_url) . '_chunk_', $cfg, 500);
498 if (empty($ids)) {
499 return '';
500 }
501 $docs = self::fetch($ids, $cfg, array(self::TEXT_FIELD, 'chunk_index'));
502 if (empty($docs)) {
503 return '';
504 }
505 $chunks = array();
506 foreach ($docs as $doc) {
507 $chunks[(int) ($doc['metadata']['chunk_index'] ?? 0)] = (string) ($doc['metadata']['text'] ?? '');
508 }
509 ksort($chunks);
510 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
511 $chunks = array_slice($chunks, 0, $max_chunks, true);
512 }
513 $chunk_count = count($chunks);
514 return implode("\n\n", $chunks);
515 }
516
517 /** Flat search hit → classic {id, score, metadata}. */
518 public static function normalize_hit(array $hit) {
519 $id = isset($hit['_id']) ? (string) $hit['_id'] : '';
520 $score = isset($hit['_score']) ? (float) $hit['_score'] : 0.0;
521 $row = array('id' => $id, 'score' => $score, 'metadata' => self::normalize_metadata($hit));
522 if (isset($hit[self::VECTOR_FIELD]) && is_array($hit[self::VECTOR_FIELD])) {
523 $row['values'] = $hit[self::VECTOR_FIELD];
524 }
525 return $row;
526 }
527
528 /** Document fields → the metadata array the classic code reads (ints where it expects ints). */
529 public static function normalize_metadata(array $fields) {
530 $meta = array();
531 foreach ($fields as $key => $value) {
532 if ($key === '_id' || $key === '_score' || $key === self::VECTOR_FIELD) {
533 continue;
534 }
535 $meta[$key] = $value;
536 }
537 foreach (array('chunk_index', 'total_chunks', 'created_at', 'last_updated') as $int_key) {
538 if (isset($meta[$int_key]) && is_numeric($meta[$int_key])) {
539 $meta[$int_key] = (int) $meta[$int_key];
540 }
541 }
542 if (isset($meta['is_chunked'])) {
543 $meta['is_chunked'] = filter_var($meta['is_chunked'], FILTER_VALIDATE_BOOLEAN);
544 }
545 return $meta;
546 }
547
548 /** Classic {id, score, metadata} rows → the KB screen's record objects. */
549 public static function records_from_matches(array $matches, $bot_id = 'default', $content_type = '') {
550 $records = array();
551 foreach ($matches as $match) {
552 $metadata = $match['metadata'] ?? array();
553 if ($content_type !== '' && (($metadata['type'] ?? 'content') !== $content_type)) {
554 continue;
555 }
556 $created_at = $metadata['created_at'] ?? $metadata['last_updated'] ?? time();
557 if (!is_numeric($created_at)) {
558 $created_at = strtotime($created_at) ?: time();
559 }
560 $records[] = (object) array(
561 'id' => $match['id'] ?? '',
562 'article_content' => $metadata['text'] ?? '',
563 'source_url' => $metadata['source_url'] ?? '',
564 'role_restriction' => $metadata['role_restriction'] ?? 'public',
565 'type' => $metadata['type'] ?? 'content',
566 'bot_id' => $bot_id,
567 'created_at' => (int) $created_at,
568 'data_source' => 'pinecone',
569 'relevance_score' => $match['score'] ?? 0,
570 'chunk_index' => isset($metadata['chunk_index']) ? (int) $metadata['chunk_index'] : null,
571 'total_chunks' => isset($metadata['total_chunks']) ? (int) $metadata['total_chunks'] : null,
572 'is_chunked' => !empty($metadata['is_chunked']),
573 );
574 }
575 return $records;
576 }
577
578 /**
579 * The KB screen listing on a document index (twin of
580 * MxChat_Pinecone_Manager::mxchat_fetch_pinecone_records, cursor-paged).
581 * With a search query the BM25 index answers it — no embedding call.
582 */
583 public static function fetch_records(array $pinecone_options, $search_query, $page, $per_page, $bot_id, $content_type) {
584 $cfg = self::cfg_from_options($pinecone_options);
585 $page = max(1, (int) $page);
586 $per_page = max(1, (int) $per_page);
587 $empty = array('data' => array(), 'total' => 0, 'total_in_database' => 0, 'showing_recent_only' => false, 'has_next' => false);
588 if (empty($cfg['host']) || empty($cfg['api_key'])) {
589 return $empty;
590 }
591 $total_in_database = self::count($cfg);
592 $type_filter = $content_type !== '' ? array('type' => array('$eq' => (string) $content_type)) : null;
593
594 if (trim((string) $search_query) !== '') {
595 $limit = min(($page * $per_page) + 100, 500);
596 $matches = self::search_text($search_query, $limit, $type_filter, $cfg);
597 if (is_wp_error($matches)) {
598 $matches = array();
599 }
600 $records = self::records_from_matches($matches, $bot_id, '');
601 $offset = ($page - 1) * $per_page;
602 return array(
603 'data' => array_slice($records, $offset, $per_page),
604 'total' => count($records),
605 'total_in_database' => $total_in_database,
606 'showing_recent_only' => false,
607 'has_next' => ($offset + $per_page) < count($records),
608 );
609 }
610
611 // Browse: one cursor step per page (plan dd6e10's rule), tokens kept per page.
612 $limit = $content_type !== '' ? $per_page * 2 : $per_page;
613 $pagination_key = 'mxchat_pinecone_page_' . md5($cfg['host'] . $cfg['namespace'] . $content_type . '_docs');
614 $token = '';
615 if ($page > 1) {
616 $stored = get_transient($pagination_key);
617 if ($stored && isset($stored[$page])) {
618 $token = $stored[$page];
619 }
620 }
621 $listing = self::list_page('', $limit, $token, $cfg);
622 if (is_wp_error($listing)) {
623 error_log('MxChat Pinecone (document index): documents/list failed — ' . $listing->get_error_message());
624 $empty['total_in_database'] = $total_in_database;
625 return $empty;
626 }
627 $has_next = $listing['next'] !== '';
628 if ($has_next) {
629 $stored = get_transient($pagination_key) ?: array();
630 $stored[$page + 1] = $listing['next'];
631 set_transient($pagination_key, $stored, 300);
632 }
633 $records = array();
634 if (!empty($listing['ids'])) {
635 $docs = self::fetch($listing['ids'], $cfg);
636 $matches = array();
637 foreach ($docs as $doc) {
638 $matches[] = array('id' => $doc['id'], 'score' => 0, 'metadata' => $doc['metadata']);
639 }
640 $records = self::records_from_matches($matches, $bot_id, (string) $content_type);
641 usort($records, function ($a, $b) {
642 return $b->created_at <=> $a->created_at;
643 });
644 $records = array_slice($records, 0, $per_page);
645 }
646 return array(
647 'data' => $records,
648 'total' => $total_in_database,
649 'total_in_database' => $total_in_database,
650 'showing_recent_only' => false,
651 'count_unit' => 'vectors',
652 'cursor_pagination' => true,
653 'has_next' => $has_next,
654 );
655 }
656
657 // =====================================================================
658 // Retrieval helpers (hybrid keyword leg)
659 // =====================================================================
660
661 /**
662 * Code-like tokens in a question: runs that mix letters and digits, or
663 * hyphen / dot joined codes (HGH25CA, SR25W, R1621-314-20, ISO-9001-2015).
664 * Plain numbers and plain words are not codes. Minimum 4 characters.
665 */
666 public static function extract_code_tokens($text) {
667 $tokens = array();
668 if (!preg_match_all('/(?<![A-Za-z0-9])([A-Za-z0-9]+(?:[.\-][A-Za-z0-9]+)*)(?![A-Za-z0-9])/u', (string) $text, $m)) {
669 return $tokens;
670 }
671 foreach ($m[1] as $candidate) {
672 $candidate = rtrim($candidate, '.');
673 if (strlen($candidate) < 4 || !preg_match('/\d/', $candidate)) {
674 continue;
675 }
676 $has_letter = (bool) preg_match('/[A-Za-z]/', $candidate);
677 $separators = preg_match_all('/[.\-]/', $candidate);
678 if (!$has_letter && $separators < 2) {
679 continue; // 2024, 26.5, 10-20
680 }
681 $tokens[strtoupper($candidate)] = $candidate;
682 }
683 return array_values($tokens);
684 }
685
686 /**
687 * Text-match filter that requires at least one of the codes. Hyphenated or
688 * dotted codes are phrase-matched (the tokenizer splits them, and "20" on
689 * its own would match everything); single runs use $match_any.
690 */
691 public static function keyword_filter(array $tokens) {
692 $clauses = array();
693 foreach ($tokens as $token) {
694 $op = preg_match('/[.\-]/', $token) ? '$match_phrase' : '$match_any';
695 $clauses[] = array(self::TEXT_FIELD => array($op => $token));
696 }
697 if (empty($clauses)) {
698 return null;
699 }
700 return count($clauses) === 1 ? $clauses[0] : array('$or' => $clauses);
701 }
702
703 /** AND two filters (either may be empty). */
704 public static function merge_filters($a, $b) {
705 $a = (!empty($a) && is_array($a)) ? $a : null;
706 $b = (!empty($b) && is_array($b)) ? $b : null;
707 if ($a === null) {
708 return $b;
709 }
710 if ($b === null) {
711 return $a;
712 }
713 return array('$and' => array($a, $b));
714 }
715
716 /**
717 * Translate the classic query body (what the mxchat_pinecone_query_body
718 * filter sees) into what a document search needs: topK → top_k, filter → filter.
719 *
720 * @return array{top_k:int,filter:array|null}
721 */
722 public static function translate_classic_body(array $body, $default_top_k) {
723 $top_k = isset($body['topK']) ? absint($body['topK']) : 0;
724 if ($top_k < 1 || $top_k > 1000) {
725 $top_k = $default_top_k;
726 }
727 $filter = (!empty($body['filter']) && is_array($body['filter'])) ? $body['filter'] : null;
728 return array('top_k' => $top_k, 'filter' => $filter);
729 }
730
731 // =====================================================================
732 // Control plane
733 // =====================================================================
734
735 private static function control_request($method, $path, $body, $api_key) {
736 return self::request($method, 'https://api.pinecone.io/' . ltrim($path, '/'), $body, $api_key, 30);
737 }
738
739 /** @return array|WP_Error the IndexModel (2026-07 shape, with schema) */
740 public static function describe_index($name, $api_key) {
741 $name = sanitize_text_field($name);
742 if ($name === '') {
743 return new WP_Error('pinecone_index', 'Index name is empty.');
744 }
745 $res = self::control_request('GET', 'indexes/' . rawurlencode($name), null, $api_key);
746 if ($res['error'] !== '' || !is_array($res['data'])) {
747 return new WP_Error('pinecone_api', $res['error'] !== '' ? $res['error'] : 'Empty describe response', array('code' => $res['code']));
748 }
749 return $res['data'];
750 }
751
752 /** Find an index in the project by its host. @return array|WP_Error */
753 public static function find_index_by_host($host, $api_key) {
754 $host = strtolower(trim(str_replace(array('https://', 'http://'), '', (string) $host), '/'));
755 if ($host === '') {
756 return new WP_Error('pinecone_index', 'Host is empty.');
757 }
758 $res = self::control_request('GET', 'indexes', null, $api_key);
759 if ($res['error'] !== '' || !is_array($res['data'])) {
760 return new WP_Error('pinecone_api', $res['error'] !== '' ? $res['error'] : 'Could not list indexes');
761 }
762 foreach (($res['data']['indexes'] ?? array()) as $index) {
763 if (isset($index['host']) && strtolower($index['host']) === $host) {
764 return $index;
765 }
766 }
767 return new WP_Error('pinecone_index', sprintf('No index in this Pinecone project answers to the host %s. Check the host and that the API key belongs to the same project.', $host));
768 }
769
770 /**
771 * Read a described index's schema. Classic vector indexes carry the
772 * reserved _values / _sparse_values fields; a document index names its own.
773 */
774 public static function inspect_schema(array $index) {
775 $out = array('is_document' => false, 'text_field' => '', 'dense_field' => '', 'dimension' => 0, 'metric' => '', 'language' => '');
776 $fields = $index['schema']['fields'] ?? array();
777 if (!is_array($fields)) {
778 return $out;
779 }
780 foreach ($fields as $name => $spec) {
781 if (!is_array($spec)) {
782 continue;
783 }
784 if (($spec['type'] ?? '') === 'dense_vector' && strpos($name, '_') !== 0) {
785 $out['dense_field'] = (string) $name;
786 $out['dimension'] = (int) ($spec['dimension'] ?? 0);
787 $out['metric'] = (string) ($spec['metric'] ?? '');
788 }
789 if (($spec['type'] ?? '') === 'string' && !empty($spec['full_text_search']) && $out['text_field'] === '') {
790 $out['text_field'] = (string) $name;
791 $out['language'] = (string) ($spec['full_text_search']['language'] ?? '');
792 }
793 }
794 $out['is_document'] = $out['dense_field'] !== '' && $out['text_field'] !== '';
795 return $out;
796 }
797
798 /**
799 * Assert an index is a document index MxChat can use. Refuses with the
800 * exact mismatch otherwise.
801 *
802 * @return array{ok:bool,message:string,host:string,name:string,dimension:int,expected:int}
803 */
804 public static function check_document_index($host, $api_key, $expected_dimension) {
805 $index = self::find_index_by_host($host, $api_key);
806 if (is_wp_error($index)) {
807 return array('ok' => false, 'message' => $index->get_error_message(), 'host' => (string) $host, 'name' => '', 'dimension' => 0, 'expected' => (int) $expected_dimension);
808 }
809 $name = (string) ($index['name'] ?? '');
810 $schema = self::inspect_schema($index);
811 $base = array('host' => (string) ($index['host'] ?? $host), 'name' => $name, 'dimension' => $schema['dimension'], 'expected' => (int) $expected_dimension);
812 if (!$schema['is_document']) {
813 $fields = is_array($index['schema']['fields'] ?? null) ? implode(', ', array_keys($index['schema']['fields'])) : 'none';
814 return $base + array('ok' => false, 'message' => sprintf('"%s" is a classic vector index (schema fields: %s), not a document index with full-text search. Create a document index with the button above, or switch the index type back to Vector index.', $name, $fields));
815 }
816 if ($schema['text_field'] !== self::TEXT_FIELD || $schema['dense_field'] !== self::VECTOR_FIELD) {
817 return $base + array('ok' => false, 'message' => sprintf('"%s" is a document index, but its fields are named "%s" (text) and "%s" (vector). MxChat writes to fields named "%s" and "%s" — create the index with the button above so the schema matches.', $name, $schema['text_field'], $schema['dense_field'], self::TEXT_FIELD, self::VECTOR_FIELD));
818 }
819 if ((int) $schema['dimension'] !== (int) $expected_dimension) {
820 return $base + array('ok' => false, 'message' => sprintf('"%s" stores %d-dimension vectors, but the active embedding model produces %d dimensions. Pick a matching embedding model or create a new document index for this one.', $name, $schema['dimension'], $expected_dimension));
821 }
822 if (empty($index['status']['ready'])) {
823 return $base + array('ok' => false, 'message' => sprintf('"%s" is a matching document index but Pinecone reports it as %s. Wait a moment and check again.', $name, (string) ($index['status']['state'] ?? 'not ready')));
824 }
825 return $base + array('ok' => true, 'message' => sprintf('"%s" is a document index with full-text search (%s, %d dimensions, %s). Ready to use.', $name, $schema['language'] !== '' ? 'language ' . $schema['language'] : 'full-text search', $schema['dimension'], $schema['metric'] ?: 'cosine'));
826 }
827
828 /**
829 * Create a document index for the active embedding dimension.
830 * @return array|WP_Error the IndexModel
831 */
832 public static function create_document_index($name, $dimension, $cloud, $region, $language, $api_key) {
833 $name = strtolower(sanitize_text_field($name));
834 if (!preg_match('/^[a-z0-9]([a-z0-9-]{0,43}[a-z0-9])?$/', $name)) {
835 return new WP_Error('pinecone_index', 'Index names are 1-45 lowercase letters, digits and hyphens.');
836 }
837 $cloud = array_key_exists($cloud, self::clouds()) ? $cloud : 'aws';
838 $region = sanitize_text_field($region) ?: 'us-east-1';
839 $language = array_key_exists($language, self::languages()) ? $language : 'en';
840 $dimension = (int) $dimension;
841 if ($dimension < 1 || $dimension > 20000) {
842 return new WP_Error('pinecone_index', 'Embedding dimension is out of range.');
843 }
844 $body = array(
845 'name' => $name,
846 'deployment' => array('deployment_type' => 'managed', 'cloud' => $cloud, 'region' => $region),
847 'schema' => array('fields' => array(
848 self::TEXT_FIELD => array('type' => 'string', 'full_text_search' => array('language' => $language)),
849 self::VECTOR_FIELD => array('type' => 'dense_vector', 'dimension' => $dimension, 'metric' => 'cosine'),
850 )),
851 );
852 $res = self::control_request('POST', 'indexes', $body, $api_key);
853 if ($res['error'] !== '' || !is_array($res['data'])) {
854 return new WP_Error('pinecone_api', $res['error'] !== '' ? $res['error'] : 'Empty create response', array('code' => $res['code']));
855 }
856 return $res['data'];
857 }
858
859 /** @return true|WP_Error */
860 public static function delete_index($name, $api_key) {
861 $name = sanitize_text_field($name);
862 if ($name === '') {
863 return new WP_Error('pinecone_index', 'Index name is empty.');
864 }
865 $res = self::control_request('DELETE', 'indexes/' . rawurlencode($name), null, $api_key);
866 if ($res['error'] !== '' && $res['code'] !== 404) {
867 return new WP_Error('pinecone_api', $res['error']);
868 }
869 return true;
870 }
871
872 /** Dimension of the active embedding model (same table the KB screen uses). */
873 public static function expected_dimension() {
874 $options = get_option('mxchat_options', array());
875 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
876 $model_dimensions = array(
877 'text-embedding-ada-002' => 1536,
878 'text-embedding-3-small' => 1536,
879 'text-embedding-3-large' => 3072,
880 'voyage-2' => 1024,
881 'voyage-large-2' => 1536,
882 'voyage-3-large' => 2048,
883 'gemini-embedding-001' => 1536,
884 );
885 if (strpos($selected_model, 'voyage-3-large') === 0) {
886 return intval($options['voyage_output_dimension'] ?? 2048);
887 }
888 if (strpos($selected_model, 'gemini-embedding') === 0) {
889 return intval($options['gemini_output_dimension'] ?? 1536);
890 }
891 $dimension = apply_filters('mxchat_pinecone_expected_dimension', $model_dimensions[$selected_model] ?? 1536, $selected_model);
892 return (int) $dimension;
893 }
894
895 // =====================================================================
896 // Options (raw writes — the registered sanitizer rebuilds the array and
897 // would flip mxchat_use_pinecone on any isset key; the autosave handler
898 // writes this option the same raw way for the same reason)
899 // =====================================================================
900
901 public static function persist_options(array $changes) {
902 global $wpdb;
903 $raw = $wpdb->get_var($wpdb->prepare("SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", self::OPTION));
904 $current = $raw === null ? array() : maybe_unserialize($raw);
905 if (!is_array($current)) {
906 $current = array();
907 }
908 foreach ($changes as $key => $value) {
909 $current[$key] = $value;
910 }
911 $serialized = maybe_serialize($current);
912 if ($raw === null) {
913 $wpdb->insert($wpdb->options, array('option_name' => self::OPTION, 'option_value' => $serialized, 'autoload' => 'off'), array('%s', '%s', '%s'));
914 } else {
915 $wpdb->update($wpdb->options, array('option_value' => $serialized), array('option_name' => self::OPTION), array('%s'), array('%s'));
916 }
917 wp_cache_delete(self::OPTION, 'options');
918 wp_cache_delete('alloptions', 'options');
919 return $current;
920 }
921
922 // =====================================================================
923 // Copy-migration (classic vector index → document index, no embedding calls)
924 // =====================================================================
925
926 public static function migration_get() {
927 $state = get_transient(self::MIGRATION_KEY);
928 return is_array($state) ? $state : array();
929 }
930
931 public static function migration_set(array $state) {
932 $state['updated'] = time();
933 set_transient(self::MIGRATION_KEY, $state, DAY_IN_SECONDS);
934 return $state;
935 }
936
937 public static function migration_clear() {
938 delete_transient(self::MIGRATION_KEY);
939 }
940
941 /** Classic GET /vectors/list page. @return array{ids:array,next:string}|WP_Error */
942 public static function classic_list_page($host, $api_key, $namespace, $limit, $token) {
943 $params = array('limit' => max(1, min(100, (int) $limit)));
944 if ((string) $namespace !== '') {
945 $params['namespace'] = (string) $namespace;
946 }
947 if ((string) $token !== '') {
948 $params['paginationToken'] = (string) $token;
949 }
950 $response = wp_remote_get('https://' . $host . '/vectors/list?' . http_build_query($params), array(
951 'headers' => array('Api-Key' => $api_key, 'accept' => 'application/json'),
952 'timeout' => 30,
953 ));
954 if (is_wp_error($response)) {
955 return new WP_Error('pinecone_api', $response->get_error_message());
956 }
957 $code = (int) wp_remote_retrieve_response_code($response);
958 $data = json_decode((string) wp_remote_retrieve_body($response), true);
959 if ($code !== 200 || !is_array($data)) {
960 return new WP_Error('pinecone_api', 'Source index list failed (HTTP ' . $code . '): ' . substr((string) wp_remote_retrieve_body($response), 0, 200));
961 }
962 $ids = array();
963 foreach (($data['vectors'] ?? array()) as $vector) {
964 if (isset($vector['id'])) {
965 $ids[] = (string) $vector['id'];
966 }
967 }
968 return array('ids' => $ids, 'next' => (string) ($data['pagination']['next'] ?? ''));
969 }
970
971 /** Classic GET /vectors/fetch (values + metadata). @return array|WP_Error id => vector */
972 public static function classic_fetch($host, $api_key, $namespace, array $ids) {
973 $vectors = array();
974 foreach (array_chunk(array_values($ids), 100) as $chunk) {
975 $query = array();
976 foreach ($chunk as $id) {
977 $query[] = 'ids=' . rawurlencode($id);
978 }
979 if ((string) $namespace !== '') {
980 $query[] = 'namespace=' . rawurlencode($namespace);
981 }
982 $response = wp_remote_get('https://' . $host . '/vectors/fetch?' . implode('&', $query), array(
983 'headers' => array('Api-Key' => $api_key, 'accept' => 'application/json'),
984 'timeout' => 60,
985 ));
986 if (is_wp_error($response)) {
987 return new WP_Error('pinecone_api', $response->get_error_message());
988 }
989 $code = (int) wp_remote_retrieve_response_code($response);
990 $data = json_decode((string) wp_remote_retrieve_body($response), true);
991 if ($code !== 200 || !is_array($data)) {
992 return new WP_Error('pinecone_api', 'Source index fetch failed (HTTP ' . $code . ')');
993 }
994 if (!empty($data['vectors']) && is_array($data['vectors'])) {
995 $vectors += $data['vectors'];
996 }
997 }
998 return $vectors;
999 }
1000
1001 /** Classic POST /describe_index_stats (no version header). @return array|WP_Error */
1002 public static function classic_stats($host, $api_key) {
1003 $response = wp_remote_post('https://' . $host . '/describe_index_stats', array(
1004 'headers' => array('Api-Key' => $api_key, 'Content-Type' => 'application/json'),
1005 'body' => '{}',
1006 'timeout' => 15,
1007 ));
1008 if (is_wp_error($response)) {
1009 return new WP_Error('pinecone_api', $response->get_error_message());
1010 }
1011 $code = (int) wp_remote_retrieve_response_code($response);
1012 $data = json_decode((string) wp_remote_retrieve_body($response), true);
1013 if ($code !== 200 || !is_array($data)) {
1014 return new WP_Error('pinecone_api', 'Source index stats failed (HTTP ' . $code . '): ' . substr((string) wp_remote_retrieve_body($response), 0, 200));
1015 }
1016 return $data;
1017 }
1018
1019 /**
1020 * Validate and start (or resume) a migration. @return array|WP_Error state
1021 */
1022 public static function migrate_start($source_host, array $target_cfg, $restart = false) {
1023 $source_host = strtolower(trim(str_replace(array('https://', 'http://'), '', (string) $source_host), '/'));
1024 if ($source_host === '') {
1025 return new WP_Error('migrate', 'Enter the host of the vector index to copy from.');
1026 }
1027 if (empty($target_cfg['host']) || empty($target_cfg['api_key'])) {
1028 return new WP_Error('migrate', 'The document index host and API key must be saved first.');
1029 }
1030 if ($source_host === strtolower($target_cfg['host'])) {
1031 return new WP_Error('migrate', 'The source and the document index are the same host.');
1032 }
1033 $existing = self::migration_get();
1034 if (!$restart && !empty($existing) && ($existing['source_host'] ?? '') === $source_host && ($existing['target_host'] ?? '') === $target_cfg['host'] && ($existing['status'] ?? '') === 'running') {
1035 return $existing; // resume
1036 }
1037 $stats = self::classic_stats($source_host, $target_cfg['api_key']);
1038 if (is_wp_error($stats)) {
1039 return $stats;
1040 }
1041 $namespace = (string) ($target_cfg['namespace'] ?? '');
1042 if ($namespace !== '') {
1043 $total = isset($stats['namespaces'][$namespace]['vectorCount']) ? (int) $stats['namespaces'][$namespace]['vectorCount'] : 0;
1044 } else {
1045 $total = (int) ($stats['totalVectorCount'] ?? 0);
1046 }
1047 $source_dimension = (int) ($stats['dimension'] ?? 0);
1048 $target_index = self::find_index_by_host($target_cfg['host'], $target_cfg['api_key']);
1049 if (is_wp_error($target_index)) {
1050 return $target_index;
1051 }
1052 $schema = self::inspect_schema($target_index);
1053 if (!$schema['is_document']) {
1054 return new WP_Error('migrate', 'The configured host is not a document index. Run Check index first.');
1055 }
1056 if ($source_dimension > 0 && $schema['dimension'] > 0 && $source_dimension !== (int) $schema['dimension']) {
1057 return new WP_Error('migrate', sprintf('Dimensions differ (source %d, document index %d) — the vectors cannot be copied. Re-import your content into the document index instead.', $source_dimension, $schema['dimension']));
1058 }
1059 $state = array(
1060 'status' => 'running',
1061 'source_host' => $source_host,
1062 'target_host' => $target_cfg['host'],
1063 'target_name' => (string) ($target_index['name'] ?? ''),
1064 'namespace' => $namespace,
1065 'total' => $total,
1066 'copied' => 0,
1067 'token' => '',
1068 'batches' => 0,
1069 'started' => time(),
1070 'error' => '',
1071 'source_name' => '',
1072 );
1073 $source_index = self::find_index_by_host($source_host, $target_cfg['api_key']);
1074 if (!is_wp_error($source_index)) {
1075 $state['source_name'] = (string) ($source_index['name'] ?? '');
1076 }
1077 return self::migration_set($state);
1078 }
1079
1080 /**
1081 * Copy one page: list → fetch (values + metadata) → documents/upsert.
1082 * Idempotent — the same ids land in the same place on a re-run.
1083 * @return array|WP_Error state
1084 */
1085 public static function migrate_step(array $state, array $target_cfg, $batch = self::MIGRATE_BATCH) {
1086 if (($state['status'] ?? '') !== 'running') {
1087 return $state;
1088 }
1089 if (($state['target_host'] ?? '') !== ($target_cfg['host'] ?? '')) {
1090 return new WP_Error('migrate', 'The document index host changed since the migration started. Start it again.');
1091 }
1092 $page = self::classic_list_page($state['source_host'], $target_cfg['api_key'], $state['namespace'], $batch, $state['token']);
1093 if (is_wp_error($page)) {
1094 $state['error'] = $page->get_error_message();
1095 return self::migration_set($state);
1096 }
1097 if (!empty($page['ids'])) {
1098 $vectors = self::classic_fetch($state['source_host'], $target_cfg['api_key'], $state['namespace'], $page['ids']);
1099 if (is_wp_error($vectors)) {
1100 $state['error'] = $vectors->get_error_message();
1101 return self::migration_set($state);
1102 }
1103 $documents = array();
1104 foreach ($vectors as $id => $vector) {
1105 if (empty($vector['values']) || !is_array($vector['values'])) {
1106 continue;
1107 }
1108 $metadata = is_array($vector['metadata'] ?? null) ? $vector['metadata'] : array();
1109 $documents[] = self::build_document((string) ($vector['id'] ?? $id), (string) ($metadata['text'] ?? ''), $vector['values'], $metadata);
1110 }
1111 $result = self::upsert($documents, $target_cfg);
1112 if (is_wp_error($result)) {
1113 $state['error'] = $result->get_error_message();
1114 return self::migration_set($state);
1115 }
1116 $state['copied'] = (int) $state['copied'] + count($documents);
1117 }
1118 $state['batches'] = (int) $state['batches'] + 1;
1119 $state['token'] = $page['next'];
1120 $state['error'] = '';
1121 if ($page['next'] === '') {
1122 $state['status'] = 'done';
1123 $state['finished'] = time();
1124 }
1125 return self::migration_set($state);
1126 }
1127
1128 // =====================================================================
1129 // AJAX (Knowledge → Pinecone card)
1130 // =====================================================================
1131
1132 public static function boot() {
1133 add_action('wp_ajax_mxchat_pinecone_docs_create_index', array(__CLASS__, 'ajax_create_index'));
1134 add_action('wp_ajax_mxchat_pinecone_docs_check_index', array(__CLASS__, 'ajax_check_index'));
1135 add_action('wp_ajax_mxchat_pinecone_docs_migrate', array(__CLASS__, 'ajax_migrate'));
1136 add_action('wp_ajax_mxchat_pinecone_docs_delete_old_index', array(__CLASS__, 'ajax_delete_old_index'));
1137 }
1138
1139 private static function ajax_guard() {
1140 check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
1141 if (!current_user_can('manage_options')) {
1142 wp_send_json_error(array('message' => esc_html__('Permission denied.', 'mxchat')), 403);
1143 }
1144 }
1145
1146 private static function posted_api_key() {
1147 $posted = isset($_POST['api_key']) ? sanitize_text_field(wp_unslash($_POST['api_key'])) : '';
1148 if ($posted !== '') {
1149 return $posted;
1150 }
1151 $options = get_option(self::OPTION, array());
1152 return is_array($options) ? (string) ($options['mxchat_pinecone_api_key'] ?? '') : '';
1153 }
1154
1155 /**
1156 * Create index for me: control-plane create with the active embedding
1157 * dimension, then fill in (and persist) the host.
1158 */
1159 public static function ajax_create_index() {
1160 self::ajax_guard();
1161 $api_key = self::posted_api_key();
1162 $name = isset($_POST['index_name']) ? sanitize_text_field(wp_unslash($_POST['index_name'])) : '';
1163 $cloud = isset($_POST['cloud']) ? sanitize_key($_POST['cloud']) : 'aws';
1164 $region = isset($_POST['region']) ? sanitize_text_field(wp_unslash($_POST['region'])) : 'us-east-1';
1165 $language = isset($_POST['language']) ? sanitize_key($_POST['language']) : 'en';
1166 if ($api_key === '') {
1167 wp_send_json_error(array('message' => esc_html__('Enter your Pinecone API key first.', 'mxchat')));
1168 }
1169 if ($name === '') {
1170 wp_send_json_error(array('message' => esc_html__('Enter an index name first — the new document index will be created under that name.', 'mxchat')));
1171 }
1172 $dimension = self::expected_dimension();
1173 $index = self::create_document_index($name, $dimension, $cloud, $region, $language, $api_key);
1174 if (is_wp_error($index)) {
1175 wp_send_json_error(array('message' => $index->get_error_message()));
1176 }
1177 $host = (string) ($index['host'] ?? '');
1178 // Give Pinecone a few seconds — small serverless indexes are usually Ready quickly.
1179 $ready = !empty($index['status']['ready']);
1180 for ($i = 0; !$ready && $i < 5; $i++) {
1181 sleep(2);
1182 $described = self::describe_index($name, $api_key);
1183 if (!is_wp_error($described)) {
1184 $ready = !empty($described['status']['ready']);
1185 $host = (string) ($described['host'] ?? $host);
1186 }
1187 }
1188 $options = get_option(self::OPTION, array());
1189 $options = is_array($options) ? $options : array();
1190 $changes = array(
1191 'mxchat_pinecone_api_key' => $api_key,
1192 'mxchat_pinecone_host' => $host,
1193 'mxchat_pinecone_index' => strtolower($name),
1194 'mxchat_pinecone_index_type' => 'document',
1195 'mxchat_pinecone_docs_verified_host' => $host,
1196 'mxchat_pinecone_docs_cloud' => array_key_exists($cloud, self::clouds()) ? $cloud : 'aws',
1197 'mxchat_pinecone_docs_region' => $region ?: 'us-east-1',
1198 'mxchat_pinecone_docs_language' => array_key_exists($language, self::languages()) ? $language : 'en',
1199 );
1200 // Remember the classic index so Migrate can copy from it.
1201 $previous_host = (string) ($options['mxchat_pinecone_host'] ?? '');
1202 if ($previous_host !== '' && $previous_host !== $host && self::options_index_type($options) === 'vector' && empty($options['mxchat_pinecone_vector_host'])) {
1203 $changes['mxchat_pinecone_vector_host'] = $previous_host;
1204 $changes['mxchat_pinecone_vector_index'] = (string) ($options['mxchat_pinecone_index'] ?? '');
1205 }
1206 self::persist_options($changes);
1207 wp_send_json_success(array(
1208 'host' => $host,
1209 'name' => strtolower($name),
1210 'dimension' => $dimension,
1211 'ready' => $ready,
1212 'vector_host' => $changes['mxchat_pinecone_vector_host'] ?? ($options['mxchat_pinecone_vector_host'] ?? ''),
1213 'message' => $ready
1214 ? sprintf(esc_html__('Document index "%1$s" created (%2$d dimensions, cosine, full-text search on the chunk text). Host filled in and saved.', 'mxchat'), strtolower($name), $dimension)
1215 : sprintf(esc_html__('Document index "%1$s" is being created (%2$d dimensions). The host is filled in and saved; run Check index in a minute to confirm it is ready.', 'mxchat'), strtolower($name), $dimension),
1216 ));
1217 }
1218
1219 /** Check index: assert the host is a document index MxChat can use. */
1220 public static function ajax_check_index() {
1221 self::ajax_guard();
1222 $api_key = self::posted_api_key();
1223 $host = isset($_POST['host']) ? sanitize_text_field(wp_unslash($_POST['host'])) : '';
1224 $host = trim(str_replace(array('https://', 'http://'), '', $host), '/');
1225 if ($api_key === '' || $host === '') {
1226 wp_send_json_error(array('message' => esc_html__('Enter the Pinecone API key and host first.', 'mxchat')));
1227 }
1228 $check = self::check_document_index($host, $api_key, self::expected_dimension());
1229 if (!$check['ok']) {
1230 wp_send_json_error($check);
1231 }
1232 $options = get_option(self::OPTION, array());
1233 $options = is_array($options) ? $options : array();
1234 $changes = array(
1235 'mxchat_pinecone_docs_verified_host' => $check['host'],
1236 'mxchat_pinecone_index' => $check['name'],
1237 );
1238 $previous_host = (string) ($options['mxchat_pinecone_host'] ?? '');
1239 if ($previous_host !== '' && strtolower($previous_host) !== strtolower($check['host']) && self::options_index_type($options) === 'vector' && empty($options['mxchat_pinecone_vector_host'])) {
1240 $changes['mxchat_pinecone_vector_host'] = $previous_host;
1241 $changes['mxchat_pinecone_vector_index'] = (string) ($options['mxchat_pinecone_index'] ?? '');
1242 }
1243 self::persist_options($changes);
1244 $check['vector_host'] = $changes['mxchat_pinecone_vector_host'] ?? ($options['mxchat_pinecone_vector_host'] ?? '');
1245 wp_send_json_success($check);
1246 }
1247
1248 /** Migrate: mode start | step | status | reset. Each step copies one page. */
1249 public static function ajax_migrate() {
1250 self::ajax_guard();
1251 $mode = isset($_POST['mode']) ? sanitize_key($_POST['mode']) : 'status';
1252 $options = get_option(self::OPTION, array());
1253 $options = is_array($options) ? $options : array();
1254 $target = self::cfg_from_options($options);
1255 if ($mode === 'reset') {
1256 self::migration_clear();
1257 wp_send_json_success(array('state' => array()));
1258 }
1259 if ($mode === 'status') {
1260 wp_send_json_success(array('state' => self::migration_get()));
1261 }
1262 if ($target['index_type'] !== 'document') {
1263 wp_send_json_error(array('message' => esc_html__('Save the Pinecone settings with the index type set to Document index first.', 'mxchat')));
1264 }
1265 if (strtolower((string) ($options['mxchat_pinecone_docs_verified_host'] ?? '')) !== strtolower($target['host'])) {
1266 wp_send_json_error(array('message' => esc_html__('Run Check index on the document index host before migrating.', 'mxchat')));
1267 }
1268 if ($mode === 'start') {
1269 $source_host = isset($_POST['source_host']) ? sanitize_text_field(wp_unslash($_POST['source_host'])) : '';
1270 $restart = !empty($_POST['restart']);
1271 $state = self::migrate_start($source_host, $target, $restart);
1272 if (is_wp_error($state)) {
1273 wp_send_json_error(array('message' => $state->get_error_message()));
1274 }
1275 if ($source_host !== '' && $source_host !== (string) ($options['mxchat_pinecone_vector_host'] ?? '')) {
1276 self::persist_options(array('mxchat_pinecone_vector_host' => $state['source_host'], 'mxchat_pinecone_vector_index' => $state['source_name']));
1277 }
1278 wp_send_json_success(array('state' => $state));
1279 }
1280 if ($mode === 'step') {
1281 $state = self::migration_get();
1282 if (empty($state)) {
1283 wp_send_json_error(array('message' => esc_html__('No migration in progress. Click Migrate to start one.', 'mxchat')));
1284 }
1285 $state = self::migrate_step($state, $target);
1286 if (is_wp_error($state)) {
1287 wp_send_json_error(array('message' => $state->get_error_message()));
1288 }
1289 if (!empty($state['error'])) {
1290 wp_send_json_error(array('message' => $state['error'], 'state' => $state));
1291 }
1292 wp_send_json_success(array('state' => $state));
1293 }
1294 wp_send_json_error(array('message' => esc_html__('Unknown migration action.', 'mxchat')));
1295 }
1296
1297 /**
1298 * Delete old index — only after a finished copy whose count matches, only
1299 * with the index name typed back, never the configured document index.
1300 */
1301 public static function ajax_delete_old_index() {
1302 self::ajax_guard();
1303 $confirm = isset($_POST['confirm_name']) ? strtolower(sanitize_text_field(wp_unslash($_POST['confirm_name']))) : '';
1304 $options = get_option(self::OPTION, array());
1305 $options = is_array($options) ? $options : array();
1306 $target = self::cfg_from_options($options);
1307 $state = self::migration_get();
1308 if (empty($state) || ($state['status'] ?? '') !== 'done') {
1309 wp_send_json_error(array('message' => esc_html__('Finish the migration first — the old index can only be deleted after a completed copy.', 'mxchat')));
1310 }
1311 if ((int) $state['copied'] < (int) $state['total'] || (int) $state['total'] === 0) {
1312 wp_send_json_error(array('message' => sprintf(esc_html__('The copy count does not match (%1$d of %2$d copied). The old index stays.', 'mxchat'), (int) $state['copied'], (int) $state['total'])));
1313 }
1314 if ($target['index_type'] !== 'document' || strtolower($target['host']) !== strtolower((string) $state['target_host'])) {
1315 wp_send_json_error(array('message' => esc_html__('The saved document index no longer matches the migration. The old index stays.', 'mxchat')));
1316 }
1317 $source = self::find_index_by_host($state['source_host'], $target['api_key']);
1318 if (is_wp_error($source)) {
1319 wp_send_json_error(array('message' => $source->get_error_message()));
1320 }
1321 $source_name = strtolower((string) ($source['name'] ?? ''));
1322 if ($source_name === '' || $confirm !== $source_name) {
1323 wp_send_json_error(array('message' => sprintf(esc_html__('Type the old index name exactly (%s) to confirm.', 'mxchat'), $source_name)));
1324 }
1325 if (strtolower((string) ($source['host'] ?? '')) === strtolower($target['host']) || $source_name === strtolower((string) ($state['target_name'] ?? ''))) {
1326 wp_send_json_error(array('message' => esc_html__('Refusing: that is the document index currently in use.', 'mxchat')));
1327 }
1328 $result = self::delete_index($source_name, $target['api_key']);
1329 if (is_wp_error($result)) {
1330 wp_send_json_error(array('message' => $result->get_error_message()));
1331 }
1332 self::persist_options(array('mxchat_pinecone_vector_host' => '', 'mxchat_pinecone_vector_index' => ''));
1333 $state['status'] = 'source_deleted';
1334 self::migration_set($state);
1335 wp_send_json_success(array('message' => sprintf(esc_html__('Old vector index "%s" deleted.', 'mxchat'), $source_name), 'state' => $state));
1336 }
1337 }
1338
1339 MxChat_Pinecone_Documents::boot();
1340