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

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

2,159 lines 105.7 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 CODES_FIELD = 'codes'; // plan 3e83e4: normalised part-number tokens stored beside the text
47 const CODES_MAX = 60; // per document — a long spec sheet must not bloat the 40 KB metadata cap
48 const CODE_MIN_LEN = 4; // shorter tokens would match everything
49 const CODE_MAX_LEN = 32;
50 const MIGRATION_KEY = 'mxchat_pinecone_docs_migration';
51 const UPSERT_BATCH = 40; // 1536-dim vectors are ~30 KB of JSON each; the request cap is 2 MB
52 const MIGRATE_BATCH = 40;
53 const MIGRATE_STEP_SECONDS = 12; // pages copied per AJAX step until this much wall time is used (3e83e4)
54 const MIGRATE_RETRIES = 3; // attempts per Pinecone call inside a step, 1 s / 2 s / 4 s apart
55 const OPTION = 'mxchat_pinecone_addon_options';
56
57 /** Fields the retrieval and listing paths ask for (never the vector unless needed). */
58 const RECORD_FIELDS = array('text', 'source_url', 'title', 'type', 'bot_id', 'is_chunked', 'chunk_index', 'total_chunks', 'parent_url_hash', 'role_restriction', 'created_at', 'last_updated');
59
60 public static function clouds() {
61 return array('aws' => 'AWS', 'gcp' => 'GCP', 'azure' => 'Azure');
62 }
63
64 public static function languages() {
65 return array(
66 'en' => 'English', 'de' => 'German', 'fr' => 'French', 'es' => 'Spanish', 'it' => 'Italian',
67 'nl' => 'Dutch', 'pt' => 'Portuguese', 'sv' => 'Swedish', 'da' => 'Danish', 'no' => 'Norwegian',
68 'fi' => 'Finnish', 'ru' => 'Russian', 'tr' => 'Turkish', 'ar' => 'Arabic', 'hu' => 'Hungarian',
69 'ro' => 'Romanian', 'el' => 'Greek',
70 );
71 }
72
73 // =====================================================================
74 // Configuration
75 // =====================================================================
76
77 /**
78 * 'vector' | 'document' from a mxchat_pinecone_addon_options-shaped array.
79 */
80 public static function options_index_type(array $options) {
81 return (isset($options['mxchat_pinecone_index_type']) && $options['mxchat_pinecone_index_type'] === 'document') ? 'document' : 'vector';
82 }
83
84 /**
85 * Resolve the Pinecone connection for a bot: the bot's own config when the
86 * Multi-Bot add-on supplies one (it may carry 'index_type'), else the
87 * site-wide option. Same precedence the classic paths use.
88 *
89 * @return array{api_key:string,host:string,namespace:string,index_type:string,use_pinecone:bool}
90 */
91 public static function config($bot_id = 'default') {
92 if ($bot_id === 'testing') {
93 $bot_id = 'default';
94 }
95 if ($bot_id !== 'default' && class_exists('MxChat_Multi_Bot_Manager')) {
96 $bot_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
97 if (is_array($bot_config) && !empty($bot_config)) {
98 return array(
99 'api_key' => $bot_config['api_key'] ?? '',
100 'host' => $bot_config['host'] ?? '',
101 'namespace' => $bot_config['namespace'] ?? '',
102 'index_type' => (($bot_config['index_type'] ?? 'vector') === 'document') ? 'document' : 'vector',
103 'use_pinecone' => !empty($bot_config['use_pinecone']),
104 );
105 }
106 }
107 $options = get_option(self::OPTION, array());
108 if (!is_array($options)) {
109 $options = array();
110 }
111 return self::cfg_from_options($options);
112 }
113
114 /**
115 * Same shape as config(), from an options array (the KB screen passes those around).
116 */
117 public static function cfg_from_options(array $options) {
118 return array(
119 'api_key' => $options['mxchat_pinecone_api_key'] ?? '',
120 'host' => $options['mxchat_pinecone_host'] ?? '',
121 'namespace' => $options['mxchat_pinecone_namespace'] ?? '',
122 'index_type' => self::options_index_type($options),
123 'use_pinecone' => isset($options['mxchat_use_pinecone']) && (string) $options['mxchat_use_pinecone'] === '1',
124 );
125 }
126
127 /** Is this bot's Pinecone configuration a document index? */
128 public static function is_document_index($bot_id = 'default') {
129 $cfg = self::config($bot_id);
130 return $cfg['index_type'] === 'document' && !empty($cfg['host']) && !empty($cfg['api_key']);
131 }
132
133 /**
134 * Is this host the site's document index? For the delete helpers that
135 * receive bare credentials (no options array, no bot id).
136 */
137 public static function is_document_host($host) {
138 $host = strtolower(trim((string) $host, '/'));
139 if ($host === '') {
140 return false;
141 }
142 $options = get_option(self::OPTION, array());
143 $is = is_array($options)
144 && self::options_index_type($options) === 'document'
145 && strtolower(trim((string) ($options['mxchat_pinecone_host'] ?? ''), '/')) === $host;
146 return (bool) apply_filters('mxchat_pinecone_is_document_host', $is, $host);
147 }
148
149 /** The "processed content" row the Knowledge screens build per indexed post. */
150 public static function processed_row($vector_id, $url, array $metadata) {
151 $created_at = $metadata['created_at'] ?? '';
152 $processed_date = 'Recently';
153 $timestamp = current_time('timestamp');
154 if (!empty($created_at)) {
155 $ts = is_numeric($created_at) ? (int) $created_at : strtotime($created_at);
156 if ($ts) {
157 $timestamp = $ts;
158 $processed_date = human_time_diff($ts, current_time('timestamp')) . ' ago';
159 }
160 }
161 return array(
162 'db_id' => $vector_id,
163 'processed_date' => $processed_date,
164 'url' => $url,
165 'source' => 'pinecone',
166 'timestamp' => $timestamp,
167 );
168 }
169
170 /** Is this options array (mxchat_pinecone_addon_options shape) a document index? */
171 public static function is_document_options($options) {
172 if (!is_array($options)) {
173 return false;
174 }
175 $cfg = self::cfg_from_options($options);
176 return $cfg['index_type'] === 'document' && !empty($cfg['host']) && !empty($cfg['api_key']);
177 }
178
179 /** Namespace path segment: Pinecone addresses the default namespace as __default__. */
180 public static function namespace_path($namespace) {
181 $namespace = (string) $namespace;
182 return $namespace === '' ? '__default__' : rawurlencode($namespace);
183 }
184
185 /** Stats report the default namespace as "__default__" — the classic shape uses "". */
186 private static function namespace_stats_key($namespace) {
187 return (string) $namespace === '' ? '__default__' : (string) $namespace;
188 }
189
190 // =====================================================================
191 // Transport
192 // =====================================================================
193
194 /**
195 * One HTTP call with the document-API version header.
196 *
197 * @return array{code:int,data:mixed,raw:string,error:string}
198 */
199 public static function request($method, $url, $body, $api_key, $timeout = 30) {
200 $args = array(
201 'method' => $method,
202 'headers' => array(
203 'Api-Key' => $api_key,
204 'accept' => 'application/json',
205 'content-type' => 'application/json',
206 'X-Pinecone-Api-Version' => self::API_VERSION,
207 ),
208 'timeout' => $timeout,
209 );
210 if ($body !== null) {
211 $args['body'] = wp_json_encode($body);
212 }
213 $response = wp_remote_request($url, $args);
214 if (is_wp_error($response)) {
215 return array('code' => 0, 'data' => null, 'raw' => '', 'error' => $response->get_error_message());
216 }
217 $code = (int) wp_remote_retrieve_response_code($response);
218 $raw = (string) wp_remote_retrieve_body($response);
219 $data = $raw === '' ? array() : json_decode($raw, true);
220 $error = '';
221 if ($code < 200 || $code >= 300) {
222 $error = is_array($data) && isset($data['error']['message']) ? $data['error']['message']
223 : (is_array($data) && isset($data['message']) ? $data['message'] : substr($raw, 0, 300));
224 $error = 'Pinecone API error (HTTP ' . $code . '): ' . $error;
225 }
226 return array('code' => $code, 'data' => $data, 'raw' => $raw, 'error' => $error);
227 }
228
229 private static function data_url(array $cfg, $path) {
230 return 'https://' . $cfg['host'] . '/namespaces/' . self::namespace_path($cfg['namespace'] ?? '') . '/documents/' . ltrim($path, '/');
231 }
232
233 private static function require_cfg(array $cfg) {
234 if (empty($cfg['host']) || empty($cfg['api_key'])) {
235 return new WP_Error('pinecone_config', 'Pinecone is not properly configured (host or API key missing).');
236 }
237 return true;
238 }
239
240 // =====================================================================
241 // Document operations
242 // =====================================================================
243
244 /**
245 * Build one document from the pieces the classic upsert would have sent.
246 * Metadata keys travel 1:1 (except 'text', which is the schema field).
247 */
248 public static function build_document($id, $text, array $embedding, array $metadata) {
249 $doc = array('_id' => (string) $id);
250 foreach ($metadata as $key => $value) {
251 if ($key === self::TEXT_FIELD || $key === self::VECTOR_FIELD || $key === '_id') {
252 continue;
253 }
254 if (is_scalar($value) || $value === null) {
255 $doc[$key] = $value;
256 } elseif (is_array($value)) {
257 $doc[$key] = $value;
258 }
259 }
260 $doc[self::TEXT_FIELD] = (string) $text;
261 $doc[self::VECTOR_FIELD] = array_values(array_map('floatval', $embedding));
262 // 3e83e4: the normalised code set travels with every write (new content
263 // and copied records alike) so a part number typed without its
264 // separators can still reach the record — see index_codes().
265 $codes = self::index_codes((string) $text);
266 if (!empty($codes)) {
267 $doc[self::CODES_FIELD] = $codes;
268 } else {
269 unset($doc[self::CODES_FIELD]);
270 }
271 return $doc;
272 }
273
274 /**
275 * Upsert documents in batches. Same id → the document is replaced.
276 *
277 * @return true|WP_Error
278 */
279 public static function upsert(array $documents, array $cfg) {
280 $ok = self::require_cfg($cfg);
281 if (is_wp_error($ok)) {
282 return $ok;
283 }
284 if (empty($documents)) {
285 return true;
286 }
287 foreach (array_chunk(array_values($documents), self::UPSERT_BATCH) as $batch) {
288 $res = self::request('POST', self::data_url($cfg, 'upsert'), array('documents' => $batch), $cfg['api_key'], 60);
289 if ($res['error'] !== '') {
290 return new WP_Error('pinecone_api', $res['error']);
291 }
292 }
293 return true;
294 }
295
296 public static function upsert_one($id, $text, array $embedding, array $metadata, array $cfg) {
297 return self::upsert(array(self::build_document($id, $text, $embedding, $metadata)), $cfg);
298 }
299
300 /**
301 * Search. Returns matches normalized to the CLASSIC shape
302 * [{id, score, metadata:{…}}] so the grouping code can be shared.
303 *
304 * @return array|WP_Error
305 */
306 public static function search(array $score_by, $top_k, $filter, array $cfg, $include_fields = null) {
307 $ok = self::require_cfg($cfg);
308 if (is_wp_error($ok)) {
309 return $ok;
310 }
311 $top_k = max(1, min(10000, (int) $top_k));
312 $body = array(
313 'score_by' => $score_by,
314 'top_k' => $top_k,
315 'include_fields' => $include_fields === null ? self::RECORD_FIELDS : array_values($include_fields),
316 );
317 if (!empty($filter) && is_array($filter)) {
318 $body['filter'] = $filter;
319 }
320 $res = self::request('POST', self::data_url($cfg, 'search'), $body, $cfg['api_key'], 30);
321 if ($res['error'] !== '') {
322 return new WP_Error('pinecone_api', $res['error']);
323 }
324 $matches = array();
325 if (is_array($res['data']) && !empty($res['data']['matches'])) {
326 foreach ($res['data']['matches'] as $hit) {
327 $matches[] = self::normalize_hit($hit);
328 }
329 }
330 return $matches;
331 }
332
333 public static function search_dense(array $vector, $top_k, $filter, array $cfg, $include_fields = null) {
334 return self::search(
335 array(array('type' => 'dense_vector', 'fields' => array(self::VECTOR_FIELD), 'values' => array_values($vector))),
336 $top_k, $filter, $cfg, $include_fields
337 );
338 }
339
340 public static function search_text($query, $top_k, $filter, array $cfg, $include_fields = null) {
341 return self::search(
342 array(array('type' => 'text', 'fields' => array(self::TEXT_FIELD), 'query' => (string) $query)),
343 $top_k, $filter, $cfg, $include_fields
344 );
345 }
346
347 /**
348 * Fetch documents by id. Returns the classic /vectors/fetch shape:
349 * [id => ['id' => …, 'values' => [...] (only when asked), 'metadata' => [...]]].
350 */
351 public static function fetch(array $ids, array $cfg, $include_fields = null, $with_vector = false) {
352 $ok = self::require_cfg($cfg);
353 if (is_wp_error($ok) || empty($ids)) {
354 return array();
355 }
356 $fields = $include_fields === null ? self::RECORD_FIELDS : array_values($include_fields);
357 if ($with_vector && !in_array(self::VECTOR_FIELD, $fields, true)) {
358 $fields[] = self::VECTOR_FIELD;
359 }
360 $out = array();
361 foreach (array_chunk(array_values(array_unique(array_map('strval', $ids))), 1000) as $chunk) {
362 $res = self::request('POST', self::data_url($cfg, 'fetch'), array('ids' => $chunk, 'include_fields' => $fields), $cfg['api_key'], 30);
363 if ($res['error'] !== '' || !is_array($res['data']) || empty($res['data']['documents'])) {
364 continue;
365 }
366 foreach ($res['data']['documents'] as $doc_id => $doc) {
367 if (!is_array($doc)) {
368 continue;
369 }
370 $id = isset($doc['_id']) ? (string) $doc['_id'] : (string) $doc_id;
371 $row = array('id' => $id, 'metadata' => self::normalize_metadata($doc));
372 if (isset($doc[self::VECTOR_FIELD]) && is_array($doc[self::VECTOR_FIELD])) {
373 $row['values'] = $doc[self::VECTOR_FIELD];
374 }
375 $out[$id] = $row;
376 }
377 }
378 return $out;
379 }
380
381 /**
382 * One page of ids. @return array{ids:array,next:string}|WP_Error
383 */
384 public static function list_page($prefix, $limit, $token, array $cfg) {
385 $ok = self::require_cfg($cfg);
386 if (is_wp_error($ok)) {
387 return $ok;
388 }
389 $body = array('limit' => max(1, min(100, (int) $limit)));
390 if ((string) $prefix !== '') {
391 $body['prefix'] = (string) $prefix;
392 }
393 if ((string) $token !== '') {
394 $body['pagination_token'] = (string) $token;
395 }
396 $res = self::request('POST', self::data_url($cfg, 'list'), $body, $cfg['api_key'], 30);
397 if ($res['error'] !== '') {
398 return new WP_Error('pinecone_api', $res['error']);
399 }
400 $ids = array();
401 if (is_array($res['data']) && !empty($res['data']['documents'])) {
402 foreach ($res['data']['documents'] as $doc) {
403 if (isset($doc['_id'])) {
404 $ids[] = (string) $doc['_id'];
405 }
406 }
407 }
408 $next = is_array($res['data']) && isset($res['data']['pagination']['next']) ? (string) $res['data']['pagination']['next'] : '';
409 return array('ids' => $ids, 'next' => $next);
410 }
411
412 /** Every id under a prefix, following pagination (capped). */
413 public static function list_ids($prefix, array $cfg, $max = 10000) {
414 $ids = array();
415 $token = '';
416 do {
417 $page = self::list_page($prefix, 100, $token, $cfg);
418 if (is_wp_error($page)) {
419 break;
420 }
421 $ids = array_merge($ids, $page['ids']);
422 $token = $page['next'];
423 } while ($token !== '' && count($ids) < $max);
424 return $ids;
425 }
426
427 /** @return true|WP_Error */
428 public static function delete_ids(array $ids, array $cfg) {
429 $ok = self::require_cfg($cfg);
430 if (is_wp_error($ok)) {
431 return $ok;
432 }
433 $ids = array_values(array_unique(array_map('strval', $ids)));
434 if (empty($ids)) {
435 return true;
436 }
437 foreach (array_chunk($ids, 1000) as $chunk) {
438 $res = self::request('POST', self::data_url($cfg, 'delete'), array('ids' => $chunk), $cfg['api_key'], 60);
439 // 404 = the namespace does not exist yet — nothing to delete.
440 if ($res['error'] !== '' && $res['code'] !== 404) {
441 return new WP_Error('pinecone_api', $res['error']);
442 }
443 }
444 return true;
445 }
446
447 /** Delete every document in the namespace, or those matching a metadata filter. @return true|WP_Error */
448 public static function delete_all(array $cfg, $filter = null) {
449 $ok = self::require_cfg($cfg);
450 if (is_wp_error($ok)) {
451 return $ok;
452 }
453 $body = (!empty($filter) && is_array($filter)) ? array('filter' => $filter) : array('delete_all' => true);
454 $res = self::request('POST', self::data_url($cfg, 'delete'), $body, $cfg['api_key'], 60);
455 if ($res['error'] !== '' && $res['code'] !== 404) {
456 return new WP_Error('pinecone_api', $res['error']);
457 }
458 return true;
459 }
460
461 /**
462 * describe_index_stats, classic-shaped: namespaces keyed the classic way
463 * ('' for the default namespace), totalVectorCount, dimension.
464 *
465 * @return array|WP_Error
466 */
467 public static function stats(array $cfg) {
468 $ok = self::require_cfg($cfg);
469 if (is_wp_error($ok)) {
470 return $ok;
471 }
472 $res = self::request('POST', 'https://' . $cfg['host'] . '/describe_index_stats', new stdClass(), $cfg['api_key'], 15);
473 if ($res['error'] !== '' || !is_array($res['data'])) {
474 return new WP_Error('pinecone_api', $res['error'] !== '' ? $res['error'] : 'Empty stats response');
475 }
476 $namespaces = array();
477 if (!empty($res['data']['namespaces']) && is_array($res['data']['namespaces'])) {
478 foreach ($res['data']['namespaces'] as $name => $info) {
479 $key = ($name === '__default__') ? '' : (string) $name;
480 $namespaces[$key] = array('vectorCount' => (int) ($info['vectorCount'] ?? $info['recordCount'] ?? 0));
481 }
482 }
483 return array(
484 'namespaces' => $namespaces,
485 'totalVectorCount' => (int) ($res['data']['totalVectorCount'] ?? 0),
486 'dimension' => (int) ($res['data']['dimension'] ?? 0),
487 );
488 }
489
490 /** Record count for the configured namespace (0 when unknown / not listed). */
491 public static function count(array $cfg) {
492 $stats = self::stats($cfg);
493 if (is_wp_error($stats)) {
494 return 0;
495 }
496 $ns = (string) ($cfg['namespace'] ?? '');
497 if ($ns !== '') {
498 return isset($stats['namespaces'][$ns]['vectorCount']) ? (int) $stats['namespaces'][$ns]['vectorCount'] : 0;
499 }
500 return (int) $stats['totalVectorCount'];
501 }
502
503 /**
504 * Reassemble a chunked entry's text from its <md5>_chunk_N documents —
505 * the document-index twin of the integrator's reassemble_chunks_from_pinecone().
506 */
507 public static function reassemble_chunks($source_url, array $cfg, $max_chunks = 0, &$chunk_count = 0) {
508 $chunk_count = 0;
509 if (empty($cfg['host']) || empty($cfg['api_key'])) {
510 return '';
511 }
512 $ids = self::list_ids(md5($source_url) . '_chunk_', $cfg, 500);
513 if (empty($ids)) {
514 return '';
515 }
516 $docs = self::fetch($ids, $cfg, array(self::TEXT_FIELD, 'chunk_index'));
517 if (empty($docs)) {
518 return '';
519 }
520 $chunks = array();
521 foreach ($docs as $doc) {
522 $chunks[(int) ($doc['metadata']['chunk_index'] ?? 0)] = (string) ($doc['metadata']['text'] ?? '');
523 }
524 ksort($chunks);
525 if ($max_chunks > 0 && count($chunks) > $max_chunks) {
526 $chunks = array_slice($chunks, 0, $max_chunks, true);
527 }
528 $chunk_count = count($chunks);
529 return implode("\n\n", $chunks);
530 }
531
532 /** Flat search hit → classic {id, score, metadata}. */
533 public static function normalize_hit(array $hit) {
534 $id = isset($hit['_id']) ? (string) $hit['_id'] : '';
535 $score = isset($hit['_score']) ? (float) $hit['_score'] : 0.0;
536 $row = array('id' => $id, 'score' => $score, 'metadata' => self::normalize_metadata($hit));
537 if (isset($hit[self::VECTOR_FIELD]) && is_array($hit[self::VECTOR_FIELD])) {
538 $row['values'] = $hit[self::VECTOR_FIELD];
539 }
540 return $row;
541 }
542
543 /** Document fields → the metadata array the classic code reads (ints where it expects ints). */
544 public static function normalize_metadata(array $fields) {
545 $meta = array();
546 foreach ($fields as $key => $value) {
547 if ($key === '_id' || $key === '_score' || $key === self::VECTOR_FIELD) {
548 continue;
549 }
550 $meta[$key] = $value;
551 }
552 foreach (array('chunk_index', 'total_chunks', 'created_at', 'last_updated') as $int_key) {
553 if (isset($meta[$int_key]) && is_numeric($meta[$int_key])) {
554 $meta[$int_key] = (int) $meta[$int_key];
555 }
556 }
557 if (isset($meta['is_chunked'])) {
558 $meta['is_chunked'] = filter_var($meta['is_chunked'], FILTER_VALIDATE_BOOLEAN);
559 }
560 return $meta;
561 }
562
563 /** Classic {id, score, metadata} rows → the KB screen's record objects. */
564 public static function records_from_matches(array $matches, $bot_id = 'default', $content_type = '') {
565 $records = array();
566 foreach ($matches as $match) {
567 $metadata = $match['metadata'] ?? array();
568 if ($content_type !== '' && (($metadata['type'] ?? 'content') !== $content_type)) {
569 continue;
570 }
571 $created_at = $metadata['created_at'] ?? $metadata['last_updated'] ?? time();
572 if (!is_numeric($created_at)) {
573 $created_at = strtotime($created_at) ?: time();
574 }
575 $records[] = (object) array(
576 'id' => $match['id'] ?? '',
577 'article_content' => $metadata['text'] ?? '',
578 'source_url' => $metadata['source_url'] ?? '',
579 'role_restriction' => $metadata['role_restriction'] ?? 'public',
580 'type' => $metadata['type'] ?? 'content',
581 'bot_id' => $bot_id,
582 'created_at' => (int) $created_at,
583 'data_source' => 'pinecone',
584 'relevance_score' => $match['score'] ?? 0,
585 'chunk_index' => isset($metadata['chunk_index']) ? (int) $metadata['chunk_index'] : null,
586 'total_chunks' => isset($metadata['total_chunks']) ? (int) $metadata['total_chunks'] : null,
587 'is_chunked' => !empty($metadata['is_chunked']),
588 );
589 }
590 return $records;
591 }
592
593 /**
594 * The KB screen listing on a document index (twin of
595 * MxChat_Pinecone_Manager::mxchat_fetch_pinecone_records, cursor-paged).
596 * With a search query the BM25 index answers it — no embedding call.
597 */
598 public static function fetch_records(array $pinecone_options, $search_query, $page, $per_page, $bot_id, $content_type) {
599 $cfg = self::cfg_from_options($pinecone_options);
600 $page = max(1, (int) $page);
601 $per_page = max(1, (int) $per_page);
602 $empty = array('data' => array(), 'total' => 0, 'total_in_database' => 0, 'showing_recent_only' => false, 'has_next' => false);
603 if (empty($cfg['host']) || empty($cfg['api_key'])) {
604 return $empty;
605 }
606 $total_in_database = self::count($cfg);
607 $type_filter = $content_type !== '' ? array('type' => array('$eq' => (string) $content_type)) : null;
608
609 if (trim((string) $search_query) !== '') {
610 $limit = min(($page * $per_page) + 100, 500);
611 $matches = self::search_text($search_query, $limit, $type_filter, $cfg);
612 if (is_wp_error($matches)) {
613 $matches = array();
614 }
615 $records = self::records_from_matches($matches, $bot_id, '');
616 $offset = ($page - 1) * $per_page;
617 return array(
618 'data' => array_slice($records, $offset, $per_page),
619 'total' => count($records),
620 'total_in_database' => $total_in_database,
621 'showing_recent_only' => false,
622 'has_next' => ($offset + $per_page) < count($records),
623 );
624 }
625
626 // Browse: one cursor step per page (plan dd6e10's rule), tokens kept per page.
627 $limit = $content_type !== '' ? $per_page * 2 : $per_page;
628 $pagination_key = 'mxchat_pinecone_page_' . md5($cfg['host'] . $cfg['namespace'] . $content_type . '_docs');
629 $token = '';
630 if ($page > 1) {
631 $stored = get_transient($pagination_key);
632 if ($stored && isset($stored[$page])) {
633 $token = $stored[$page];
634 }
635 }
636 $listing = self::list_page('', $limit, $token, $cfg);
637 if (is_wp_error($listing)) {
638 error_log('MxChat Pinecone (document index): documents/list failed — ' . $listing->get_error_message());
639 $empty['total_in_database'] = $total_in_database;
640 return $empty;
641 }
642 $has_next = $listing['next'] !== '';
643 if ($has_next) {
644 $stored = get_transient($pagination_key) ?: array();
645 $stored[$page + 1] = $listing['next'];
646 set_transient($pagination_key, $stored, 300);
647 }
648 $records = array();
649 if (!empty($listing['ids'])) {
650 $docs = self::fetch($listing['ids'], $cfg);
651 $matches = array();
652 foreach ($docs as $doc) {
653 $matches[] = array('id' => $doc['id'], 'score' => 0, 'metadata' => $doc['metadata']);
654 }
655 $records = self::records_from_matches($matches, $bot_id, (string) $content_type);
656 usort($records, function ($a, $b) {
657 return $b->created_at <=> $a->created_at;
658 });
659 $records = array_slice($records, 0, $per_page);
660 }
661 return array(
662 'data' => $records,
663 'total' => $total_in_database,
664 'total_in_database' => $total_in_database,
665 'showing_recent_only' => false,
666 'count_unit' => 'vectors',
667 'cursor_pagination' => true,
668 'has_next' => $has_next,
669 );
670 }
671
672 // =====================================================================
673 // Retrieval helpers (hybrid keyword leg)
674 // =====================================================================
675
676 /**
677 * Code-like tokens in a question: runs that mix letters and digits, or
678 * hyphen / dot joined codes (HGH25CA, SR25W, R1621-314-20, ISO-9001-2015).
679 * Plain numbers and plain words are not codes. Minimum 4 characters.
680 */
681 public static function extract_code_tokens($text) {
682 $tokens = array();
683 if (!preg_match_all('/(?<![A-Za-z0-9])([A-Za-z0-9]+(?:[.\-][A-Za-z0-9]+)*)(?![A-Za-z0-9])/u', (string) $text, $m)) {
684 return $tokens;
685 }
686 foreach ($m[1] as $candidate) {
687 $candidate = rtrim($candidate, '.');
688 if (strlen($candidate) < 4 || !preg_match('/\d/', $candidate)) {
689 continue;
690 }
691 $has_letter = (bool) preg_match('/[A-Za-z]/', $candidate);
692 $separators = preg_match_all('/[.\-]/', $candidate);
693 if (!$has_letter && $separators < 2) {
694 continue; // 2024, 26.5, 10-20
695 }
696 $tokens[strtoupper($candidate)] = $candidate;
697 }
698 return array_values($tokens);
699 }
700
701 /**
702 * Text-match filter that requires at least one of the codes. Hyphenated or
703 * dotted codes are phrase-matched (the tokenizer splits them, and "20" on
704 * its own would match everything); single runs use $match_any.
705 *
706 * 3e83e4: a second leg matches the NORMALISED code set stored on each
707 * document (codes $in …), which is what lets "R165321320" reach a record
708 * that only ever says "R1653" — see query_codes() for the expansion.
709 */
710 public static function keyword_filter(array $tokens) {
711 $clauses = array();
712 foreach ($tokens as $token) {
713 $op = preg_match('/[.\-]/', $token) ? '$match_phrase' : '$match_any';
714 $clauses[] = array(self::TEXT_FIELD => array($op => $token));
715 }
716 $query_codes = self::query_codes($tokens);
717 if (!empty($query_codes)) {
718 $clauses[] = array(self::CODES_FIELD => array('$in' => $query_codes));
719 }
720 if (empty($clauses)) {
721 return null;
722 }
723 return count($clauses) === 1 ? $clauses[0] : array('$or' => $clauses);
724 }
725
726 /** Upper-case, separators stripped: "R1653 213 20" → "R165321320", "hgh25ca" → "HGH25CA". */
727 public static function normalize_code($code) {
728 return strtoupper(preg_replace('/[\s.\-_\/]+/u', '', (string) $code));
729 }
730
731 /**
732 * Code-like runs in a piece of text, INCLUDING digit groups that follow a
733 * code separated by a space, hyphen or dot ("R1653 213 20" is one number
734 * the way Rexroth prints it). Returns [ [head, full], … ] where head is
735 * the first run and full is the whole joined run, both un-normalised.
736 */
737 private static function code_runs($text) {
738 $runs = array();
739 $text = (string) $text;
740 $pattern = '/(?<![A-Za-z0-9])([A-Za-z0-9]+(?:[.\-][A-Za-z0-9]+)*)((?:[ .\-]\d{2,6}(?![A-Za-z0-9])){1,3})?(?![A-Za-z0-9])/u';
741 $offset = 0;
742 $length = strlen($text);
743 // 0d7bd4: walked one match at a time. Only a head that IS a code keeps
744 // its trailing digit groups; a plain word ("part 4471-AB-12") used to
745 // take "4471" as its tail and the digit-led number was never stored.
746 while ($offset < $length && preg_match($pattern, $text, $match, PREG_OFFSET_CAPTURE, $offset)) {
747 $head = rtrim($match[1][0], '.');
748 $head_end = $match[1][1] + strlen($match[1][0]);
749 $is_code = strlen($head) >= self::CODE_MIN_LEN && preg_match('/\d/', $head);
750 if ($is_code) {
751 $has_letter = (bool) preg_match('/[A-Za-z]/', $head);
752 $separators = preg_match_all('/[.\-]/', $head);
753 if (!$has_letter && $separators < 2) {
754 $is_code = false; // 2024, 26.5, 10-20
755 }
756 }
757 if (!$is_code) {
758 $offset = $head_end;
759 continue;
760 }
761 $tail = isset($match[2]) && $match[2][1] >= 0 ? $match[2][0] : '';
762 $runs[] = array($head, $head . $tail);
763 $offset = $match[0][1] + strlen($match[0][0]);
764 }
765 return $runs;
766 }
767
768 /**
769 * The normalised token set stored on a document at write time: for every
770 * code-like run in the text, the separator-stripped form, the leading stem
771 * (the run before the first separator) and the letters+digits stem
772 * ("HGH25" of "HGH25CA"). Unique, minimum 4 characters, capped.
773 */
774 public static function index_codes($text) {
775 $codes = array();
776 foreach (self::code_runs($text) as $run) {
777 list($head, $full) = $run;
778 $candidates = array(self::normalize_code($full), self::normalize_code($head));
779 $stem = preg_split('/[.\-\s]/', $head)[0];
780 $candidates[] = self::normalize_code($stem);
781 if (preg_match('/^([A-Za-z]+\d+)/', $head, $sm)) {
782 $candidates[] = self::normalize_code($sm[1]);
783 }
784 foreach ($candidates as $c) {
785 if (strlen($c) >= self::CODE_MIN_LEN && strlen($c) <= self::CODE_MAX_LEN && preg_match('/\d/', $c)) {
786 $codes[$c] = true;
787 }
788 }
789 if (count($codes) >= self::CODES_MAX) {
790 break;
791 }
792 }
793 // 0d7bd4: strval — PHP turns an all-digit array key into an int, and
794 // Pinecone refuses a list that mixes strings and numbers.
795 return array_slice(array_map('strval', array_keys($codes)), 0, self::CODES_MAX);
796 }
797
798 /**
799 * What a question's tokens expand to for the codes $in filter: the
800 * stripped form, every prefix of it from 4 characters up (a stored stem
801 * that is a prefix of what the visitor typed then matches — "R1653" for
802 * "R165321320") and the letters+digits stem. Plain-language questions
803 * carry no tokens and return nothing, so they are unchanged.
804 */
805 public static function query_codes(array $tokens) {
806 $codes = array();
807 foreach ($tokens as $token) {
808 $full = self::normalize_code($token);
809 if (strlen($full) < self::CODE_MIN_LEN || !preg_match('/\d/', $full)) {
810 continue;
811 }
812 $full = substr($full, 0, self::CODE_MAX_LEN);
813 $codes[$full] = true;
814 for ($len = self::CODE_MIN_LEN; $len < strlen($full); $len++) {
815 $codes[substr($full, 0, $len)] = true;
816 }
817 if (preg_match('/^([A-Z]+\d+)/', $full, $sm) && strlen($sm[1]) >= self::CODE_MIN_LEN) {
818 $codes[$sm[1]] = true;
819 }
820 if (count($codes) >= 200) {
821 break;
822 }
823 }
824 return array_slice(array_map('strval', array_keys($codes)), 0, 200);
825 }
826
827 /**
828 * Order keyword-leg hits so an exact code match outranks a prefix-only
829 * match: a hit is exact when its stored code set (or its text) carries the
830 * stripped question token itself. Dense order is kept inside each group.
831 *
832 * @return array{hits:array,exact:int,prefix:int}
833 */
834 public static function rank_keyword_hits(array $hits, array $tokens) {
835 $exact_codes = array();
836 foreach ($tokens as $token) {
837 $n = self::normalize_code($token);
838 if (strlen($n) >= self::CODE_MIN_LEN) {
839 $exact_codes[$n] = true;
840 }
841 }
842 $exact = array();
843 $prefix = array();
844 foreach ($hits as $hit) {
845 $meta = is_array($hit['metadata'] ?? null) ? $hit['metadata'] : array();
846 $stored = is_array($meta[self::CODES_FIELD] ?? null) ? $meta[self::CODES_FIELD] : array();
847 $is_exact = false;
848 foreach ($stored as $code) {
849 if (isset($exact_codes[strtoupper((string) $code)])) {
850 $is_exact = true;
851 break;
852 }
853 }
854 if (!$is_exact && !empty($tokens) && isset($meta[self::TEXT_FIELD])) {
855 $haystack = self::normalize_code($meta[self::TEXT_FIELD]);
856 foreach ($exact_codes as $code => $_) {
857 if ($haystack !== '' && strpos($haystack, $code) !== false) {
858 $is_exact = true;
859 break;
860 }
861 }
862 }
863 $hit['code_match'] = $is_exact ? 'exact' : 'prefix';
864 if ($is_exact) {
865 $exact[] = $hit;
866 } else {
867 $prefix[] = $hit;
868 }
869 }
870 return array('hits' => array_merge($exact, $prefix), 'exact' => count($exact), 'prefix' => count($prefix));
871 }
872
873 /** AND two filters (either may be empty). */
874 public static function merge_filters($a, $b) {
875 $a = (!empty($a) && is_array($a)) ? $a : null;
876 $b = (!empty($b) && is_array($b)) ? $b : null;
877 if ($a === null) {
878 return $b;
879 }
880 if ($b === null) {
881 return $a;
882 }
883 return array('$and' => array($a, $b));
884 }
885
886 /**
887 * Translate the classic query body (what the mxchat_pinecone_query_body
888 * filter sees) into what a document search needs: topK → top_k, filter → filter.
889 *
890 * @return array{top_k:int,filter:array|null}
891 */
892 public static function translate_classic_body(array $body, $default_top_k) {
893 $top_k = isset($body['topK']) ? absint($body['topK']) : 0;
894 if ($top_k < 1 || $top_k > 1000) {
895 $top_k = $default_top_k;
896 }
897 $filter = (!empty($body['filter']) && is_array($body['filter'])) ? $body['filter'] : null;
898 return array('top_k' => $top_k, 'filter' => $filter);
899 }
900
901 // =====================================================================
902 // Control plane
903 // =====================================================================
904
905 private static function control_request($method, $path, $body, $api_key) {
906 return self::request($method, 'https://api.pinecone.io/' . ltrim($path, '/'), $body, $api_key, 30);
907 }
908
909 /** @return array|WP_Error the IndexModel (2026-07 shape, with schema) */
910 public static function describe_index($name, $api_key) {
911 $name = sanitize_text_field($name);
912 if ($name === '') {
913 return new WP_Error('pinecone_index', 'Index name is empty.');
914 }
915 $res = self::control_request('GET', 'indexes/' . rawurlencode($name), null, $api_key);
916 if ($res['error'] !== '' || !is_array($res['data'])) {
917 return new WP_Error('pinecone_api', $res['error'] !== '' ? $res['error'] : 'Empty describe response', array('code' => $res['code']));
918 }
919 return $res['data'];
920 }
921
922 /** Find an index in the project by its host. @return array|WP_Error */
923 public static function find_index_by_host($host, $api_key) {
924 $host = strtolower(trim(str_replace(array('https://', 'http://'), '', (string) $host), '/'));
925 if ($host === '') {
926 return new WP_Error('pinecone_index', 'Host is empty.');
927 }
928 $res = self::control_request('GET', 'indexes', null, $api_key);
929 if ($res['error'] !== '' || !is_array($res['data'])) {
930 return new WP_Error('pinecone_api', $res['error'] !== '' ? $res['error'] : 'Could not list indexes');
931 }
932 foreach (($res['data']['indexes'] ?? array()) as $index) {
933 if (isset($index['host']) && strtolower($index['host']) === $host) {
934 return $index;
935 }
936 }
937 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));
938 }
939
940 /**
941 * Read a described index's schema. Classic vector indexes carry the
942 * reserved _values / _sparse_values fields; a document index names its own.
943 */
944 public static function inspect_schema(array $index) {
945 $out = array('is_document' => false, 'text_field' => '', 'dense_field' => '', 'dimension' => 0, 'metric' => '', 'language' => '');
946 $fields = $index['schema']['fields'] ?? array();
947 if (!is_array($fields)) {
948 return $out;
949 }
950 foreach ($fields as $name => $spec) {
951 if (!is_array($spec)) {
952 continue;
953 }
954 if (($spec['type'] ?? '') === 'dense_vector' && strpos($name, '_') !== 0) {
955 $out['dense_field'] = (string) $name;
956 $out['dimension'] = (int) ($spec['dimension'] ?? 0);
957 $out['metric'] = (string) ($spec['metric'] ?? '');
958 }
959 if (($spec['type'] ?? '') === 'string' && !empty($spec['full_text_search']) && $out['text_field'] === '') {
960 $out['text_field'] = (string) $name;
961 $out['language'] = (string) ($spec['full_text_search']['language'] ?? '');
962 }
963 }
964 $out['is_document'] = $out['dense_field'] !== '' && $out['text_field'] !== '';
965 return $out;
966 }
967
968 /**
969 * Assert an index is a document index MxChat can use. Refuses with the
970 * exact mismatch otherwise.
971 *
972 * @return array{ok:bool,message:string,host:string,name:string,dimension:int,expected:int}
973 */
974 public static function check_document_index($host, $api_key, $expected_dimension) {
975 $index = self::find_index_by_host($host, $api_key);
976 if (is_wp_error($index)) {
977 return array('ok' => false, 'message' => $index->get_error_message(), 'host' => (string) $host, 'name' => '', 'dimension' => 0, 'expected' => (int) $expected_dimension);
978 }
979 $name = (string) ($index['name'] ?? '');
980 $schema = self::inspect_schema($index);
981 $base = array('host' => (string) ($index['host'] ?? $host), 'name' => $name, 'dimension' => $schema['dimension'], 'expected' => (int) $expected_dimension);
982 if (!$schema['is_document']) {
983 $fields = is_array($index['schema']['fields'] ?? null) ? implode(', ', array_keys($index['schema']['fields'])) : 'none';
984 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));
985 }
986 if ($schema['text_field'] !== self::TEXT_FIELD || $schema['dense_field'] !== self::VECTOR_FIELD) {
987 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));
988 }
989 if ((int) $schema['dimension'] !== (int) $expected_dimension) {
990 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));
991 }
992 if (empty($index['status']['ready'])) {
993 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')));
994 }
995 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'));
996 }
997
998 /**
999 * The plain-language "that name is taken" message (3e83e4) — never a bare
1000 * HTTP 409. Carries a free suggestion so the UI can fill it in.
1001 */
1002 public static function name_taken_message($name, $suggested = '') {
1003 $message = sprintf(
1004 /* translators: %s: Pinecone index name */
1005 __('An index named "%s" already exists in this Pinecone project. Pick a different name, or paste that index\'s host above and press Check index instead of creating one.', 'mxchat'),
1006 $name
1007 );
1008 if ($suggested !== '') {
1009 /* translators: %s: suggested Pinecone index name */
1010 $message .= ' ' . sprintf(__('Suggested free name: %s.', 'mxchat'), $suggested);
1011 }
1012 return $message;
1013 }
1014
1015 /**
1016 * A free index name derived from the current one: <base>-docs, then
1017 * <base>-docs-2, -3 … (an existing -docs suffix is not doubled). Lists the
1018 * project's indexes once. @return array{name:string,taken:bool,existing:array}
1019 */
1020 public static function suggest_index_name($base, $api_key) {
1021 $base = strtolower(sanitize_text_field($base));
1022 $base = preg_replace('/[^a-z0-9-]+/', '-', $base);
1023 $base = trim(preg_replace('/-{2,}/', '-', $base), '-');
1024 $base = preg_replace('/-docs(-\d+)?$/', '', $base);
1025 if ($base === '') {
1026 $base = 'mxchat';
1027 }
1028 $existing = array();
1029 $res = self::control_request('GET', 'indexes', null, $api_key);
1030 if ($res['error'] === '' && is_array($res['data'])) {
1031 foreach (($res['data']['indexes'] ?? array()) as $index) {
1032 if (isset($index['name'])) {
1033 $existing[strtolower((string) $index['name'])] = true;
1034 }
1035 }
1036 }
1037 $stem = substr($base, 0, 45 - strlen('-docs-99'));
1038 $stem = rtrim($stem, '-');
1039 $candidate = $stem . '-docs';
1040 for ($n = 2; isset($existing[$candidate]) && $n < 100; $n++) {
1041 $candidate = $stem . '-docs-' . $n;
1042 }
1043 return array('name' => $candidate, 'taken' => isset($existing[$base]), 'existing' => array_keys($existing));
1044 }
1045
1046 /**
1047 * Create a document index for the active embedding dimension.
1048 * @return array|WP_Error the IndexModel
1049 */
1050 public static function create_document_index($name, $dimension, $cloud, $region, $language, $api_key) {
1051 $name = strtolower(sanitize_text_field($name));
1052 if (!preg_match('/^[a-z0-9]([a-z0-9-]{0,43}[a-z0-9])?$/', $name)) {
1053 return new WP_Error('pinecone_index', 'Index names are 1-45 lowercase letters, digits and hyphens.');
1054 }
1055 // 3e83e4: say so in plain words BEFORE Pinecone can answer 409 — the
1056 // name field is prefilled with the vector index being migrated away
1057 // from, so the first click used to collide for exactly that customer.
1058 $taken = self::describe_index($name, $api_key);
1059 if (!is_wp_error($taken)) {
1060 $suggest = self::suggest_index_name($name, $api_key);
1061 return new WP_Error('pinecone_exists', self::name_taken_message($name, $suggest['name']), array('code' => 409, 'suggested' => $suggest['name']));
1062 }
1063 $cloud = array_key_exists($cloud, self::clouds()) ? $cloud : 'aws';
1064 $region = sanitize_text_field($region) ?: 'us-east-1';
1065 $language = array_key_exists($language, self::languages()) ? $language : 'en';
1066 $dimension = (int) $dimension;
1067 if ($dimension < 1 || $dimension > 20000) {
1068 return new WP_Error('pinecone_index', 'Embedding dimension is out of range.');
1069 }
1070 $body = array(
1071 'name' => $name,
1072 'deployment' => array('deployment_type' => 'managed', 'cloud' => $cloud, 'region' => $region),
1073 'schema' => array('fields' => array(
1074 self::TEXT_FIELD => array('type' => 'string', 'full_text_search' => array('language' => $language)),
1075 self::VECTOR_FIELD => array('type' => 'dense_vector', 'dimension' => $dimension, 'metric' => 'cosine'),
1076 )),
1077 );
1078 $res = self::control_request('POST', 'indexes', $body, $api_key);
1079 if ($res['code'] === 409) {
1080 $suggest = self::suggest_index_name($name, $api_key);
1081 return new WP_Error('pinecone_exists', self::name_taken_message($name, $suggest['name']), array('code' => 409, 'suggested' => $suggest['name']));
1082 }
1083 if ($res['error'] !== '' || !is_array($res['data'])) {
1084 return new WP_Error('pinecone_api', $res['error'] !== '' ? $res['error'] : 'Empty create response', array('code' => $res['code']));
1085 }
1086 return $res['data'];
1087 }
1088
1089 /** @return true|WP_Error */
1090 public static function delete_index($name, $api_key) {
1091 $name = sanitize_text_field($name);
1092 if ($name === '') {
1093 return new WP_Error('pinecone_index', 'Index name is empty.');
1094 }
1095 $res = self::control_request('DELETE', 'indexes/' . rawurlencode($name), null, $api_key);
1096 if ($res['error'] !== '' && $res['code'] !== 404) {
1097 return new WP_Error('pinecone_api', $res['error']);
1098 }
1099 return true;
1100 }
1101
1102 /** Dimension of the active embedding model (same table the KB screen uses). */
1103 public static function expected_dimension() {
1104 $dimension = self::expected_dimension_checked();
1105 return is_wp_error($dimension) ? 0 : (int) $dimension;
1106 }
1107
1108 /**
1109 * 418afd: the dimension the site's embeddings really have, or why it is
1110 * not known. A Custom Provider embedding model is asked for its size
1111 * (MxChat_Utils::expected_embedding_dimension()); the standard dropdown is
1112 * disabled on such a site and used to size the index anyway. The filter
1113 * still has the last word, and can supply a number when the model could
1114 * not be asked.
1115 *
1116 * @param bool $force Ask a custom model again even after a recent failure (the card's buttons).
1117 * @return int|WP_Error
1118 */
1119 public static function expected_dimension_checked($force = false) {
1120 $options = get_option('mxchat_options', array());
1121 $options = is_array($options) ? $options : array();
1122 $custom = isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on';
1123 $selected_model = $custom
1124 ? MxChat_Utils::get_selected_embedding_model($options)
1125 : ($options['embedding_model'] ?? 'text-embedding-ada-002');
1126 $dimension = MxChat_Utils::expected_embedding_dimension($options, $force);
1127 if (is_wp_error($dimension)) {
1128 $supplied = (int) apply_filters('mxchat_pinecone_expected_dimension', 0, $selected_model);
1129 return $supplied > 0 ? $supplied : $dimension;
1130 }
1131 if (!$custom && (strpos($selected_model, 'voyage-3-large') === 0 || strpos($selected_model, 'gemini-embedding') === 0)) {
1132 return (int) $dimension; // adjustable-size models: the owner's own setting, never filtered (as before)
1133 }
1134 return (int) apply_filters('mxchat_pinecone_expected_dimension', $dimension, $selected_model);
1135 }
1136
1137 /** Plain-language refusal for the card when a custom model's size is unknown. (Plain __(): the card's JS text-escapes it.) */
1138 public static function dimension_unknown_message(WP_Error $error, $action) {
1139 $reason = trim($error->get_error_message());
1140 $tail = $action === 'create'
1141 ? __('The index was not created.', 'mxchat')
1142 : __('The index was not checked.', 'mxchat');
1143 return sprintf(
1144 /* translators: 1: the error the embedding endpoint returned, 2: what did not happen */
1145 __('Could not determine the dimension of your Custom Provider embedding model (%1$s). %2$s Check the Custom Provider embedding settings, then try again.', 'mxchat'),
1146 $reason !== '' ? $reason : __('no answer from the embedding endpoint', 'mxchat'),
1147 $tail
1148 );
1149 }
1150
1151 // =====================================================================
1152 // Options (raw writes — the registered sanitizer rebuilds the array and
1153 // would flip mxchat_use_pinecone on any isset key; the autosave handler
1154 // writes this option the same raw way for the same reason)
1155 // =====================================================================
1156
1157 public static function persist_options(array $changes) {
1158 global $wpdb;
1159 $raw = $wpdb->get_var($wpdb->prepare("SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", self::OPTION));
1160 $current = $raw === null ? array() : maybe_unserialize($raw);
1161 if (!is_array($current)) {
1162 $current = array();
1163 }
1164 foreach ($changes as $key => $value) {
1165 $current[$key] = $value;
1166 }
1167 $serialized = maybe_serialize($current);
1168 if ($raw === null) {
1169 $wpdb->insert($wpdb->options, array('option_name' => self::OPTION, 'option_value' => $serialized, 'autoload' => 'off'), array('%s', '%s', '%s'));
1170 } else {
1171 $wpdb->update($wpdb->options, array('option_value' => $serialized), array('option_name' => self::OPTION), array('%s'), array('%s'));
1172 }
1173 wp_cache_delete(self::OPTION, 'options');
1174 wp_cache_delete('alloptions', 'options');
1175 return $current;
1176 }
1177
1178 // =====================================================================
1179 // Copy-migration (classic vector index → document index, no embedding calls)
1180 // =====================================================================
1181
1182 public static function migration_get() {
1183 $state = get_transient(self::MIGRATION_KEY);
1184 return is_array($state) ? $state : array();
1185 }
1186
1187 public static function migration_set(array $state) {
1188 $state['updated'] = time();
1189 set_transient(self::MIGRATION_KEY, $state, DAY_IN_SECONDS);
1190 return $state;
1191 }
1192
1193 public static function migration_clear() {
1194 delete_transient(self::MIGRATION_KEY);
1195 }
1196
1197 /**
1198 * 3e83e4: is there an unfinished copy INTO this host? The settings save
1199 * and the autosave refuse to switch the live chatbot onto a document index
1200 * while its copy is still running (unless the owner ticks Switch anyway),
1201 * so a production bot never answers from a half-filled index.
1202 *
1203 * @return array|null the running state, or null when nothing blocks
1204 */
1205 public static function migration_blocks_switch($host) {
1206 $host = strtolower(trim(str_replace(array('https://', 'http://'), '', (string) $host), '/'));
1207 $state = self::migration_get();
1208 if ($host === '' || empty($state) || ($state['status'] ?? '') !== 'running') {
1209 return null;
1210 }
1211 if (strtolower((string) ($state['target_host'] ?? '')) !== $host) {
1212 return null;
1213 }
1214 return $state;
1215 }
1216
1217 /** Plain-language line for the switch gate: "The copy into X is still running (481 of 4,503 records)…". */
1218 public static function switch_blocked_message(array $state) {
1219 return sprintf(
1220 /* translators: 1: document index name, 2: records copied, 3: records in total */
1221 __('The copy into "%1$s" is still running (%2$s of %3$s records). Let it finish before switching the chatbot to the document index, or tick Switch anyway to move it now and copy the rest afterwards.', 'mxchat'),
1222 (string) ($state['target_name'] ?: $state['target_host']),
1223 number_format_i18n((int) ($state['copied'] ?? 0)),
1224 number_format_i18n((int) ($state['total'] ?? 0))
1225 );
1226 }
1227
1228 /**
1229 * Run one Pinecone call up to MIGRATE_RETRIES times, 1 s / 2 s / 4 s apart,
1230 * so a single dropped connection or a 5xx does not end the copy.
1231 *
1232 * @param callable $call returns array|true|WP_Error
1233 * @return array{result:mixed,attempts:int}
1234 */
1235 private static function with_retry(callable $call) {
1236 $result = null;
1237 $attempts = 0;
1238 for ($attempt = 1; $attempt <= self::MIGRATE_RETRIES; $attempt++) {
1239 $attempts = $attempt;
1240 $result = call_user_func($call);
1241 if (!is_wp_error($result)) {
1242 return array('result' => $result, 'attempts' => $attempts);
1243 }
1244 if ($attempt < self::MIGRATE_RETRIES) {
1245 sleep((int) pow(2, $attempt - 1));
1246 }
1247 }
1248 return array('result' => $result, 'attempts' => $attempts);
1249 }
1250
1251 /** Seconds one AJAX step may spend copying pages (bounded by PHP's own limit). */
1252 private static function step_budget() {
1253 $budget = (float) apply_filters('mxchat_pinecone_migrate_step_seconds', self::MIGRATE_STEP_SECONDS);
1254 $limit = (int) ini_get('max_execution_time');
1255 if ($limit > 0 && $limit - 8 < $budget) {
1256 $budget = max(3, $limit - 8);
1257 }
1258 return $budget;
1259 }
1260
1261 /** Classic GET /vectors/list page. @return array{ids:array,next:string}|WP_Error */
1262 public static function classic_list_page($host, $api_key, $namespace, $limit, $token) {
1263 $params = array('limit' => max(1, min(100, (int) $limit)));
1264 if ((string) $namespace !== '') {
1265 $params['namespace'] = (string) $namespace;
1266 }
1267 if ((string) $token !== '') {
1268 $params['paginationToken'] = (string) $token;
1269 }
1270 $response = wp_remote_get('https://' . $host . '/vectors/list?' . http_build_query($params), array(
1271 'headers' => array('Api-Key' => $api_key, 'accept' => 'application/json'),
1272 'timeout' => 30,
1273 ));
1274 if (is_wp_error($response)) {
1275 return new WP_Error('pinecone_api', $response->get_error_message());
1276 }
1277 $code = (int) wp_remote_retrieve_response_code($response);
1278 $data = json_decode((string) wp_remote_retrieve_body($response), true);
1279 if ($code !== 200 || !is_array($data)) {
1280 return new WP_Error('pinecone_api', 'Source index list failed (HTTP ' . $code . '): ' . substr((string) wp_remote_retrieve_body($response), 0, 200));
1281 }
1282 $ids = array();
1283 foreach (($data['vectors'] ?? array()) as $vector) {
1284 if (isset($vector['id'])) {
1285 $ids[] = (string) $vector['id'];
1286 }
1287 }
1288 return array('ids' => $ids, 'next' => (string) ($data['pagination']['next'] ?? ''));
1289 }
1290
1291 /** Classic GET /vectors/fetch (values + metadata). @return array|WP_Error id => vector */
1292 public static function classic_fetch($host, $api_key, $namespace, array $ids) {
1293 $vectors = array();
1294 foreach (array_chunk(array_values($ids), 100) as $chunk) {
1295 $query = array();
1296 foreach ($chunk as $id) {
1297 $query[] = 'ids=' . rawurlencode($id);
1298 }
1299 if ((string) $namespace !== '') {
1300 $query[] = 'namespace=' . rawurlencode($namespace);
1301 }
1302 $response = wp_remote_get('https://' . $host . '/vectors/fetch?' . implode('&', $query), array(
1303 'headers' => array('Api-Key' => $api_key, 'accept' => 'application/json'),
1304 'timeout' => 60,
1305 ));
1306 if (is_wp_error($response)) {
1307 return new WP_Error('pinecone_api', $response->get_error_message());
1308 }
1309 $code = (int) wp_remote_retrieve_response_code($response);
1310 $data = json_decode((string) wp_remote_retrieve_body($response), true);
1311 if ($code !== 200 || !is_array($data)) {
1312 return new WP_Error('pinecone_api', 'Source index fetch failed (HTTP ' . $code . ')');
1313 }
1314 if (!empty($data['vectors']) && is_array($data['vectors'])) {
1315 $vectors += $data['vectors'];
1316 }
1317 }
1318 return $vectors;
1319 }
1320
1321 /** Classic POST /describe_index_stats (no version header). @return array|WP_Error */
1322 public static function classic_stats($host, $api_key) {
1323 $response = wp_remote_post('https://' . $host . '/describe_index_stats', array(
1324 'headers' => array('Api-Key' => $api_key, 'Content-Type' => 'application/json'),
1325 'body' => '{}',
1326 'timeout' => 15,
1327 ));
1328 if (is_wp_error($response)) {
1329 return new WP_Error('pinecone_api', $response->get_error_message());
1330 }
1331 $code = (int) wp_remote_retrieve_response_code($response);
1332 $data = json_decode((string) wp_remote_retrieve_body($response), true);
1333 if ($code !== 200 || !is_array($data)) {
1334 return new WP_Error('pinecone_api', 'Source index stats failed (HTTP ' . $code . '): ' . substr((string) wp_remote_retrieve_body($response), 0, 200));
1335 }
1336 return $data;
1337 }
1338
1339 /** Display key of a namespace in the per-namespace map — Pinecone's console names the default one __default__. */
1340 public static function ns_key($namespace) {
1341 return (string) $namespace === '' ? '__default__' : (string) $namespace;
1342 }
1343
1344 /** Raw namespace for a display key (the default namespace is '' on the wire). */
1345 public static function ns_raw($key) {
1346 return (string) $key === '__default__' ? '' : (string) $key;
1347 }
1348
1349 /** Classic describe_index_stats → [raw namespace => record count], the default namespace as ''. */
1350 private static function namespace_counts(array $stats) {
1351 $counts = array();
1352 foreach ((array) ($stats['namespaces'] ?? array()) as $ns_name => $ns_info) {
1353 $counts[self::ns_raw($ns_name)] = (int) ($ns_info['vectorCount'] ?? $ns_info['recordCount'] ?? 0);
1354 }
1355 return $counts;
1356 }
1357
1358 /**
1359 * e50d2e: the order namespaces are copied in when every namespace is
1360 * asked for — the default namespace first, then the rest by name. Only
1361 * namespaces that hold records are queued.
1362 */
1363 private static function namespace_queue(array $counts) {
1364 $queue = array();
1365 if (!empty($counts[''])) {
1366 $queue[] = '';
1367 }
1368 $named = array();
1369 foreach ($counts as $ns => $count) {
1370 if ($ns !== '' && $count > 0) {
1371 $named[] = (string) $ns;
1372 }
1373 }
1374 sort($named, SORT_STRING);
1375 return array_merge($queue, $named);
1376 }
1377
1378 private static function new_namespace_entry($namespace, $total, $status = 'queued') {
1379 return array('namespace' => (string) $namespace, 'total' => (int) $total, 'copied' => 0, 'status' => $status, 'target_count' => null);
1380 }
1381
1382 /**
1383 * e50d2e: the per-namespace map of a migration state — display key →
1384 * namespace / total / copied / status / target_count. A state written
1385 * before this build copied one namespace and carries no map, so one is
1386 * synthesised from its single namespace; the Delete guard and the step
1387 * loop read every state through here.
1388 */
1389 public static function per_namespace_map(array $state) {
1390 if (!empty($state['per_namespace']) && is_array($state['per_namespace'])) {
1391 return $state['per_namespace'];
1392 }
1393 $namespace = (string) ($state['namespace'] ?? '');
1394 $status = ($state['status'] ?? '') === 'running' ? 'running' : (in_array($state['status'] ?? '', array('done', 'source_deleted'), true) ? 'done' : 'queued');
1395 $entry = self::new_namespace_entry($namespace, (int) ($state['total'] ?? 0), $status);
1396 $entry['copied'] = (int) ($state['copied'] ?? 0);
1397 $entry['target_count'] = isset($state['target_count']) ? $state['target_count'] : null;
1398 return array(self::ns_key($namespace) => $entry);
1399 }
1400
1401 /**
1402 * e50d2e: widen a single-namespace copy — running or finished — to every
1403 * namespace of the source that holds records. Namespaces already copied
1404 * keep their entry, the rest queue behind the current one, the total
1405 * becomes the sum, and "other namespaces" empties because none are left
1406 * out any more. A finished copy with nothing left to queue stays done.
1407 *
1408 * @return array|WP_Error state
1409 */
1410 private static function extend_to_all_namespaces(array $state, $api_key) {
1411 $stats = self::classic_stats((string) ($state['source_host'] ?? ''), $api_key);
1412 if (is_wp_error($stats)) {
1413 return $stats;
1414 }
1415 $ns_counts = self::namespace_counts($stats);
1416 $map = self::per_namespace_map($state);
1417 $queue = array_values((array) ($state['queue'] ?? array()));
1418 $current = isset($state['current']) ? (string) $state['current'] : (string) ($state['namespace'] ?? '');
1419 foreach (self::namespace_queue($ns_counts) as $ns) {
1420 if (isset($map[self::ns_key($ns)]) || $ns === $current || in_array($ns, $queue, true)) {
1421 continue;
1422 }
1423 $map[self::ns_key($ns)] = self::new_namespace_entry($ns, $ns_counts[$ns]);
1424 $queue[] = $ns;
1425 }
1426 $total = 0;
1427 foreach ($map as $entry) {
1428 $total += (int) ($entry['total'] ?? 0);
1429 }
1430 $state['per_namespace'] = $map;
1431 $state['queue'] = $queue;
1432 $state['current'] = $current;
1433 $state['namespace'] = $current;
1434 $state['total'] = $total;
1435 $state['total_all'] = (int) ($stats['totalVectorCount'] ?? $total);
1436 $state['other_namespaces'] = array();
1437 $state['all_namespaces'] = true;
1438 if (($state['status'] ?? '') === 'done' && !empty($queue)) {
1439 $next = array_shift($queue);
1440 $state['queue'] = $queue;
1441 $state['current'] = $next;
1442 $state['namespace'] = $next;
1443 $state['token'] = '';
1444 $state['status'] = 'running';
1445 $state['error'] = '';
1446 $state['error_at'] = 0;
1447 $state['per_namespace'][self::ns_key($next)]['status'] = 'running';
1448 unset($state['finished']);
1449 }
1450 return $state;
1451 }
1452
1453 /**
1454 * Validate and start (or resume) a migration. @return array|WP_Error state
1455 *
1456 * e50d2e: $all_namespaces copies every namespace of the source that holds
1457 * records — the default namespace first, then the rest by name — each into
1458 * the same-named namespace of the document index, one after another, in
1459 * one run. Without it the copy is exactly 3e83e4's: the one configured
1460 * namespace. Asking for every namespace on a copy that is already running
1461 * or finished keeps what was copied and queues the rest.
1462 */
1463 public static function migrate_start($source_host, array $target_cfg, $restart = false, $all_namespaces = false) {
1464 $source_host = strtolower(trim(str_replace(array('https://', 'http://'), '', (string) $source_host), '/'));
1465 if ($source_host === '') {
1466 return new WP_Error('migrate', 'Enter the host of the vector index to copy from.');
1467 }
1468 if (empty($target_cfg['host']) || empty($target_cfg['api_key'])) {
1469 return new WP_Error('migrate', 'The document index host and API key must be saved first.');
1470 }
1471 if ($source_host === strtolower($target_cfg['host'])) {
1472 return new WP_Error('migrate', 'The source and the document index are the same host.');
1473 }
1474 $existing = self::migration_get();
1475 $same_copy = !empty($existing) && ($existing['source_host'] ?? '') === $source_host && strtolower((string) ($existing['target_host'] ?? '')) === strtolower($target_cfg['host']);
1476 if (!$restart && $same_copy && ($existing['status'] ?? '') === 'running') {
1477 // Resume from the recorded cursor — whether the last step ended
1478 // cleanly or with an error (3e83e4: a second click continues,
1479 // it never starts over unless asked).
1480 $existing['error'] = '';
1481 if ($all_namespaces && empty($existing['all_namespaces'])) {
1482 $existing = self::extend_to_all_namespaces($existing, $target_cfg['api_key']);
1483 if (is_wp_error($existing)) {
1484 return $existing;
1485 }
1486 }
1487 return self::migration_set($existing);
1488 }
1489 if (!$restart && $same_copy && $all_namespaces && ($existing['status'] ?? '') === 'done') {
1490 $extended = self::extend_to_all_namespaces($existing, $target_cfg['api_key']);
1491 if (is_wp_error($extended)) {
1492 return $extended;
1493 }
1494 return self::migration_set($extended);
1495 }
1496 $stats = self::classic_stats($source_host, $target_cfg['api_key']);
1497 if (is_wp_error($stats)) {
1498 return $stats;
1499 }
1500 $namespace = (string) ($target_cfg['namespace'] ?? '');
1501 // 3e83e4: the total is the SOURCE NAMESPACE's count, never the whole
1502 // index — the copy lists one namespace, so an index with records in
1503 // other namespaces used to report "done" with copied < total and no
1504 // explanation. The other namespaces are recorded for the UI instead.
1505 $ns_counts = self::namespace_counts($stats);
1506 $queue = array();
1507 $per_namespace = array();
1508 $other_namespaces = array();
1509 if ($all_namespaces) {
1510 $queue = self::namespace_queue($ns_counts);
1511 if (empty($queue)) {
1512 $queue = array($namespace); // stats listed no namespaces — copy the configured one, as before
1513 }
1514 $total = 0;
1515 foreach ($queue as $ns) {
1516 if (isset($ns_counts[$ns])) {
1517 $count = (int) $ns_counts[$ns];
1518 } elseif ($ns === '' && empty($ns_counts)) {
1519 $count = (int) ($stats['totalVectorCount'] ?? 0);
1520 } else {
1521 $count = 0;
1522 }
1523 $per_namespace[self::ns_key($ns)] = self::new_namespace_entry($ns, $count);
1524 $total += $count;
1525 }
1526 $current = (string) array_shift($queue);
1527 $per_namespace[self::ns_key($current)]['status'] = 'running';
1528 } else {
1529 $current = $namespace;
1530 if (isset($ns_counts[$namespace])) {
1531 $total = (int) $ns_counts[$namespace];
1532 } elseif ($namespace === '' && empty($ns_counts)) {
1533 $total = (int) ($stats['totalVectorCount'] ?? 0);
1534 } else {
1535 $total = 0;
1536 }
1537 $per_namespace[self::ns_key($namespace)] = self::new_namespace_entry($namespace, $total, 'running');
1538 // 47c61f: in the copy order (default first, then by name). Pinecone's
1539 // stats object lists namespaces in a different order call to call.
1540 foreach (self::namespace_queue($ns_counts) as $ns_name) {
1541 if ((string) $ns_name !== (string) $namespace) {
1542 $other_namespaces[self::ns_key($ns_name)] = (int) $ns_counts[$ns_name];
1543 }
1544 }
1545 }
1546 $source_dimension = (int) ($stats['dimension'] ?? 0);
1547 $target_index = self::find_index_by_host($target_cfg['host'], $target_cfg['api_key']);
1548 if (is_wp_error($target_index)) {
1549 return $target_index;
1550 }
1551 $schema = self::inspect_schema($target_index);
1552 if (!$schema['is_document']) {
1553 return new WP_Error('migrate', 'The configured host is not a document index. Run Check index first.');
1554 }
1555 if ($source_dimension > 0 && $schema['dimension'] > 0 && $source_dimension !== (int) $schema['dimension']) {
1556 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']));
1557 }
1558 $state = array(
1559 'status' => 'running',
1560 'source_host' => $source_host,
1561 'target_host' => $target_cfg['host'],
1562 'target_name' => (string) ($target_index['name'] ?? ''),
1563 'namespace' => $current,
1564 'total' => $total,
1565 'total_all' => (int) ($stats['totalVectorCount'] ?? $total),
1566 'other_namespaces' => $other_namespaces,
1567 // e50d2e: the namespace being copied, the ones still queued and
1568 // a per-namespace ledger — the Delete guard checks every entry.
1569 'all_namespaces' => (bool) $all_namespaces,
1570 'current' => $current,
1571 'queue' => $queue,
1572 'per_namespace' => $per_namespace,
1573 'copied' => 0,
1574 'skipped' => 0,
1575 'token' => '',
1576 'batches' => 0,
1577 'retries' => 0,
1578 'failed_steps' => 0,
1579 'started' => time(),
1580 'error' => '',
1581 'error_at' => 0,
1582 'target_count' => null,
1583 'source_name' => '',
1584 );
1585 $source_index = self::find_index_by_host($source_host, $target_cfg['api_key']);
1586 if (!is_wp_error($source_index)) {
1587 $state['source_name'] = (string) ($source_index['name'] ?? '');
1588 }
1589 return self::migration_set($state);
1590 }
1591
1592 /**
1593 * Copy pages until the step's time budget is used or the source is
1594 * exhausted: list → fetch (values + metadata) → documents/upsert, each
1595 * call retried with backoff. Idempotent — the same ids land in the same
1596 * place on a re-run. The cursor is persisted after EVERY page, so a step
1597 * that dies mid-way (proxy timeout, killed request) loses at most one
1598 * page of work and the next click continues from the recorded position.
1599 *
1600 * @return array|WP_Error state
1601 */
1602 public static function migrate_step(array $state, array $target_cfg, $batch = self::MIGRATE_BATCH) {
1603 if (($state['status'] ?? '') !== 'running') {
1604 return $state;
1605 }
1606 if (strtolower((string) ($state['target_host'] ?? '')) !== strtolower((string) ($target_cfg['host'] ?? ''))) {
1607 return new WP_Error('migrate', 'The document index host changed since the migration started. Start it again.');
1608 }
1609 $started = microtime(true);
1610 $budget = self::step_budget();
1611 $pages = 0;
1612 $api_key = $target_cfg['api_key'];
1613 foreach (array('retries', 'skipped', 'failed_steps', 'error_at') as $counter) {
1614 $state[$counter] = (int) ($state[$counter] ?? 0); // states written before 3e83e4 lack these
1615 }
1616 if (empty($state['per_namespace']) || !is_array($state['per_namespace'])) {
1617 // e50d2e: a state written before this build copied one namespace and has no ledger
1618 $state['per_namespace'] = self::per_namespace_map($state);
1619 $state['current'] = (string) ($state['namespace'] ?? '');
1620 $state['queue'] = array();
1621 }
1622 if (!isset($state['current'])) {
1623 $state['current'] = (string) ($state['namespace'] ?? '');
1624 }
1625 do {
1626 $source_host = $state['source_host'];
1627 $namespace = (string) $state['current'];
1628 $ns_key = self::ns_key($namespace);
1629 $token = $state['token'];
1630 // e50d2e: the same namespace on the document side — every bot keeps reading its own
1631 $page_cfg = $target_cfg;
1632 $page_cfg['namespace'] = $namespace;
1633 $listed = self::with_retry(function () use ($source_host, $api_key, $namespace, $batch, $token) {
1634 return self::classic_list_page($source_host, $api_key, $namespace, $batch, $token);
1635 });
1636 $state['retries'] += max(0, $listed['attempts'] - 1);
1637 $page = $listed['result'];
1638 if (is_wp_error($page)) {
1639 return self::migration_fail($state, 'Listing the source index failed after ' . $listed['attempts'] . ' attempts: ' . $page->get_error_message());
1640 }
1641 $ids = $page['ids'];
1642 if (!empty($ids)) {
1643 $fetched = self::with_retry(function () use ($source_host, $api_key, $namespace, $ids) {
1644 return self::classic_fetch($source_host, $api_key, $namespace, $ids);
1645 });
1646 $state['retries'] += max(0, $fetched['attempts'] - 1);
1647 $vectors = $fetched['result'];
1648 if (is_wp_error($vectors)) {
1649 return self::migration_fail($state, 'Reading ' . count($ids) . ' records from the source index failed after ' . $fetched['attempts'] . ' attempts: ' . $vectors->get_error_message());
1650 }
1651 $documents = array();
1652 $skipped = 0;
1653 foreach ($vectors as $id => $vector) {
1654 if (empty($vector['values']) || !is_array($vector['values'])) {
1655 $skipped++;
1656 continue;
1657 }
1658 $metadata = is_array($vector['metadata'] ?? null) ? $vector['metadata'] : array();
1659 $documents[] = self::build_document((string) ($vector['id'] ?? $id), (string) ($metadata['text'] ?? ''), $vector['values'], $metadata);
1660 }
1661 $skipped += max(0, count($ids) - count($vectors));
1662 if (!empty($documents)) {
1663 $upserted = self::with_retry(function () use ($documents, $page_cfg) {
1664 return self::upsert($documents, $page_cfg);
1665 });
1666 $state['retries'] += max(0, $upserted['attempts'] - 1);
1667 if (is_wp_error($upserted['result'])) {
1668 return self::migration_fail($state, 'Writing ' . count($documents) . ' records to the document index failed after ' . $upserted['attempts'] . ' attempts: ' . $upserted['result']->get_error_message());
1669 }
1670 }
1671 $state['copied'] = (int) $state['copied'] + count($documents);
1672 $state['skipped'] = (int) ($state['skipped'] ?? 0) + $skipped;
1673 $state['per_namespace'][$ns_key]['copied'] = (int) ($state['per_namespace'][$ns_key]['copied'] ?? 0) + count($documents);
1674 }
1675 $state['batches'] = (int) $state['batches'] + 1;
1676 $state['token'] = $page['next'];
1677 $state['error'] = '';
1678 $state['error_at'] = 0;
1679 $pages++;
1680 if ($page['next'] === '') {
1681 // This namespace is copied: record what the document index
1682 // reports for it, then move on to the next queued namespace
1683 // inside the same time budget (e50d2e), or finish.
1684 $state['per_namespace'][$ns_key]['status'] = 'done';
1685 $state['per_namespace'][$ns_key]['finished'] = time();
1686 $ns_target = self::namespace_count($page_cfg);
1687 $state['per_namespace'][$ns_key]['target_count'] = $ns_target > 0 ? $ns_target : null;
1688 $queue = array_values((array) ($state['queue'] ?? array()));
1689 if (!empty($queue)) {
1690 $next = (string) array_shift($queue);
1691 $state['queue'] = $queue;
1692 $state['current'] = $next;
1693 $state['namespace'] = $next;
1694 $state['token'] = '';
1695 if (!isset($state['per_namespace'][self::ns_key($next)])) {
1696 $state['per_namespace'][self::ns_key($next)] = self::new_namespace_entry($next, 0);
1697 }
1698 $state['per_namespace'][self::ns_key($next)]['status'] = 'running';
1699 self::migration_set($state);
1700 continue; // re-checks the budget, then lists the next namespace from its first page
1701 }
1702 $state['status'] = 'done';
1703 $state['finished'] = time();
1704 // The finished state names what both sides report, so the UI
1705 // can say "5,000 copied; the document index holds 5,000".
1706 $target_count = 0;
1707 foreach ($state['per_namespace'] as $entry) {
1708 $target_count += (int) ($entry['target_count'] ?? 0);
1709 }
1710 $state['target_count'] = $target_count > 0 ? $target_count : null;
1711 $state['target_count_at'] = time();
1712 break;
1713 }
1714 self::migration_set($state); // cursor persisted per page, not per step
1715 } while ((microtime(true) - $started) < $budget);
1716 $state['last_step_pages'] = $pages;
1717 $state['last_step_seconds'] = round(microtime(true) - $started, 2);
1718 return self::migration_set($state);
1719 }
1720
1721 /** Record count of ONE namespace on a document index (0 when not listed) — count() reads the whole index for the default namespace. */
1722 private static function namespace_count(array $cfg) {
1723 $stats = self::stats($cfg);
1724 if (is_wp_error($stats)) {
1725 return 0;
1726 }
1727 $ns = (string) ($cfg['namespace'] ?? '');
1728 return isset($stats['namespaces'][$ns]['vectorCount']) ? (int) $stats['namespaces'][$ns]['vectorCount'] : 0;
1729 }
1730
1731 /**
1732 * e50d2e: namespaces of the OLD index that still hold records without a
1733 * finished, count-matching copy — read from the live source stats, so a
1734 * namespace filled after the copy counts too. Empty array = every record
1735 * on the old index has a copy; the Delete old index button refuses on
1736 * anything else and names what is missing.
1737 *
1738 * @return array|WP_Error display key → ['namespace','source','copied','status']
1739 */
1740 public static function uncopied_namespaces(array $state, $api_key) {
1741 $stats = self::classic_stats((string) ($state['source_host'] ?? ''), $api_key);
1742 if (is_wp_error($stats)) {
1743 return $stats;
1744 }
1745 $counts = self::namespace_counts($stats);
1746 if (empty($counts) && (int) ($stats['totalVectorCount'] ?? 0) > 0) {
1747 $counts[''] = (int) $stats['totalVectorCount'];
1748 }
1749 $map = self::per_namespace_map($state);
1750 $missing = array();
1751 foreach ($counts as $ns => $count) {
1752 if ($count <= 0) {
1753 continue;
1754 }
1755 $key = self::ns_key($ns);
1756 $entry = isset($map[$key]) && is_array($map[$key]) ? $map[$key] : null;
1757 if ($entry === null || ($entry['status'] ?? '') !== 'done' || (int) ($entry['copied'] ?? 0) < $count) {
1758 $missing[$key] = array(
1759 'namespace' => (string) $ns,
1760 'source' => (int) $count,
1761 'copied' => $entry ? (int) ($entry['copied'] ?? 0) : 0,
1762 'status' => $entry ? (string) ($entry['status'] ?? '') : 'not copied',
1763 );
1764 }
1765 }
1766 // Named in the copy order (default first, then by name) — Pinecone's stats object order varies call to call.
1767 $ordered = array();
1768 foreach (self::namespace_queue(array_combine(array_map(array(__CLASS__, 'ns_raw'), array_keys($missing)), array_column($missing, 'source')) ?: array()) as $ns) {
1769 $ordered[self::ns_key($ns)] = $missing[self::ns_key($ns)];
1770 }
1771 return $ordered + $missing;
1772 }
1773
1774 /** Plain-language refusal naming every namespace the copy is missing. (Plain __(): the card's JS text-escapes it.) */
1775 public static function uncopied_message(array $missing) {
1776 $parts = array();
1777 foreach ($missing as $key => $m) {
1778 $parts[] = sprintf(
1779 /* translators: 1: namespace name, 2: records copied, 3: records on the old index */
1780 __('%1$s (%2$s of %3$s records copied)', 'mxchat'),
1781 $key,
1782 number_format_i18n((int) $m['copied']),
1783 number_format_i18n((int) $m['source'])
1784 );
1785 }
1786 return sprintf(
1787 /* translators: %s: comma-separated namespace names with their counts */
1788 _n(
1789 'Namespace %s on the old index has records that are not in the document index. Tick Copy every namespace and run Migrate again, then delete. The old index stays.',
1790 'Namespaces %s on the old index have records that are not in the document index. Tick Copy every namespace and run Migrate again, then delete. The old index stays.',
1791 count($missing),
1792 'mxchat'
1793 ),
1794 implode(', ', $parts)
1795 );
1796 }
1797
1798 /**
1799 * 5872ef: bots that still keep the given Pinecone host as their own (the
1800 * Multi-Bot add-on stores a host per bot). Core knows nothing about the
1801 * bots table — the add-on answers the filter, and with it inactive the
1802 * list is empty. Delete old index refuses while this is not empty.
1803 *
1804 * @return array bot id → bot name
1805 */
1806 public static function bots_on_host($host) {
1807 $host = strtolower(trim(str_replace(array('https://', 'http://'), '', (string) $host), '/'));
1808 if ($host === '') {
1809 return array();
1810 }
1811 $bots = apply_filters('mxchat_pinecone_bots_on_host', array(), $host);
1812 if (!is_array($bots)) {
1813 return array();
1814 }
1815 $named = array();
1816 foreach ($bots as $bot_id => $name) {
1817 $name = is_scalar($name) ? trim((string) $name) : '';
1818 $named[(string) $bot_id] = $name !== '' ? $name : (string) $bot_id;
1819 }
1820 return $named;
1821 }
1822
1823 /** Plain-language refusal naming every bot still on the old index. (Plain __(): the card's JS text-escapes it.) */
1824 public static function bots_on_host_message(array $bots) {
1825 return sprintf(
1826 /* translators: 1: number of bots, 2: comma-separated bot names */
1827 _n(
1828 '%1$s bot still uses the old index: %2$s. Move it to the new index first (Multi-Bot, All Bots, Move these bots to the new index), or it will stop answering. The old index stays.',
1829 '%1$s bots still use the old index: %2$s. Move them to the new index first (Multi-Bot, All Bots, Move these bots to the new index), or they will stop answering. The old index stays.',
1830 count($bots),
1831 'mxchat'
1832 ),
1833 number_format_i18n(count($bots)),
1834 implode(', ', $bots)
1835 );
1836 }
1837
1838 /** Record a step failure without ending the copy: status stays running, the cursor stays put. */
1839 private static function migration_fail(array $state, $message) {
1840 $state['error'] = (string) $message;
1841 $state['error_at'] = time();
1842 $state['failed_steps'] = (int) ($state['failed_steps'] ?? 0) + 1;
1843 return self::migration_set($state);
1844 }
1845
1846 // =====================================================================
1847 // AJAX (Knowledge → Pinecone card)
1848 // =====================================================================
1849
1850 public static function boot() {
1851 add_action('wp_ajax_mxchat_pinecone_docs_create_index', array(__CLASS__, 'ajax_create_index'));
1852 add_action('wp_ajax_mxchat_pinecone_docs_check_index', array(__CLASS__, 'ajax_check_index'));
1853 add_action('wp_ajax_mxchat_pinecone_docs_suggest_name', array(__CLASS__, 'ajax_suggest_name'));
1854 add_action('wp_ajax_mxchat_pinecone_docs_migrate', array(__CLASS__, 'ajax_migrate'));
1855 add_action('wp_ajax_mxchat_pinecone_docs_delete_old_index', array(__CLASS__, 'ajax_delete_old_index'));
1856 }
1857
1858 /**
1859 * Suggest a free document-index name for the name in the field (3e83e4).
1860 * With a host that already answers in the project, the name is that
1861 * index's own — the owner is pointing at an existing document index, not
1862 * about to create one.
1863 */
1864 public static function ajax_suggest_name() {
1865 self::ajax_guard();
1866 $api_key = self::posted_api_key();
1867 $base = isset($_POST['base']) ? sanitize_text_field(wp_unslash($_POST['base'])) : '';
1868 $host = isset($_POST['host']) ? sanitize_text_field(wp_unslash($_POST['host'])) : '';
1869 if ($api_key === '') {
1870 wp_send_json_error(array('message' => esc_html__('Enter your Pinecone API key first.', 'mxchat')));
1871 }
1872 if ($host !== '') {
1873 $index = self::find_index_by_host($host, $api_key);
1874 if (!is_wp_error($index) && !empty($index['name'])) {
1875 wp_send_json_success(array('name' => strtolower((string) $index['name']), 'resolved' => true, 'taken' => false, 'base' => strtolower($base)));
1876 }
1877 }
1878 $suggest = self::suggest_index_name($base, $api_key);
1879 wp_send_json_success(array('name' => $suggest['name'], 'resolved' => false, 'taken' => $suggest['taken'], 'base' => strtolower($base)));
1880 }
1881
1882 /** The document-index target for a migration call: the host in the form, else the saved one. */
1883 private static function posted_target_host(array $options) {
1884 $posted = isset($_POST['target_host']) ? sanitize_text_field(wp_unslash($_POST['target_host'])) : '';
1885 $posted = strtolower(trim(str_replace(array('https://', 'http://'), '', $posted), '/'));
1886 if ($posted !== '') {
1887 return $posted;
1888 }
1889 return strtolower(trim((string) ($options['mxchat_pinecone_host'] ?? ''), '/'));
1890 }
1891
1892 private static function ajax_guard() {
1893 check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
1894 if (!current_user_can('manage_options')) {
1895 wp_send_json_error(array('message' => esc_html__('Permission denied.', 'mxchat')), 403);
1896 }
1897 }
1898
1899 private static function posted_api_key() {
1900 $posted = isset($_POST['api_key']) ? sanitize_text_field(wp_unslash($_POST['api_key'])) : '';
1901 if ($posted !== '') {
1902 return $posted;
1903 }
1904 $options = get_option(self::OPTION, array());
1905 return is_array($options) ? (string) ($options['mxchat_pinecone_api_key'] ?? '') : '';
1906 }
1907
1908 /**
1909 * Create index for me: control-plane create with the active embedding
1910 * dimension, then fill in (and persist) the host.
1911 */
1912 public static function ajax_create_index() {
1913 self::ajax_guard();
1914 $api_key = self::posted_api_key();
1915 $name = isset($_POST['index_name']) ? sanitize_text_field(wp_unslash($_POST['index_name'])) : '';
1916 $cloud = isset($_POST['cloud']) ? sanitize_key($_POST['cloud']) : 'aws';
1917 $region = isset($_POST['region']) ? sanitize_text_field(wp_unslash($_POST['region'])) : 'us-east-1';
1918 $language = isset($_POST['language']) ? sanitize_key($_POST['language']) : 'en';
1919 if ($api_key === '') {
1920 wp_send_json_error(array('message' => esc_html__('Enter your Pinecone API key first.', 'mxchat')));
1921 }
1922 if ($name === '') {
1923 wp_send_json_error(array('message' => esc_html__('Enter an index name first — the new document index will be created under that name.', 'mxchat')));
1924 }
1925 $dimension = self::expected_dimension_checked(true);
1926 if (is_wp_error($dimension)) {
1927 // 418afd: never size an index from a guess
1928 wp_send_json_error(array('message' => self::dimension_unknown_message($dimension, 'create'), 'code' => 'dimension_unknown'));
1929 }
1930 $index = self::create_document_index($name, $dimension, $cloud, $region, $language, $api_key);
1931 if (is_wp_error($index)) {
1932 $data = $index->get_error_data();
1933 wp_send_json_error(array(
1934 'message' => $index->get_error_message(),
1935 'code' => $index->get_error_code(),
1936 'suggested' => is_array($data) ? (string) ($data['suggested'] ?? '') : '',
1937 ));
1938 }
1939 $host = (string) ($index['host'] ?? '');
1940 // Give Pinecone a few seconds — small serverless indexes are usually Ready quickly.
1941 $ready = !empty($index['status']['ready']);
1942 for ($i = 0; !$ready && $i < 5; $i++) {
1943 sleep(2);
1944 $described = self::describe_index($name, $api_key);
1945 if (!is_wp_error($described)) {
1946 $ready = !empty($described['status']['ready']);
1947 $host = (string) ($described['host'] ?? $host);
1948 }
1949 }
1950 $options = get_option(self::OPTION, array());
1951 $options = is_array($options) ? $options : array();
1952 // 3e83e4: creating the index no longer moves the live chatbot. The
1953 // host and index type are only written when the owner saves the
1954 // form; here we record that this host passed the check (the save
1955 // gate) and the settings the index was created with.
1956 $changes = array(
1957 'mxchat_pinecone_docs_verified_host' => $host,
1958 'mxchat_pinecone_docs_cloud' => array_key_exists($cloud, self::clouds()) ? $cloud : 'aws',
1959 'mxchat_pinecone_docs_region' => $region ?: 'us-east-1',
1960 'mxchat_pinecone_docs_language' => array_key_exists($language, self::languages()) ? $language : 'en',
1961 );
1962 if (empty($options['mxchat_pinecone_api_key'])) {
1963 $changes['mxchat_pinecone_api_key'] = $api_key;
1964 }
1965 // Remember the classic index so Migrate can copy from it.
1966 $previous_host = (string) ($options['mxchat_pinecone_host'] ?? '');
1967 if ($previous_host !== '' && strtolower($previous_host) !== strtolower($host) && self::options_index_type($options) === 'vector' && empty($options['mxchat_pinecone_vector_host'])) {
1968 $changes['mxchat_pinecone_vector_host'] = $previous_host;
1969 $changes['mxchat_pinecone_vector_index'] = (string) ($options['mxchat_pinecone_index'] ?? '');
1970 }
1971 self::persist_options($changes);
1972 wp_send_json_success(array(
1973 'host' => $host,
1974 'name' => strtolower($name),
1975 'dimension' => $dimension,
1976 'ready' => $ready,
1977 'vector_host' => $changes['mxchat_pinecone_vector_host'] ?? ($options['mxchat_pinecone_vector_host'] ?? ''),
1978 // Plain text: the card's JS escapes it on render (esc_html__ here double-escaped the quotes to a literal &quot;).
1979 'message' => $ready
1980 ? sprintf(__('Document index "%1$s" created (%2$d dimensions, cosine, full-text search on the chunk text). The host is filled in above. Your chatbot keeps answering from the current index until you save the Pinecone settings — copy your records across with Migrate first.', 'mxchat'), strtolower($name), $dimension)
1981 : sprintf(__('Document index "%1$s" is being created (%2$d dimensions). The host is filled in above; run Check index in a minute to confirm it is ready. Your chatbot keeps answering from the current index until you save the Pinecone settings.', 'mxchat'), strtolower($name), $dimension),
1982 ));
1983 }
1984
1985 /** Check index: assert the host is a document index MxChat can use. */
1986 public static function ajax_check_index() {
1987 self::ajax_guard();
1988 $api_key = self::posted_api_key();
1989 $host = isset($_POST['host']) ? sanitize_text_field(wp_unslash($_POST['host'])) : '';
1990 $host = trim(str_replace(array('https://', 'http://'), '', $host), '/');
1991 if ($api_key === '' || $host === '') {
1992 wp_send_json_error(array('message' => esc_html__('Enter the Pinecone API key and host first.', 'mxchat')));
1993 }
1994 $dimension = self::expected_dimension_checked(true);
1995 if (is_wp_error($dimension)) {
1996 wp_send_json_error(array('ok' => false, 'message' => self::dimension_unknown_message($dimension, 'check'), 'code' => 'dimension_unknown'));
1997 }
1998 $check = self::check_document_index($host, $api_key, $dimension);
1999 if (!$check['ok']) {
2000 wp_send_json_error($check);
2001 }
2002 $options = get_option(self::OPTION, array());
2003 $options = is_array($options) ? $options : array();
2004 $changes = array(
2005 'mxchat_pinecone_docs_verified_host' => $check['host'],
2006 'mxchat_pinecone_index' => $check['name'],
2007 );
2008 $previous_host = (string) ($options['mxchat_pinecone_host'] ?? '');
2009 if ($previous_host !== '' && strtolower($previous_host) !== strtolower($check['host']) && self::options_index_type($options) === 'vector' && empty($options['mxchat_pinecone_vector_host'])) {
2010 $changes['mxchat_pinecone_vector_host'] = $previous_host;
2011 $changes['mxchat_pinecone_vector_index'] = (string) ($options['mxchat_pinecone_index'] ?? '');
2012 }
2013 self::persist_options($changes);
2014 $check['vector_host'] = $changes['mxchat_pinecone_vector_host'] ?? ($options['mxchat_pinecone_vector_host'] ?? '');
2015 wp_send_json_success($check);
2016 }
2017
2018 /**
2019 * Migrate: mode start | step | status | reset.
2020 *
2021 * 3e83e4: the copy is DECOUPLED from the live retrieval path. The target
2022 * is the document-index host in the form (posted as target_host) plus the
2023 * API key, checked against Pinecone at start — the saved index type is
2024 * never consulted, so the site keeps answering from its vector index for
2025 * the whole copy and the owner switches by saving the settings afterwards.
2026 * Each step copies pages for up to MIGRATE_STEP_SECONDS.
2027 */
2028 public static function ajax_migrate() {
2029 self::ajax_guard();
2030 $mode = isset($_POST['mode']) ? sanitize_key($_POST['mode']) : 'status';
2031 $options = get_option(self::OPTION, array());
2032 $options = is_array($options) ? $options : array();
2033 if ($mode === 'reset') {
2034 self::migration_clear();
2035 wp_send_json_success(array('state' => array()));
2036 }
2037 if ($mode === 'status') {
2038 wp_send_json_success(array('state' => self::migration_get()));
2039 }
2040 $api_key = self::posted_api_key();
2041 $namespace = (string) ($options['mxchat_pinecone_namespace'] ?? '');
2042 if ($api_key === '') {
2043 wp_send_json_error(array('message' => esc_html__('Enter your Pinecone API key first.', 'mxchat')));
2044 }
2045 if ($mode === 'start') {
2046 $target_host = self::posted_target_host($options);
2047 if ($target_host === '') {
2048 wp_send_json_error(array('message' => esc_html__('Enter the host of the document index to copy into (or create one with Create index for me).', 'mxchat')));
2049 }
2050 $dimension = self::expected_dimension_checked(true);
2051 if (is_wp_error($dimension)) {
2052 wp_send_json_error(array('message' => self::dimension_unknown_message($dimension, 'check'), 'code' => 'dimension_unknown'));
2053 }
2054 $check = self::check_document_index($target_host, $api_key, $dimension);
2055 if (!$check['ok']) {
2056 wp_send_json_error(array('message' => $check['message']));
2057 }
2058 $target = array('api_key' => $api_key, 'host' => strtolower($check['host']), 'namespace' => $namespace, 'index_type' => 'document');
2059 $source_host = isset($_POST['source_host']) ? sanitize_text_field(wp_unslash($_POST['source_host'])) : '';
2060 $restart = !empty($_POST['restart']);
2061 $all_ns = !empty($_POST['all_namespaces']); // e50d2e: Copy every namespace
2062 $state = self::migrate_start($source_host, $target, $restart, $all_ns);
2063 if (is_wp_error($state)) {
2064 wp_send_json_error(array('message' => $state->get_error_message()));
2065 }
2066 $changes = array('mxchat_pinecone_docs_verified_host' => $target['host']);
2067 if ($source_host !== '' && strtolower($state['source_host']) !== strtolower((string) ($options['mxchat_pinecone_vector_host'] ?? ''))) {
2068 $changes['mxchat_pinecone_vector_host'] = $state['source_host'];
2069 $changes['mxchat_pinecone_vector_index'] = $state['source_name'];
2070 }
2071 self::persist_options($changes);
2072 wp_send_json_success(array('state' => $state, 'live_index_type' => self::options_index_type($options)));
2073 }
2074 if ($mode === 'step') {
2075 $state = self::migration_get();
2076 if (empty($state)) {
2077 wp_send_json_error(array('message' => esc_html__('No migration in progress. Click Migrate to start one.', 'mxchat')));
2078 }
2079 $target = array('api_key' => $api_key, 'host' => (string) ($state['target_host'] ?? ''), 'namespace' => (string) ($state['current'] ?? ($state['namespace'] ?? $namespace)), 'index_type' => 'document');
2080 $state = self::migrate_step($state, $target);
2081 if (is_wp_error($state)) {
2082 wp_send_json_error(array('message' => $state->get_error_message()));
2083 }
2084 if (!empty($state['error'])) {
2085 wp_send_json_error(array('message' => $state['error'], 'state' => $state));
2086 }
2087 wp_send_json_success(array('state' => $state, 'live_index_type' => self::options_index_type($options)));
2088 }
2089 wp_send_json_error(array('message' => esc_html__('Unknown migration action.', 'mxchat')));
2090 }
2091
2092 /**
2093 * Delete old index — only after a finished copy whose count matches, only
2094 * with the index name typed back, never the configured document index.
2095 */
2096 public static function ajax_delete_old_index() {
2097 self::ajax_guard();
2098 $confirm = isset($_POST['confirm_name']) ? strtolower(sanitize_text_field(wp_unslash($_POST['confirm_name']))) : '';
2099 $options = get_option(self::OPTION, array());
2100 $options = is_array($options) ? $options : array();
2101 $target = self::cfg_from_options($options);
2102 $state = self::migration_get();
2103 if (empty($state) || ($state['status'] ?? '') !== 'done') {
2104 wp_send_json_error(array('message' => esc_html__('Finish the migration first — the old index can only be deleted after a completed copy.', 'mxchat')));
2105 }
2106 if ((int) $state['copied'] < (int) $state['total'] || (int) $state['total'] === 0) {
2107 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'])));
2108 }
2109 if ($target['index_type'] !== 'document' || strtolower($target['host']) !== strtolower((string) $state['target_host'])) {
2110 wp_send_json_error(array('message' => esc_html__('The saved document index no longer matches the migration. The old index stays.', 'mxchat')));
2111 }
2112 // e50d2e: EVERY namespace that holds records on the old index needs a
2113 // finished copy whose count matches — not just the one copied last.
2114 // Before this, a Multi-Bot or legacy-namespace site could delete
2115 // records that were never copied.
2116 $missing = self::uncopied_namespaces($state, $target['api_key']);
2117 // 5872ef: a bot that still keeps the old index as its own host stops
2118 // answering the moment that index is deleted, however complete the
2119 // copy is. Refuse and name the bots; when namespaces are uncopied too,
2120 // say both in one answer.
2121 $bots = self::bots_on_host((string) ($state['source_host'] ?? ''));
2122 if (!empty($bots)) {
2123 $refusal = array('message' => self::bots_on_host_message($bots), 'bots' => $bots);
2124 if (is_array($missing) && !empty($missing)) {
2125 $refusal['message'] .= ' ' . self::uncopied_message($missing);
2126 $refusal['uncopied'] = $missing;
2127 }
2128 wp_send_json_error($refusal);
2129 }
2130 if (is_wp_error($missing)) {
2131 wp_send_json_error(array('message' => sprintf(__('Could not read the old index before deleting it (%s). The old index stays.', 'mxchat'), $missing->get_error_message())));
2132 }
2133 if (!empty($missing)) {
2134 wp_send_json_error(array('message' => self::uncopied_message($missing), 'uncopied' => $missing));
2135 }
2136 $source = self::find_index_by_host($state['source_host'], $target['api_key']);
2137 if (is_wp_error($source)) {
2138 wp_send_json_error(array('message' => $source->get_error_message()));
2139 }
2140 $source_name = strtolower((string) ($source['name'] ?? ''));
2141 if ($source_name === '' || $confirm !== $source_name) {
2142 wp_send_json_error(array('message' => sprintf(esc_html__('Type the old index name exactly (%s) to confirm.', 'mxchat'), $source_name)));
2143 }
2144 if (strtolower((string) ($source['host'] ?? '')) === strtolower($target['host']) || $source_name === strtolower((string) ($state['target_name'] ?? ''))) {
2145 wp_send_json_error(array('message' => esc_html__('Refusing: that is the document index currently in use.', 'mxchat')));
2146 }
2147 $result = self::delete_index($source_name, $target['api_key']);
2148 if (is_wp_error($result)) {
2149 wp_send_json_error(array('message' => $result->get_error_message()));
2150 }
2151 self::persist_options(array('mxchat_pinecone_vector_host' => '', 'mxchat_pinecone_vector_index' => ''));
2152 $state['status'] = 'source_deleted';
2153 self::migration_set($state);
2154 wp_send_json_success(array('message' => sprintf(esc_html__('Old vector index "%s" deleted.', 'mxchat'), $source_name), 'state' => $state));
2155 }
2156 }
2157
2158 MxChat_Pinecone_Documents::boot();
2159