PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.8
MxChat – AI Chatbot & Content Generation for WordPress v3.2.8
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 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
← All changes | includes/class-mxchat-utils.php +35 -1129 3.2.213.2.8 View file →
@@ -5,308 +5,8 @@
5 5
6 6 class MxChat_Utils {
7 7
8 8 /**
9 - * True while a storage routine is re-writing an entry it is about to re-add
10 - * (submit_chunked_content's clean-slate delete). delete_chunks_for_url() skips
11 - * the Vector Store mirror-delete while set — an internal re-store is not an
12 - * entry removal, and mirroring it would delete-then-reupload the entry's file
13 - * on every chunked save (plan 15b5c6).
14 - */
15 -private static $vectorstore_mirror_suspended = false;
16 -
17 -/**
18 - * Validate a client-supplied session id (plan-mxchat-20260731-d42bec).
19 - *
20 - * sanitize_text_field() — which every session_id read site used before this —
21 - * preserves '/' and '..'. Harmless where the value is only an option or
22 - * transient key suffix, but mxchat_send_delayed_transcript() interpolates it
23 - * into a filesystem path, so '../../../../path/x' wrote, emailed and deleted a
24 - * file outside the uploads dir.
25 - *
26 - * REJECTS rather than rewrites: a silently-stripped id would orphan the
27 - * conversation it belongs to, which is harder to diagnose than a clean refusal.
28 - * Returns '' for anything malformed, so call sites fall into the empty-session
29 - * error paths they already have.
30 - *
31 - * The generator only ever emits 'mxchat_chat_' + 32 hex chars
32 - * (class-mxchat-integrator.php, js/chat-script.js), so this is not restrictive
33 - * in practice. Length ceiling is deliberate — session ids are also used as
34 - * option-name suffixes, and WP option names cap at 191 chars.
35 - *
36 - * @param mixed $raw Raw request value.
37 - * @return string The id if well-formed, '' otherwise.
38 - */
39 -public static function sanitize_session_id($raw) {
40 - if (!is_scalar($raw)) {
41 - return '';
42 - }
43 - $val = trim((string) $raw);
44 - if ($val === '') {
45 - return '';
46 - }
47 - return preg_match('/\A[A-Za-z0-9_-]{1,128}\z/', $val) ? $val : '';
48 -}
49 -
50 -/**
51 - * Sanitize the lead-capture consent-checkbox label (plan b062c4).
52 - *
53 - * The label is owner-supplied and renders inside the widget's email form, so
54 - * this is a security boundary, not a formatting nicety. One explicit
55 - * allowlist, used at BOTH save time (options.php sanitize + autosave AJAX)
56 - * and render time (widget form, admin surfaces) so the two can never drift:
57 - * an anchor — the whole point is "I agree to the <a>Privacy Policy</a>" —
58 - * plus inline emphasis. No block tags, no images, no style attributes.
59 - *
60 - * The stored consent record keeps this exact sanitized string as "the text
61 - * the visitor saw", so it must be deterministic: same input, same output,
62 - * whichever path ran it.
63 - *
64 - * @param mixed $raw Owner-entered label.
65 - * @return string Sanitized label, capped at 1000 chars.
66 - */
67 -public static function sanitize_consent_label($raw) {
68 - if (!is_scalar($raw)) {
69 - return '';
70 - }
71 -
72 - $allowed = array(
73 - 'a' => array(
74 - 'href' => true,
75 - 'title' => true,
76 - 'target' => true,
77 - 'rel' => true,
78 - ),
79 - 'strong' => array(),
80 - 'em' => array(),
81 - 'br' => array(),
82 - );
83 -
84 - $label = wp_kses(trim((string) $raw), $allowed);
85 -
86 - return mb_substr($label, 0, 1000);
87 -}
88 -
89 -/**
90 - * Per-request cache for get_session_history(). Mirrors get_option()'s
91 - * request-scoped caching, which the mxchat_history_ option reads got for
92 - * free before plan 839c4c moved history reads onto the transcripts table.
93 - */
94 -private static $history_cache = array();
95 -
96 -/**
97 - * Session chat history read from the transcripts table, in the exact array
98 - * shape the legacy mxchat_history_<sid> option stored (plan 839c4c). The
99 - * option was a second copy of state the table already held — measured
100 - * byte-identical in role/content/order on 174 of 177 real sessions, with
101 - * the table a superset on the rest — at up to 64 KB per option row. The
102 - * table is now the single store; nothing writes the option any more.
103 - *
104 - * Shape notes, load-bearing for the consumers:
105 - * - id: the transcripts row id (int). Integer ids make the pollers'
106 - * ">" comparisons correct where the old uniqid() strings only worked by
107 - * accident of hex ordering.
108 - * - timestamp: milliseconds, derived from the table's second-resolution GMT
109 - * column (x1000). Consumers comparing against a real-millisecond client
110 - * cutoff MUST floor the cutoff to the second and err inclusive — see the
111 - * persistence-off filters in class-mxchat-integrator.php.
112 - * - agent_name: the row's user_identifier, which the writer sets to the
113 - * same displayed_name value the option carried (agent name when present,
114 - * else email, else identifier).
115 - *
116 - * Public and static so mxchat-woo / mxchat-forms can call the same accessor
117 - * as core, guarded with method_exists against an older mxchat-basic.
118 - *
119 - * @param string $session_id
120 - * @return array[] Chronological entries: id, role, content, timestamp, agent_name.
121 - */
122 -public static function get_session_history($session_id) {
123 - global $wpdb;
124 -
125 - $session_id = self::sanitize_session_id($session_id);
126 - if ($session_id === '') {
127 - return array();
128 - }
129 -
130 - if (array_key_exists($session_id, self::$history_cache)) {
131 - return self::$history_cache[$session_id];
132 - }
133 -
134 - $table = $wpdb->prefix . 'mxchat_chat_transcripts';
135 -
136 - // No SHOW TABLES guard: this is the chat hot path and the table is
137 - // created on activation (with an admin-load safety net). A genuinely
138 - // missing table fails the query and yields the same empty history the
139 - // old option read produced on a fresh session.
140 - $rows = $wpdb->get_results(
141 - $wpdb->prepare(
142 - "SELECT id, role, message, user_identifier, timestamp
143 - FROM `$table` WHERE session_id = %s ORDER BY id ASC",
144 - $session_id
145 - ),
146 - ARRAY_A
147 - );
148 -
149 - $history = array();
150 - if (is_array($rows)) {
151 - foreach ($rows as $row) {
152 - // The column stores GMT (current_time('mysql', 1) at the writer),
153 - // so pin the parse to UTC rather than the site timezone.
154 - $ts = strtotime($row['timestamp'] . ' +0000');
155 - $history[] = array(
156 - 'id' => (int) $row['id'],
157 - 'role' => (string) $row['role'],
158 - 'content' => (string) $row['message'],
159 - 'timestamp' => ($ts ? $ts : 0) * 1000,
160 - 'agent_name' => (string) $row['user_identifier'],
161 - );
162 - }
163 - }
164 -
165 - self::$history_cache[$session_id] = $history;
166 -
167 - return $history;
168 -}
169 -
170 -/**
171 - * Drop the cached history for one session (or all). The writer calls this
172 - * after every insert so a later read in the same request — e.g. the AI
173 - * context build that follows saving the user's message — sees the new row,
174 - * matching the read-your-own-write behavior update_option() gave the old
175 - * option copy.
176 - *
177 - * @param string|null $session_id Null flushes everything (test seam).
178 - */
179 -public static function flush_session_history_cache($session_id = null) {
180 - if ($session_id === null) {
181 - self::$history_cache = array();
182 - return;
183 - }
184 -
185 - unset(self::$history_cache[(string) $session_id]);
186 -}
187 -
188 -/**
189 - * Most recipients the Notification Email field will accept (plan 2f131a).
190 - * A settings field is not a mailing list.
191 - */
192 -const NOTIFICATION_EMAIL_MAX = 5;
193 -
194 -/**
195 - * Parse the Notification Email field into a list of recipients (plan 2f131a).
196 - *
197 - * THE TRAP THIS EXISTS TO CLOSE: sanitize_email() cannot be the validator for
198 - * this field, because its output for the failing input is VALID. WordPress
199 - * strips the separator and the surplus '@' and concatenates the remains:
200 - *
201 - * support@acme.com, sales@acme.com -> support@acme.comsalesacme.com
202 - *
203 - * and is_email() then returns true on that. So every guard in the plugin passed,
204 - * the address was stored, the autosave ticked green, and both the new-session
205 - * notification and the auto-emailed transcript went to a domain that does not
206 - * exist — with no error anywhere. Validating the RAW part BEFORE sanitizing is
207 - * the whole point; reversing those two lines silently restores the bug.
208 - *
209 - * All-or-nothing by design: if any entry is bad the caller must store NOTHING.
210 - * A partial accept — keeping the good addresses and dropping the bad one — is
211 - * the same defect in a new costume, because the owner still believes everyone
212 - * on their list is being notified.
213 - *
214 - * @param mixed $raw Raw field value, exactly as submitted.
215 - * @return array{emails: string[], error: string} Empty emails + empty error
216 - * means the field was empty.
217 - */
218 -public static function parse_notification_emails($raw) {
219 - $out = array('emails' => array(), 'error' => '');
220 -
221 - if (!is_scalar($raw)) {
222 - $out['error'] = __('The notification email could not be read.', 'mxchat');
223 - return $out;
224 - }
225 -
226 - $raw = trim((string) $raw);
227 - if ($raw === '') {
228 - return $out; // genuinely empty — the caller falls back to admin_email
229 - }
230 -
231 - $seen = array();
232 - foreach (preg_split('/[,;]/', $raw) as $part) {
233 - $part = trim($part);
234 - if ($part === '') {
235 - // A trailing or doubled separator carries no address, so skipping it
236 - // cannot silently drop a recipient. This is the ONLY thing tolerated.
237 - continue;
238 - }
239 -
240 - // RAW first. See the note above — order is load-bearing.
241 - $clean = is_email($part) ? sanitize_email($part) : '';
242 - if ($clean === '' || !is_email($clean)) {
243 - return array(
244 - 'emails' => array(),
245 - 'error' => sprintf(
246 - /* translators: %s: the email address the owner typed. */
247 - __('"%s" is not a valid email address, so nothing was saved. Separate multiple addresses with a comma.', 'mxchat'),
248 - esc_html($part)
249 - ),
250 - );
251 - }
252 -
253 - $key = strtolower($clean);
254 - if (isset($seen[$key])) {
255 - continue; // same address twice would simply mail them twice
256 - }
257 - $seen[$key] = true;
258 - $out['emails'][] = $clean;
259 - }
260 -
261 - if (count($out['emails']) > self::NOTIFICATION_EMAIL_MAX) {
262 - return array(
263 - 'emails' => array(),
264 - 'error' => sprintf(
265 - /* translators: %d: maximum number of notification recipients. */
266 - __('Enter at most %d email addresses, separated by commas.', 'mxchat'),
267 - self::NOTIFICATION_EMAIL_MAX
268 - ),
269 - );
270 - }
271 -
272 - return $out;
273 -}
274 -
275 -/**
276 - * The stored recipient list, ready to hand to wp_mail() (plan 2f131a).
277 - *
278 - * Fallback rule, and it is narrow on purpose: an EMPTY field falls back to the
279 - * site admin address, because that is the documented behaviour and an owner who
280 - * never filled the field in still wants their notifications. A field holding
281 - * something unusable does NOT fall back — it sends nowhere, exactly as before
282 - * this plan. Falling back on bad input would mean a typo silently redirects a
283 - * store's transcripts to a different mailbox than the one on screen.
284 - *
285 - * @param array|null $options mxchat_transcripts_options, or null to read it.
286 - * @return string[] Recipients; empty means do not send.
287 - */
288 -public static function notification_recipients($options = null) {
289 - if (!is_array($options)) {
290 - $options = get_option('mxchat_transcripts_options', array());
291 - if (!is_array($options)) {
292 - $options = array();
293 - }
294 - }
295 -
296 - $raw = isset($options['mxchat_notification_email']) ? $options['mxchat_notification_email'] : '';
297 - $raw = is_scalar($raw) ? trim((string) $raw) : '';
298 -
299 - if ($raw === '') {
300 - $admin = get_option('admin_email');
301 - return is_email($admin) ? array($admin) : array();
302 - }
303 -
304 - $parsed = self::parse_notification_emails($raw);
305 - return $parsed['error'] === '' ? $parsed['emails'] : array();
306 -}
307 -
308 -/**
309 9 * Centralized embedding model registry. Single source of truth for dimensions
310 10 * and provider, so model-switch protection logic doesn't drift across files.
311 11 */
312 12 public static function embedding_model_registry() {
@@ -324,12 +24,8 @@
324 24 return isset($registry[$model]) ? (int) $registry[$model]['dims'] : 0;
325 25 }
326 26
327 27 public static function embedding_model_label($model) {
328 - if (is_string($model) && strpos($model, 'custom:') === 0) {
329 - /* translators: %s: the embedding model name configured on the custom provider */
330 - return sprintf(__('%s (custom provider)', 'mxchat'), substr($model, 7));
331 - }
332 28 $registry = self::embedding_model_registry();
333 29 return isset($registry[$model]) ? $registry[$model]['label'] : $model;
334 30 }
335 31
@@ -352,110 +48,8 @@
352 48 }
353 49 }
354 50
355 51 /**
356 - * The model name the custom-provider embedding path will send, mirroring the
357 - * fallback chain the request itself uses: dedicated custom embedding model,
358 - * else the custom chat model, else 'default'. Single source shared by
359 - * generate_embedding_custom() and the mismatch-warning "selected" side so the
360 - * two can never drift (plan ae02cb).
361 - */
362 -public static function resolve_custom_embedding_model($options) {
363 - if (isset($options['custom_provider_embedding_model']) && trim((string) $options['custom_provider_embedding_model']) !== '') {
364 - return trim((string) $options['custom_provider_embedding_model']);
365 - }
366 - if (isset($options['custom_provider_model']) && trim((string) $options['custom_provider_model']) !== '') {
367 - return trim((string) $options['custom_provider_model']);
368 - }
369 - return 'default';
370 -}
371 -
372 -/**
373 - * The EFFECTIVE selected embedding model — what the next embed will actually
374 - * use. With custom-provider embeddings on this is the custom identity in the
375 - * same 'custom:<model>' form stamp_active_embedding_model() records, not the
376 - * inert standard dropdown value. Mismatch-warning comparisons must read this,
377 - * never $options['embedding_model'] directly — the dropdown cannot be
378 - * deselected, so reading it raw flags every correctly-configured custom setup.
379 - */
380 -public static function get_selected_embedding_model($options = null) {
381 - if (!is_array($options)) {
382 - $options = get_option('mxchat_options', array());
383 - }
384 - if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
385 - return 'custom:' . self::resolve_custom_embedding_model($options);
386 - }
387 - return $options['embedding_model'] ?? '';
388 -}
389 -
390 -/**
391 - * Extract the 11-character YouTube video ID from a URL, or '' if the URL is
392 - * not a single-video YouTube link. Single source of truth for both the KB
393 - * ingestion side and the chat render side — do not duplicate this parsing.
394 - * Channel, playlist, and search URLs deliberately return '' (only a URL that
395 - * identifies one video can be embedded).
396 - */
397 -public static function parse_youtube_id($url) {
398 - if (!is_string($url) || $url === '' || !preg_match('#^https?://#i', $url)) {
399 - return '';
400 - }
401 - $host = strtolower((string) wp_parse_url($url, PHP_URL_HOST));
402 - $host = preg_replace('/^(www|m)\./', '', $host);
403 - $path = (string) wp_parse_url($url, PHP_URL_PATH);
404 - $id = '';
405 - if ($host === 'youtu.be') {
406 - $segments = explode('/', ltrim($path, '/'));
407 - $id = $segments[0] ?? '';
408 - } elseif (in_array($host, array('youtube.com', 'youtube-nocookie.com'), true)) {
409 - if (preg_match('#^/(?:shorts|embed|live|v)/([A-Za-z0-9_-]+)#', $path, $m)) {
410 - $id = $m[1];
411 - } elseif ($path === '/watch') {
412 - parse_str((string) wp_parse_url($url, PHP_URL_QUERY), $query_vars);
413 - $id = isset($query_vars['v']) ? (string) $query_vars['v'] : '';
414 - }
415 - }
416 - $id = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $id);
417 - return (strlen($id) === 11) ? $id : '';
418 -}
419 -
420 -/**
421 - * plan-mxchat-20260813-f52492 — video-card gating.
422 - *
423 - * Two standalone options (NOT mxchat_options — they skip the sanitize/autosave
424 - * traps entirely), read here so the gate in the integrator and the fields on
425 - * Knowledge -> Chunking & Retrieval can never disagree about a default.
426 - *
427 - * Master switch. Default ON: the card is existing behavior, and this is an
428 - * opt-out for owners who never want one, not a new feature to opt into.
429 - */
430 -public static function video_embed_enabled() {
431 - return get_option('mxchat_video_embed_enabled', 'on') === 'on';
432 -}
433 -
434 -/**
435 - * The video card's OWN confidence floor, as a 0-1 cosine — deliberately not
436 - * the site-wide Similarity Threshold (default 35). "Good enough to quote in
437 - * the answer" and "good enough to put a video on screen" are different
438 - * questions: retrieval is allowed to be generous because the model still
439 - * decides what to say, whereas the card is asserted to the visitor with no
440 - * such filter. Stored as an int percentage to match the site-wide slider's
441 - * convention; the default (55) sits above it on purpose.
442 - *
443 - * MXCHAT_VIDEO_EMBED_THRESHOLD_DEFAULT is the single source of that number.
444 - */
445 -public static function video_embed_threshold() {
446 - $stored = get_option('mxchat_video_embed_threshold', null);
447 - $percent = ($stored === null || $stored === '')
448 - ? MXCHAT_VIDEO_EMBED_THRESHOLD_DEFAULT
449 - : (int) $stored;
450 - if ($percent < 0) { $percent = 0; }
451 - if ($percent > 100) { $percent = 100; }
452 - // Cast: PHP evaluates 100/100 to int(1), so an unclamped return type would
453 - // vary with the stored value. Callers compare against a cosine — keep it float.
454 - return (float) $percent / 100;
455 -}
456 -
457 -/**
458 52 * UPDATED: Submit or update content (and its embedding) in the database.
459 53 * Stores in Pinecone if enabled, otherwise stores in WordPress DB.
460 54 *
461 55 * @param string $content The content to be embedded.
@@ -472,18 +66,10 @@
472 66
473 67 //error_log('[MXCHAT-DB] Starting database submission for URL: ' . $source_url . ' (Bot: ' . $bot_id . ', Type: ' . $content_type . ')');
474 68 //error_log('[MXCHAT-DB] Content length: ' . strlen($content) . ' bytes');
475 69
476 - // Sanitize the source URL. Internal identities (mxchat:// manual docs,
477 - // upload:// file uploads) are NOT web URLs — esc_url_raw EMPTIES them
478 - // because its protocol list is statically cached and effectively
479 - // unfilterable (measured, plan 945406 / 0485e5) — sanitize those as text
480 - // so upserts stay keyed to a stable identity across re-imports.
481 - if (preg_match('#^(mxchat|upload)://#i', $source_url)) {
482 - $source_url = sanitize_text_field($source_url);
483 - } else {
484 - $source_url = esc_url_raw($source_url);
485 - }
70 + // Sanitize the source URL
71 + $source_url = esc_url_raw($source_url);
486 72
487 73 // Sanitize content_type
488 74 $content_type = sanitize_key($content_type);
489 75 if (empty($content_type)) {
@@ -498,16 +84,9 @@
498 84 // Check if chunking should be applied
499 85 $chunker = MxChat_Chunker::from_settings();
500 86 if ($chunker->should_chunk($safe_content)) {
501 87 //error_log('[MXCHAT-DB] Content exceeds chunk threshold, using chunked submission');
502 - $chunk_result = self::submit_chunked_content($safe_content, $source_url, $api_key, $bot_id, $content_type, $chunker);
503 - // Vector Store mirror gets the entry WHOLE — one file per KB entry,
504 - // OpenAI chunks server-side; local chunking is our own embedding
505 - // concern and never reaches the store (plan 15b5c6).
506 - if ($chunk_result === true && class_exists('MxChat_Vectorstore_Manager')) {
507 - MxChat_Vectorstore_Manager::sync_upsert_entry($source_url, $safe_content, $bot_id, $content_type);
508 - }
509 - return $chunk_result;
88 + return self::submit_chunked_content($safe_content, $source_url, $api_key, $bot_id, $content_type, $chunker);
510 89 }
511 90
512 91 // UPDATED: Generate the embedding using bot-specific configuration
513 92 $embedding_vector = self::generate_embedding($content, $api_key, $bot_id);
@@ -512,13 +91,10 @@
512 91 // UPDATED: Generate the embedding using bot-specific configuration
513 92 $embedding_vector = self::generate_embedding($content, $api_key, $bot_id);
514 93
515 94 if (!is_array($embedding_vector)) {
516 - // Surface the provider's real reason instead of a fixed string (4a7c0a).
517 - $reason = is_wp_error($embedding_vector)
518 - ? $embedding_vector->get_error_message()
519 - : 'Failed to generate embedding for content';
520 - return new WP_Error('embedding_failed', $reason);
95 + //error_log('[MXCHAT-DB] Error: Embedding generation failed');
96 + return new WP_Error('embedding_failed', 'Failed to generate embedding for content');
521 97 }
522 98
523 99 //error_log('[MXCHAT-DB] Embedding generated successfully');
524 100
@@ -525,30 +101,14 @@
525 101 // UPDATED: Check if Pinecone is enabled for this specific bot
526 102 if (self::is_pinecone_enabled_for_bot($bot_id)) {
527 103 //error_log('[MXCHAT-DB] Pinecone is enabled for bot ' . $bot_id . ' - using Pinecone storage');
528 104 // Store in Pinecone only
529 - $result = self::store_in_pinecone_only($embedding_vector, $content, $source_url, $vector_id, $bot_id, $content_type);
530 - // If this URL previously stored as CHUNKED content and the new content fits in a
531 - // single vector, the upsert above only overwrote the base id — the old
532 - // md5(url)_chunk_N vectors would keep serving the stale text. Sweep them.
533 - // Only for a real source_url: chunk ids derive from it, and md5('') is shared
534 - // by legacy URL-less entries so a blind sweep there could hit other entries.
535 - if ($result === true && !empty($source_url)) {
536 - self::cleanup_pinecone_chunk_stragglers($source_url, $bot_id);
537 - }
538 - if ($result === true && class_exists('MxChat_Vectorstore_Manager')) {
539 - MxChat_Vectorstore_Manager::sync_upsert_entry($source_url, $safe_content, $bot_id, $content_type);
540 - }
541 - return $result;
105 + return self::store_in_pinecone_only($embedding_vector, $content, $source_url, $vector_id, $bot_id, $content_type);
542 106 } else {
543 107 //error_log('[MXCHAT-DB] Pinecone not enabled for bot ' . $bot_id . ' - using WordPress storage');
544 108 // Store in WordPress database only
545 109 $embedding_vector_serialized = maybe_serialize($embedding_vector);
546 - $result = self::store_in_wordpress_db($safe_content, $source_url, $embedding_vector_serialized, $table_name, $content_type);
547 - if ($result === true && class_exists('MxChat_Vectorstore_Manager')) {
548 - MxChat_Vectorstore_Manager::sync_upsert_entry($source_url, $safe_content, $bot_id, $content_type);
549 - }
550 - return $result;
110 + return self::store_in_wordpress_db($safe_content, $source_url, $embedding_vector_serialized, $table_name, $content_type);
551 111 }
552 112 }
553 113
554 114 /**
@@ -664,15 +224,11 @@
664 224 // Check if this is truly manual content (no URL at all) vs a real URL that filter_var rejects
665 225 // filter_var(FILTER_VALIDATE_URL) rejects valid URLs with encoded chars, non-ASCII, fragments, etc.
666 226 // Use a looser check: if it starts with http(s):// or has a scheme, it's a URL
667 227 $has_url_scheme = !empty($source_url) && preg_match('#^https?://#i', $source_url);
668 - // upload:// identities (admin document/PDF uploads, plan 0485e5) are stable
669 - // and deduplicable — treat them like URLs so a re-upload UPDATES the row
670 - // instead of minting a fresh manual identity (which would duplicate).
671 - $has_stable_identity = $has_url_scheme || (!empty($source_url) && preg_match('#^upload://#i', $source_url));
672 228 // Treat legacy mxchat.ai source URLs as manual — old bug assigned the site URL to manual entries
673 229 $is_legacy_mxchat_url = $has_url_scheme && strpos($source_url, 'mxchat.ai') !== false;
674 - $is_manual_content = empty($source_url) || $source_url === '' || !$has_stable_identity || $is_legacy_mxchat_url;
230 + $is_manual_content = empty($source_url) || $source_url === '' || !$has_url_scheme || $is_legacy_mxchat_url;
675 231
676 232 if ($is_manual_content) {
677 233 // Generate unique identifier for manual content to prevent overwrites
678 234 $source_url = 'mxchat://manual-content/' . time() . '-' . wp_generate_password(8, false);
@@ -796,11 +352,10 @@
796 352 // ===== UPDATED: Handle manual content with unique vector IDs =====
797 353 if ($vector_id) {
798 354 // Use provided vector ID
799 355 //error_log('[MXCHAT-PINECONE-MAIN] Using provided vector ID: ' . $vector_id);
800 - } elseif (!empty($url) && preg_match('#^(https?|upload)://#i', $url)) {
801 - // For URLs — and stable upload:// identities (plan 0485e5) — use an
802 - // identity-derived ID so a re-import upserts the same vector.
356 + } elseif (!empty($url) && preg_match('#^https?://#i', $url)) {
357 + // For URLs, use URL-based ID (existing behavior)
803 358 $vector_id = md5($url);
804 359 //error_log('[MXCHAT-PINECONE-MAIN] Generated vector ID from URL: ' . $vector_id);
805 360 } else {
806 361 // For manual content (empty/no URL scheme), generate unique ID
@@ -918,104 +473,8 @@
918 473 return true;
919 474 }
920 475
921 476 /**
922 - * Caller-side pre-flight for KB ingestion: can an embedding request be made
923 - * with these options, and which API key should travel downstream?
924 - *
925 - * Custom-provider-aware — generate_embedding() below routes to the custom
926 - * endpoint FIRST and ignores the passed cloud key entirely when
927 - * custom_provider_for_embeddings is on, so on that branch the only real
928 - * requirement is a Base URL. Ingestion callers that gated on a cloud API key
929 - * were killing keyless custom-embeddings sites (local Ollama / LM Studio
930 - * class) before the embed layer could route (plan cbd5fd).
931 - *
932 - * NOTE: reads $options['embedding_model'] raw on purpose — this mirrors
933 - * generate_embedding()'s own routing read, NOT the mismatch-banner's
934 - * "selected" chain (get_selected_embedding_model). The helper must predict
935 - * what the very next embed call will do, byte-for-byte.
936 - *
937 - * Decision only — callers keep their own error-surfacing shape (admin-notice
938 - * transient + redirect, wp_send_json_error, WP_Error, silent return).
939 - *
940 - * @param array|null $options Resolved options (bot-specific where the caller
941 - * has them); null loads the default bot's options.
942 - * @return array {
943 - * @type bool $ok Whether ingestion can proceed.
944 - * @type string $api_key Key to pass downstream ('' on the custom branch —
945 - * generate_embedding() ignores it there).
946 - * @type string $reason Human-readable blocker; '' when $ok.
947 - * @type string $provider Short provider label ('OpenAI', 'Voyage AI',
948 - * 'Google Gemini', 'Custom Provider').
949 - * }
950 - */
951 -public static function embedding_preflight($options = null) {
952 - if (!is_array($options)) {
953 - $options = get_option('mxchat_options');
954 - $options = is_array($options) ? $options : array();
955 - }
956 -
957 - // Custom branch mirrors generate_embedding()'s routing order (custom first).
958 - if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
959 - $base_url = isset($options['custom_provider_base_url']) ? rtrim(trim((string) $options['custom_provider_base_url']), '/') : '';
960 - if ($base_url === '') {
961 - return array(
962 - 'ok' => false,
963 - 'api_key' => '',
964 - // Same string generate_embedding_custom() returns for this state.
965 - 'reason' => __('Custom provider Base URL is not configured.', 'mxchat'),
966 - 'provider' => 'Custom Provider',
967 - );
968 - }
969 - return array('ok' => true, 'api_key' => '', 'reason' => '', 'provider' => 'Custom Provider');
970 - }
971 -
972 - $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
973 - if (strpos($selected_model, 'voyage') === 0) {
974 - $api_key = $options['voyage_api_key'] ?? '';
975 - $provider = 'Voyage AI';
976 - } elseif (strpos($selected_model, 'gemini-embedding') === 0) {
977 - $api_key = $options['gemini_api_key'] ?? '';
978 - $provider = 'Google Gemini';
979 - } else {
980 - $api_key = $options['api_key'] ?? '';
981 - $provider = 'OpenAI';
982 - }
983 -
984 - if (empty($api_key)) {
985 - return array(
986 - 'ok' => false,
987 - 'api_key' => '',
988 - 'reason' => sprintf(
989 - /* translators: %s: embedding provider name */
990 - __('%s API key is not configured. Please add your API key in the settings before submitting content.', 'mxchat'),
991 - $provider
992 - ),
993 - 'provider' => $provider,
994 - );
995 - }
996 -
997 - return array('ok' => true, 'api_key' => $api_key, 'reason' => '', 'provider' => $provider);
998 -}
999 -
1000 -/**
1001 - * Public QUERY-side entry point (plan 876edb). The chat pipeline's
1002 - * MxChat_Integrator::mxchat_generate_embedding() adapter routes through here
1003 - * so the query and index sides share ONE provider-routing implementation —
1004 - * the same endpoints, request bodies, and stamping semantics. The Integrator
1005 - * keeps its own error vocabulary by translating the WP_Error this returns
1006 - * (see the structured error data on every failure path below).
1007 - *
1008 - * @param string $text The text to be embedded.
1009 - * @param string $api_key Caller-resolved API key (per-bot on the query side).
1010 - * @param string $bot_id The bot ID for multi-bot support.
1011 - * @return array|WP_Error The embedding vector, or WP_Error carrying the reason.
1012 - */
1013 -public static function generate_query_embedding($text, $api_key, $bot_id = 'default') {
1014 - return self::generate_embedding($text, $api_key, $bot_id);
1015 -}
1016 -
1017 -/**
1018 477 * UPDATED: Generate an embedding for the given text using bot-specific configuration.
1019 478 *
1020 479 * @param string $text The text to be embedded.
1021 480 * @param string $api_key The API key used for generating embeddings.
@@ -1036,13 +495,9 @@
1036 495 // vectors and query vectors come from the same model. Default-off behavior
1037 496 // below is untouched.
1038 497 if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
1039 498 $custom = self::generate_embedding_custom($text, $options);
1040 - // The custom path already returns a human-readable error string —
1041 - // carry it instead of collapsing to null (plan 4a7c0a). The 'custom'
1042 - // branch marker lets the Integrator adapter map the string back onto
1043 - // its own error codes (876edb).
1044 - return is_array($custom) ? $custom : new WP_Error('embedding_failed', (string) $custom, array('branch' => 'custom'));
499 + return is_array($custom) ? $custom : null;
1045 500 }
1046 501
1047 502 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
1048 503
@@ -1054,13 +509,10 @@
1054 509 $endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/' . $selected_model . ':embedContent';
1055 510 $api_key = $options['gemini_api_key'] ?? '';
1056 511 } else {
1057 512 $endpoint = 'https://api.openai.com/v1/embeddings';
1058 - // Prefer the caller-resolved key when one was passed — the query side
1059 - // resolves per-bot keys at its call sites (integrator adapter, 876edb).
1060 - // Index callers pass the preflight key, which equals this options read,
1061 - // so nothing changes for them.
1062 - $api_key = !empty($api_key) ? $api_key : ($options['api_key'] ?? '');
513 + // Use the bot-specific API key or fallback to passed API key
514 + $api_key = $options['api_key'] ?? $api_key;
1063 515 }
1064 516
1065 517 // Prepare request body based on provider
1066 518 if (strpos($selected_model, 'gemini-embedding') === 0) {
@@ -1111,22 +563,14 @@
1111 563
1112 564 $response = wp_remote_post($endpoint, $args);
1113 565
1114 566 if (is_wp_error($response)) {
1115 - $message = 'Embedding request failed (connection): ' . $response->get_error_message();
1116 - if (class_exists('MxChat_Admin')) {
1117 - MxChat_Admin::mxchat_log_debug('embedding_error', $message, array('model' => $selected_model, 'bot_id' => $bot_id));
1118 - }
1119 - return new WP_Error('embedding_failed', $message, array(
1120 - 'branch' => 'cloud',
1121 - 'kind' => 'connection',
1122 - 'reason' => $response->get_error_message(),
1123 - 'model' => $selected_model,
1124 - ));
567 + //error_log('Error generating embedding for bot ' . $bot_id . ': ' . $response->get_error_message());
568 + return null;
1125 569 }
1126 -
570 +
1127 571 $response_body = json_decode(wp_remote_retrieve_body($response), true);
1128 -
572 +
1129 573 // Handle different response formats based on provider
1130 574 if (strpos($selected_model, 'gemini-embedding') === 0) {
1131 575 // Gemini API response format
1132 576 if (isset($response_body['embedding']['values']) && is_array($response_body['embedding']['values'])) {
@@ -1132,9 +576,10 @@
1132 576 if (isset($response_body['embedding']['values']) && is_array($response_body['embedding']['values'])) {
1133 577 self::stamp_active_embedding_model($selected_model);
1134 578 return $response_body['embedding']['values'];
1135 579 } else {
1136 - return self::embedding_failure_error($response, $selected_model, $api_key, $bot_id);
580 + //error_log('Invalid response received from Gemini embedding API for bot ' . $bot_id . ': ' . wp_json_encode($response_body));
581 + return null;
1137 582 }
1138 583 } else {
1139 584 // OpenAI/Voyage API response format
1140 585 if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
@@ -1140,68 +585,15 @@
1140 585 if (isset($response_body['data'][0]['embedding']) && is_array($response_body['data'][0]['embedding'])) {
1141 586 self::stamp_active_embedding_model($selected_model);
1142 587 return $response_body['data'][0]['embedding'];
1143 588 } else {
1144 - return self::embedding_failure_error($response, $selected_model, $api_key, $bot_id);
589 + //error_log('Invalid response received from embedding API for bot ' . $bot_id . ': ' . wp_json_encode($response_body));
590 + return null;
1145 591 }
1146 592 }
1147 593 }
1148 594
1149 595 /**
1150 - * Build a WP_Error carrying the embedding provider's REAL failure reason,
1151 - * and record it in the Debug Mode log. Previously every failure path
1152 - * returned bare null, so customers saw only "Failed to generate embedding
1153 - * for content" / "Failed to store any chunks" with no cause (plan 4a7c0a).
1154 - *
1155 - * The API key never appears in provider response bodies (it travels in the
1156 - * request headers), but the reason is scrubbed for it anyway before it can
1157 - * reach a notice or the debug log.
1158 - */
1159 -private static function embedding_failure_error($response, $selected_model, $api_key, $bot_id) {
1160 - $status = (int) wp_remote_retrieve_response_code($response);
1161 - $raw = (string) wp_remote_retrieve_body($response);
1162 - $decoded = json_decode($raw, true);
1163 -
1164 - // Provider error shapes: OpenAI + Gemini use {"error":{"message":…}};
1165 - // Voyage uses {"detail":…}.
1166 - $reason = '';
1167 - if (is_array($decoded)) {
1168 - if (isset($decoded['error']['message']) && is_string($decoded['error']['message'])) {
1169 - $reason = $decoded['error']['message'];
1170 - } elseif (isset($decoded['detail']) && is_string($decoded['detail'])) {
1171 - $reason = $decoded['detail'];
1172 - }
1173 - }
1174 - if ($reason === '') {
1175 - $reason = ($raw !== '') ? substr($raw, 0, 200) : 'empty or malformed response';
1176 - }
1177 - if (is_string($api_key) && $api_key !== '') {
1178 - $reason = str_replace($api_key, '[redacted]', $reason);
1179 - }
1180 - $reason = substr($reason, 0, 300);
1181 - $message = sprintf('Embedding failed (%s, HTTP %d): %s', $selected_model, $status, $reason);
1182 -
1183 - if (class_exists('MxChat_Admin')) {
1184 - MxChat_Admin::mxchat_log_debug('embedding_error', $message, array(
1185 - 'model' => $selected_model,
1186 - 'status' => $status,
1187 - 'bot_id' => $bot_id,
1188 - ));
1189 - }
1190 -
1191 - // Structured data so the Integrator's query-side adapter can rebuild its
1192 - // typed error contract (auth/rate-limit/quota/invalid-response) without a
1193 - // second transport implementation (876edb). Additive — message unchanged.
1194 - return new WP_Error('embedding_failed', $message, array(
1195 - 'branch' => 'cloud',
1196 - 'status' => $status,
1197 - 'error_type' => (is_array($decoded) && isset($decoded['error']['type']) && is_string($decoded['error']['type'])) ? $decoded['error']['type'] : '',
1198 - 'reason' => $reason,
1199 - 'model' => $selected_model,
1200 - ));
1201 -}
1202 -
1203 -/**
1204 596 * Generate an embedding via a Custom (OpenAI-compatible) provider's /embeddings route.
1205 597 * Shared by every embedding entry point so the KNOWLEDGE-BASE INDEX side and the
1206 598 * QUERY side route through the same model when the opt-in
1207 599 * 'custom_provider_for_embeddings' setting is on. Mirrors the query-path logic in
@@ -1229,11 +621,12 @@
1229 621 $api_key = isset($options['custom_provider_api_key']) ? trim((string) $options['custom_provider_api_key']) : '';
1230 622 $auth_scheme = isset($options['custom_provider_auth_scheme']) ? $options['custom_provider_auth_scheme'] : 'bearer';
1231 623 $api_version = isset($options['custom_provider_api_version']) ? trim((string) $options['custom_provider_api_version']) : '';
1232 624
1233 - // Embedding model: shared resolver (dedicated embedding model -> chat model
1234 - // -> 'default') — the mismatch warning's "selected" side reads the same chain.
1235 - $model = self::resolve_custom_embedding_model($options);
625 + // Embedding model: prefer the dedicated custom_provider_embedding_model, fall back to the chat model.
626 + $model = (isset($options['custom_provider_embedding_model']) && trim((string) $options['custom_provider_embedding_model']) !== '')
627 + ? trim((string) $options['custom_provider_embedding_model'])
628 + : ((isset($options['custom_provider_model']) && trim((string) $options['custom_provider_model']) !== '') ? trim((string) $options['custom_provider_model']) : 'default');
1236 629
1237 630 $embed_url = $base_url . '/embeddings';
1238 631 if (!empty($api_version)) {
1239 632 $embed_url .= (strpos($embed_url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($api_version);
@@ -1253,13 +646,9 @@
1253 646 'body' => wp_json_encode(['input' => $text, 'model' => $model]),
1254 647 'timeout' => 60,
1255 648 ]);
1256 649 if (is_wp_error($response)) {
1257 - return self::log_custom_embedding_failure(
1258 - 'Connection error when generating embeddings (custom provider): ' . $response->get_error_message(),
1259 - $model,
1260 - $api_key
1261 - );
650 + return 'Connection error when generating embeddings (custom provider): ' . $response->get_error_message();
1262 651 }
1263 652
1264 653 $status = wp_remote_retrieve_response_code($response);
1265 654 $body = json_decode(wp_remote_retrieve_body($response), true);
@@ -1264,14 +653,9 @@
1264 653 $status = wp_remote_retrieve_response_code($response);
1265 654 $body = json_decode(wp_remote_retrieve_body($response), true);
1266 655 if ($status !== 200) {
1267 656 $msg = isset($body['error']['message']) ? $body['error']['message'] : 'HTTP ' . $status;
1268 - return self::log_custom_embedding_failure(
1269 - 'Custom embedding endpoint error: ' . $msg,
1270 - $model,
1271 - $api_key,
1272 - (int) $status
1273 - );
657 + return 'Custom embedding endpoint error: ' . $msg;
1274 658 }
1275 659 if (isset($body['data'][0]['embedding']) && is_array($body['data'][0]['embedding'])) {
1276 660 // Stamp the custom model identity so the active-embedding-model mismatch
1277 661 // warning reflects the real (custom) model rather than the built-in setting.
@@ -1277,42 +661,12 @@
1277 661 // warning reflects the real (custom) model rather than the built-in setting.
1278 662 self::stamp_active_embedding_model('custom:' . $model);
1279 663 return $body['data'][0]['embedding'];
1280 664 }
1281 - return self::log_custom_embedding_failure('Invalid embedding response from custom provider.', $model, $api_key);
665 + return 'Invalid embedding response from custom provider.';
1282 666 }
1283 667
1284 668 /**
1285 - * Record a custom-provider embedding failure in the Debug Mode log, then
1286 - * return the message unchanged so callers keep their string-error contract.
1287 - * The cloud branch has logged its failures since 4a7c0a; the custom branch
1288 - * never did, so chat-side failures on Custom-provider installs were
1289 - * invisible to Debug Mode despite the 3.2.18 readme saying otherwise
1290 - * (plan 71e4b6). Same scrub-then-log shape as embedding_failure_error().
1291 - *
1292 - * @param string $message Human-readable failure (the caller's return value).
1293 - * @param string $model Resolved custom embedding model.
1294 - * @param string $api_key Scrubbed out of the logged message if it ever appears.
1295 - * @param int $status HTTP status when one was received, 0 otherwise.
1296 - * @return string The (scrubbed) message.
1297 - */
1298 -private static function log_custom_embedding_failure($message, $model, $api_key, $status = 0) {
1299 - if (is_string($api_key) && $api_key !== '') {
1300 - $message = str_replace($api_key, '[redacted]', $message);
1301 - }
1302 -
1303 - if (class_exists('MxChat_Admin')) {
1304 - $context = array('model' => 'custom:' . $model);
1305 - if ($status > 0) {
1306 - $context['status'] = $status;
1307 - }
1308 - MxChat_Admin::mxchat_log_debug('embedding_error', $message, $context);
1309 - }
1310 -
1311 - return $message;
1312 -}
1313 -
1314 -/**
1315 669 * Submit content as multiple chunks
1316 670 *
1317 671 * Splits large content into chunks, generates embeddings for each,
1318 672 * and stores them with chunk metadata for later reassembly.
@@ -1331,26 +685,10 @@
1331 685
1332 686 //error_log('[MXCHAT-CHUNK-DEBUG] Starting chunked submission for: ' . $source_url);
1333 687 //error_log('[MXCHAT-CHUNK-DEBUG] Content length: ' . strlen($content) . ' chars');
1334 688
1335 - // URL-less (manual) content needs a minted identity BEFORE chunk ids are derived:
1336 - // every chunk id is md5(source_url)_chunk_N, so with source_url = '' EVERY long
1337 - // manual document shared the md5('') prefix — and the clean-slate delete below
1338 - // wiped the PREVIOUS manual entry's chunks each time a new one was added. The
1339 - // single-vector paths already mint (mxchat:// in WP, manual_* in Pinecone); this
1340 - // was the one storage path that didn't. Keep an mxchat:// identity if the caller
1341 - // already carries one.
1342 - if (strpos($source_url, 'mxchat://') !== 0 && strpos($source_url, 'upload://') !== 0 && !preg_match('#^https?://#i', $source_url)) {
1343 - $source_url = 'mxchat://manual-content/' . time() . '-' . wp_generate_password(8, false);
1344 - }
1345 -
1346 - // First, delete any existing chunks for this URL (clean slate).
1347 - // Mirror-suspended: this is a re-store, not an entry removal — the Vector
1348 - // Store file is replaced (or kept, on hash match) by the caller's
1349 - // sync_upsert_entry after storage succeeds.
1350 - self::$vectorstore_mirror_suspended = true;
689 + // First, delete any existing chunks for this URL (clean slate)
1351 690 $delete_result = self::delete_chunks_for_url($source_url, $bot_id);
1352 - self::$vectorstore_mirror_suspended = false;
1353 691 if (is_wp_error($delete_result)) {
1354 692 //error_log('[MXCHAT-CHUNK-DEBUG] Warning: Failed to delete existing chunks: ' . $delete_result->get_error_message());
1355 693 // Continue anyway - we'll overwrite with upsert
1356 694 }
@@ -1370,11 +708,8 @@
1370 708 return new WP_Error('chunking_failed', 'Content could not be split into chunks');
1371 709 }
1372 710
1373 711 $errors = array();
1374 - $embed_failures = 0;
1375 - $first_embed_reason = '';
1376 - $first_store_reason = '';
1377 712 $is_pinecone = self::is_pinecone_enabled_for_bot($bot_id);
1378 713
1379 714 foreach ($chunks as $index => $chunk_text) {
1380 715 // Generate chunk metadata
@@ -1414,17 +749,10 @@
1414 749 // Generate embedding for this chunk
1415 750 $embedding_vector = self::generate_embedding($chunk_text, $api_key, $bot_id);
1416 751
1417 752 if (!is_array($embedding_vector)) {
1418 - // Track embedding failures separately from storage failures, and
1419 - // keep the first provider reason seen — the two failure classes
1420 - // have opposite remedies (API key vs Pinecone/DB) (plan 4a7c0a).
1421 - $embed_failures++;
1422 - $reason = is_wp_error($embedding_vector) ? $embedding_vector->get_error_message() : '';
1423 - if ($reason !== '' && $first_embed_reason === '') {
1424 - $first_embed_reason = $reason;
1425 - }
1426 - $errors[] = new WP_Error('embedding_failed', 'Failed to generate embedding for chunk ' . $index . ($reason !== '' ? ' — ' . $reason : ''));
753 + $errors[] = new WP_Error('embedding_failed', 'Failed to generate embedding for chunk ' . $index);
754 + //error_log('[MXCHAT-CHUNK] Failed to generate embedding for chunk ' . $index);
1427 755 continue;
1428 756 }
1429 757
1430 758 if ($is_pinecone) {
@@ -1454,44 +782,20 @@
1454 782 }
1455 783
1456 784 if (is_wp_error($result)) {
1457 785 $errors[] = $result;
1458 - if ($first_store_reason === '') {
1459 - $first_store_reason = $result->get_error_message();
1460 - }
786 + //error_log('[MXCHAT-CHUNK] Failed to store chunk ' . $index . ': ' . $result->get_error_message());
1461 787 }
1462 788 }
1463 789
1464 790 if (count($errors) === $total_chunks) {
1465 - // Say WHICH stage failed — "failed to store" used to cover pure
1466 - // embedding failures too, sending customers to debug Pinecone when
1467 - // the problem was their embedding API key (plan 4a7c0a).
1468 - if ($embed_failures === $total_chunks) {
1469 - return new WP_Error('chunking_failed',
1470 - 'Failed to store any chunks — every chunk failed to embed'
1471 - . ($first_embed_reason !== '' ? ': ' . $first_embed_reason : '')
1472 - . ' Check the embedding provider API key and model under MxChat Settings.');
1473 - }
1474 - if ($embed_failures === 0) {
1475 - return new WP_Error('chunking_failed',
1476 - 'Failed to store any chunks — embeddings generated but storage failed'
1477 - . ($first_store_reason !== '' ? ': ' . $first_store_reason : '')
1478 - . ' Check the knowledge base storage (Pinecone index or database).');
1479 - }
1480 - return new WP_Error('chunking_failed', sprintf(
1481 - 'Failed to store any chunks — %d failed to embed%s and %d failed to store%s',
1482 - $embed_failures,
1483 - $first_embed_reason !== '' ? ' (' . $first_embed_reason . ')' : '',
1484 - $total_chunks - $embed_failures,
1485 - $first_store_reason !== '' ? ' (' . $first_store_reason . ')' : ''
1486 - ));
791 + return new WP_Error('chunking_failed', 'Failed to store any chunks');
1487 792 }
1488 793
1489 794 if (!empty($errors)) {
1490 - $detail = $first_embed_reason !== '' ? $first_embed_reason : $first_store_reason;
795 + //error_log('[MXCHAT-CHUNK] Completed with ' . count($errors) . ' errors out of ' . $total_chunks . ' chunks');
1491 796 return new WP_Error('chunking_partial_failure',
1492 - sprintf('Failed to store %d of %d chunks', count($errors), $total_chunks)
1493 - . ($detail !== '' ? ' — first error: ' . $detail : ''));
797 + sprintf('Failed to store %d of %d chunks', count($errors), $total_chunks));
1494 798 }
1495 799
1496 800 //error_log('[MXCHAT-CHUNK] Successfully stored all ' . $total_chunks . ' chunks');
1497 801 return true;
@@ -1614,14 +918,8 @@
1614 918 */
1615 919 public static function delete_chunks_for_url($source_url, $bot_id = 'default') {
1616 920 //error_log('[MXCHAT-CHUNK-DELETE] Deleting chunks for URL: ' . $source_url);
1617 921
1618 - // Entry removal — mirror it to the Vector Store (unless a storage routine
1619 - // is mid-re-store, see $vectorstore_mirror_suspended).
1620 - if (!self::$vectorstore_mirror_suspended && class_exists('MxChat_Vectorstore_Manager')) {
1621 - MxChat_Vectorstore_Manager::sync_delete_entry($source_url, $bot_id);
1622 - }
1623 -
1624 922 if (self::is_pinecone_enabled_for_bot($bot_id)) {
1625 923 return self::delete_pinecone_chunks_by_url($source_url, $bot_id);
1626 924 } else {
1627 925 return self::delete_wordpress_chunks_by_url($source_url);
@@ -1630,109 +928,8 @@
1630 928
1631 929 /**
1632 930 * Delete all chunks for a URL from Pinecone
1633 931 */
1634 -/**
1635 - * Delete leftover md5(url)_chunk_N vectors after a URL's content was re-stored
1636 - * as a SINGLE vector (content shrank below the chunk threshold on edit/re-import).
1637 - * Unlike delete_pinecone_chunks_by_url this leaves the base id alone — the caller
1638 - * just upserted the new content there. Failure is logged, not fatal: the save
1639 - * itself succeeded, and the next save retries the sweep.
1640 - */
1641 -private static function cleanup_pinecone_chunk_stragglers($source_url, $bot_id) {
1642 - if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1643 - $pinecone_options = get_option('mxchat_pinecone_addon_options');
1644 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1645 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1646 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1647 - } else {
1648 - $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
1649 - if (empty($bot_pinecone_config)) {
1650 - $pinecone_options = get_option('mxchat_pinecone_addon_options');
1651 - $api_key = $pinecone_options['mxchat_pinecone_api_key'] ?? '';
1652 - $host = $pinecone_options['mxchat_pinecone_host'] ?? '';
1653 - $namespace = $pinecone_options['mxchat_pinecone_namespace'] ?? '';
1654 - } else {
1655 - $api_key = $bot_pinecone_config['api_key'] ?? '';
1656 - $host = $bot_pinecone_config['host'] ?? '';
1657 - $namespace = $bot_pinecone_config['namespace'] ?? '';
1658 - }
1659 - }
1660 -
1661 - if (empty($host) || empty($api_key)) {
1662 - return;
1663 - }
1664 -
1665 - $stragglers = array();
1666 -
1667 - // Pinecone /vectors/list is a GET endpoint with query-string parameters (a POST
1668 - // answers 200-with-an-empty-body, which reads as "no stragglers").
1669 - $query_params = array(
1670 - 'prefix' => md5($source_url) . '_chunk_',
1671 - 'limit' => 100,
1672 - );
1673 - if (!empty($namespace)) {
1674 - $query_params['namespace'] = $namespace;
1675 - }
1676 -
1677 - $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
1678 -
1679 - do {
1680 - $list_response = wp_remote_get($list_url, array(
1681 - 'headers' => array(
1682 - 'Api-Key' => $api_key,
1683 - 'accept' => 'application/json',
1684 - ),
1685 - 'timeout' => 30,
1686 - ));
1687 -
1688 - if (is_wp_error($list_response) || wp_remote_retrieve_response_code($list_response) !== 200) {
1689 - break;
1690 - }
1691 -
1692 - $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
1693 - if (!empty($list_data['vectors'])) {
1694 - foreach ($list_data['vectors'] as $vector) {
1695 - if (isset($vector['id'])) {
1696 - $stragglers[] = $vector['id'];
1697 - }
1698 - }
1699 - }
1700 -
1701 - $next_token = $list_data['pagination']['next'] ?? '';
1702 - if (empty($next_token)) {
1703 - break;
1704 - }
1705 -
1706 - $query_params['paginationToken'] = $next_token;
1707 - $list_url = "https://{$host}/vectors/list?" . http_build_query($query_params);
1708 - } while (true);
1709 -
1710 - if (empty($stragglers)) {
1711 - return;
1712 - }
1713 -
1714 - $delete_body = array('ids' => $stragglers);
1715 - if (!empty($namespace)) {
1716 - $delete_body['namespace'] = $namespace;
1717 - }
1718 -
1719 - $delete_response = wp_remote_post("https://{$host}/vectors/delete", array(
1720 - 'headers' => array(
1721 - 'Api-Key' => $api_key,
1722 - 'accept' => 'application/json',
1723 - 'content-type' => 'application/json'
1724 - ),
1725 - 'body' => wp_json_encode($delete_body),
1726 - 'timeout' => 30
1727 - ));
1728 -
1729 - if ((is_wp_error($delete_response) || wp_remote_retrieve_response_code($delete_response) !== 200)
1730 - && class_exists('MxChat_Admin') && method_exists('MxChat_Admin', 'mxchat_log_debug')) {
1731 - MxChat_Admin::mxchat_log_debug('pinecone_error', 'Failed to sweep stale chunk vectors after single-vector re-store', array('source_url' => $source_url, 'bot_id' => $bot_id, 'count' => count($stragglers)));
1732 - }
1733 -}
1734 -
1735 932 private static function delete_pinecone_chunks_by_url($source_url, $bot_id) {
1736 933 // Get Pinecone configuration
1737 934 if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
1738 935 $pinecone_options = get_option('mxchat_pinecone_addon_options');
@@ -1866,297 +1063,6 @@
1866 1063 }
1867 1064
1868 1065 //error_log('[MXCHAT-CHUNK-DELETE] Deleted ' . $result . ' rows from WordPress DB');
1869 1066 return true;
1870 -}
1871 -
1872 -/**
1873 - * Hybrid keyword boost (plan-38ffa1): detect whether the WP-DB knowledge
1874 - * table can serve the keyword leg via a MySQL FULLTEXT index, creating the
1875 - * index if needed. Detection runs once and caches the answer in the
1876 - * mxchat_hybrid_keyword_capability option ('fulltext' | 'like'); pass
1877 - * $force to re-detect. LIKE is the graceful fallback for shared hosts
1878 - * whose ALTER fails — the feature works either way, FULLTEXT just ranks
1879 - * better and scales.
1880 - *
1881 - * @param bool $force Re-run detection even if a cached answer exists.
1882 - * @return string 'fulltext' or 'like'
1883 - */
1884 -public static function mxchat_hybrid_detect_capability($force = false) {
1885 - $cached = get_option('mxchat_hybrid_keyword_capability', '');
1886 - if (!$force && in_array($cached, array('fulltext', 'like'), true)) {
1887 - return $cached;
1888 - }
1889 -
1890 - global $wpdb;
1891 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
1892 -
1893 - $index_exists = $wpdb->get_var("SHOW INDEX FROM {$table} WHERE Key_name = 'mxchat_content_ft'");
1894 - if (!$index_exists) {
1895 - // Suppress the visible error on hosts where this is not permitted —
1896 - // failure is an expected, handled outcome (LIKE fallback).
1897 - $suppress = $wpdb->suppress_errors(true);
1898 - $wpdb->query("ALTER TABLE {$table} ADD FULLTEXT INDEX mxchat_content_ft (article_content)");
1899 - $wpdb->suppress_errors($suppress);
1900 - $index_exists = $wpdb->get_var("SHOW INDEX FROM {$table} WHERE Key_name = 'mxchat_content_ft'");
1901 - }
1902 -
1903 - $capability = $index_exists ? 'fulltext' : 'like';
1904 - update_option('mxchat_hybrid_keyword_capability', $capability);
1905 - return $capability;
1906 -}
1907 -
1908 -/**
1909 - * Public entry for re-embedding already-stored content in place (wp mxchat
1910 - * rtl-repair, plan d1e6f7). Thin wrapper so the repair CLI gets the exact
1911 - * provider routing the import path uses — the repaired vector must come from
1912 - * the same model family the bot indexes with, or retrieval stays broken.
1913 - */
1914 -public static function regenerate_embedding($text, $api_key, $bot_id = 'default') {
1915 - return self::generate_embedding($text, $api_key, $bot_id);
1916 -}
1917 -
1918 -/**
1919 - * Restore logical character order in PDF-extracted RTL text (plan 32bf9e).
1920 - *
1921 - * The bundled Smalot parser only un-reverses text runs tagged with the
1922 - * ReversedChars marked-content operator (Word emits it; LibreOffice and most
1923 - * other producers do not), so their Hebrew/Arabic PDFs extract in visual
1924 - * (reversed) order and embed/search as garbage. This is OUR post-processing
1925 - * seam over getText() — the parser itself is never patched (it gets replaced
1926 - * wholesale on library updates).
1927 - *
1928 - * Heuristic and deliberately conservative, per line:
1929 - * - lines without strong RTL codepoints are untouched (a fully-Latin line in
1930 - * an RTL document therefore stays as extracted — accepted limitation);
1931 - * - Arabic presentation forms are a definitive visual-order signal (they only
1932 - * appear in shaped output): de-shape to base letters and reverse;
1933 - * - otherwise flip only on positive evidence — Hebrew final-letter position
1934 - * (a sofit at word START only happens in reversed text) or sentence
1935 - * punctuation position (leading in visual order, trailing in logical);
1936 - * - ambiguous lines are left alone: a conservative miss beats corrupting a
1937 - * Word-produced extraction the parser already handled (the double-flip
1938 - * guard this plan's approval named mandatory).
1939 - *
1940 - * @param string $text One extracted page string, straight from getText().
1941 - * @param string $context Caller tag for the Debug Mode entry (site + page).
1942 - * @return string Text with RTL lines restored to logical order.
1943 - */
1944 -public static function normalize_pdf_rtl($text, $context = '') {
1945 - if (!is_string($text) || '' === $text) {
1946 - return $text;
1947 - }
1948 - // Escape hatch for sites whose PDFs already extract logically.
1949 - if (!apply_filters('mxchat_pdf_rtl_normalize', true, $text)) {
1950 - return $text;
1951 - }
1952 - // Fast bail: nothing RTL anywhere in the page.
1953 - if (!preg_match('/[\x{0590}-\x{05FF}\x{0600}-\x{06FF}\x{0750}-\x{077F}\x{FB50}-\x{FDFF}\x{FE70}-\x{FEFF}]/u', $text)) {
1954 - return $text;
1955 - }
1956 -
1957 - $parts = preg_split('/(\R)/u', $text, -1, PREG_SPLIT_DELIM_CAPTURE);
1958 - if (false === $parts) {
1959 - return $text;
1960 - }
1961 -
1962 - $flipped_lines = 0;
1963 - $deshaped_lines = 0;
1964 - foreach ($parts as $i => $part) {
1965 - if ('' === $part || preg_match('/^\R$/u', $part)) {
1966 - continue;
1967 - }
1968 - $was_flipped = false;
1969 - $was_deshaped = false;
1970 - $new = self::pdf_rtl_normalize_line($part, $was_flipped, $was_deshaped);
1971 - if ($new !== $part) {
1972 - $parts[$i] = $new;
1973 - }
1974 - if ($was_flipped) {
1975 - $flipped_lines++;
1976 - }
1977 - if ($was_deshaped) {
1978 - $deshaped_lines++;
1979 - }
1980 - }
1981 -
1982 - if (($flipped_lines || $deshaped_lines) && class_exists('MxChat_Admin')) {
1983 - MxChat_Admin::mxchat_log_debug('pdf_rtl_normalized', 'RTL PDF text restored to logical order', array(
1984 - 'context' => (string) $context,
1985 - 'lines_flipped' => $flipped_lines,
1986 - 'lines_deshaped' => $deshaped_lines,
1987 - 'decision' => 'visual-order extraction detected',
1988 - ));
1989 - }
1990 -
1991 - return implode('', $parts);
1992 -}
1993 -
1994 -/**
1995 - * Normalize one line. Sets $flipped/$deshaped for the caller's debug entry.
1996 - */
1997 -private static function pdf_rtl_normalize_line($line, &$flipped, &$deshaped) {
1998 - $flipped = false;
1999 - $deshaped = false;
2000 -
2001 - if (!preg_match('/[\x{0590}-\x{05FF}\x{0600}-\x{06FF}\x{0750}-\x{077F}\x{FB50}-\x{FDFF}\x{FE70}-\x{FEFF}]/u', $line)) {
2002 - return $line;
2003 - }
2004 -
2005 - $has_forms = (bool) preg_match('/[\x{FB50}-\x{FDFF}\x{FE70}-\x{FEFF}]/u', $line);
2006 - $work = $line;
2007 - if ($has_forms) {
2008 - $work = strtr($work, self::pdf_rtl_deshape_map());
2009 - $deshaped = ($work !== $line);
2010 - }
2011 -
2012 - $verdict = 'ambiguous';
2013 - if ($has_forms) {
2014 - // Shaped glyph codepoints only exist in visual-order output.
2015 - $verdict = 'visual';
2016 - } else {
2017 - // Strong-direction dominance gate first: an LTR-dominant line with an
2018 - // embedded RTL word is not flip material.
2019 - $rtl_count = preg_match_all('/[\x{0590}-\x{05FF}\x{0600}-\x{06FF}\x{0750}-\x{077F}]/u', $work, $m_rtl);
2020 - $ltr_count = preg_match_all('/[A-Za-z]/u', $work, $m_ltr);
2021 - if ($rtl_count < 1 || $rtl_count <= $ltr_count) {
2022 - return $line;
2023 - }
2024 -
2025 - // Hebrew final letters (ך ם ן ף ץ) end words in logical text; one at
2026 - // a word START (Hebrew letter follows, none precedes) is reversal
2027 - // evidence. Positional, so it survives the line being reversed.
2028 - $sofit_initial = preg_match_all('/(?<![\x{05D0}-\x{05EA}])[\x{05DA}\x{05DD}\x{05DF}\x{05E3}\x{05E5}](?=[\x{05D0}-\x{05EA}])/u', $work, $m_i);
2029 - $sofit_terminal = preg_match_all('/(?<=[\x{05D0}-\x{05EA}])[\x{05DA}\x{05DD}\x{05DF}\x{05E3}\x{05E5}](?![\x{05D0}-\x{05EA}])/u', $work, $m_t);
2030 - if ($sofit_initial > $sofit_terminal) {
2031 - $verdict = 'visual';
2032 - } elseif ($sofit_terminal > $sofit_initial) {
2033 - $verdict = 'logical';
2034 - } else {
2035 - // Sentence punctuation lands at the visual LEFT edge of an RTL
2036 - // line, i.e. the START of a visual-order extraction.
2037 - $trimmed = trim($work);
2038 - $starts_punct = (bool) preg_match('/^[.?!:;,]/u', $trimmed);
2039 - $ends_punct = (bool) preg_match('/[.?!:;,]$/u', $trimmed);
2040 - if ($starts_punct && !$ends_punct) {
2041 - $verdict = 'visual';
2042 - } elseif ($ends_punct && !$starts_punct) {
2043 - $verdict = 'logical';
2044 - }
2045 - }
2046 - }
2047 -
2048 - if ('visual' !== $verdict) {
2049 - // Ambiguous or logical: hand back the original line UNLESS we
2050 - // de-shaped (de-shaping alone is always safe — same letters, same
2051 - // order, un-ligated).
2052 - return $deshaped ? $work : $line;
2053 - }
2054 -
2055 - $flipped = true;
2056 - return self::pdf_rtl_flip_line($work);
2057 -}
2058 -
2059 -/**
2060 - * Reverse a visual-order line into logical order: full character reversal,
2061 - * mirror paired punctuation, then re-reverse embedded LTR runs (Latin words
2062 - * and digit sequences, incl. Arabic-Indic digits) so they stay readable.
2063 - */
2064 -private static function pdf_rtl_flip_line($line) {
2065 - $chars = preg_split('//u', $line, -1, PREG_SPLIT_NO_EMPTY);
2066 - if (false === $chars) {
2067 - return $line;
2068 - }
2069 - $reversed = implode('', array_reverse($chars));
2070 - $reversed = strtr($reversed, array(
2071 - '(' => ')', ')' => '(',
2072 - '[' => ']', ']' => '[',
2073 - '{' => '}', '}' => '{',
2074 - '<' => '>', '>' => '<',
2075 - ));
2076 - $restored = preg_replace_callback(
2077 - '/[0-9A-Za-z\x{0660}-\x{0669}\x{06F0}-\x{06F9}](?:[0-9A-Za-z\x{0660}-\x{0669}\x{06F0}-\x{06F9} .,\'"%\-:\/]*[0-9A-Za-z\x{0660}-\x{0669}\x{06F0}-\x{06F9}])?/u',
2078 - function ($m) {
2079 - $run = preg_split('//u', $m[0], -1, PREG_SPLIT_NO_EMPTY);
2080 - return false === $run ? $m[0] : implode('', array_reverse($run));
2081 - },
2082 - $reversed
2083 - );
2084 - return null === $restored ? $reversed : $restored;
2085 -}
2086 -
2087 -/**
2088 - * Arabic presentation forms (A + B) -> base letters. Built once from range
2089 - * specs rather than ~120 hand-written literal entries; every codepoint in a
2090 - * range maps to the same base sequence (isolated/final/initial/medial forms
2091 - * of one letter are contiguous in the FE70 block).
2092 - */
2093 -private static function pdf_rtl_deshape_map() {
2094 - static $map = null;
2095 - if (null !== $map) {
2096 - return $map;
2097 - }
2098 - $ranges = array(
2099 - // Form B harakat (each pair = standalone + tatweel-joined form).
2100 - array(0xFE70, 0xFE71, array(0x064B)), array(0xFE72, 0xFE72, array(0x064C)),
2101 - array(0xFE74, 0xFE74, array(0x064D)), array(0xFE76, 0xFE77, array(0x064E)),
2102 - array(0xFE78, 0xFE79, array(0x064F)), array(0xFE7A, 0xFE7B, array(0x0650)),
2103 - array(0xFE7C, 0xFE7D, array(0x0651)), array(0xFE7E, 0xFE7F, array(0x0652)),
2104 - // Form B letters.
2105 - array(0xFE80, 0xFE80, array(0x0621)), array(0xFE81, 0xFE82, array(0x0622)),
2106 - array(0xFE83, 0xFE84, array(0x0623)), array(0xFE85, 0xFE86, array(0x0624)),
2107 - array(0xFE87, 0xFE88, array(0x0625)), array(0xFE89, 0xFE8C, array(0x0626)),
2108 - array(0xFE8D, 0xFE8E, array(0x0627)), array(0xFE8F, 0xFE92, array(0x0628)),
2109 - array(0xFE93, 0xFE94, array(0x0629)), array(0xFE95, 0xFE98, array(0x062A)),
2110 - array(0xFE99, 0xFE9C, array(0x062B)), array(0xFE9D, 0xFEA0, array(0x062C)),
2111 - array(0xFEA1, 0xFEA4, array(0x062D)), array(0xFEA5, 0xFEA8, array(0x062E)),
2112 - array(0xFEA9, 0xFEAA, array(0x062F)), array(0xFEAB, 0xFEAC, array(0x0630)),
2113 - array(0xFEAD, 0xFEAE, array(0x0631)), array(0xFEAF, 0xFEB0, array(0x0632)),
2114 - array(0xFEB1, 0xFEB4, array(0x0633)), array(0xFEB5, 0xFEB8, array(0x0634)),
2115 - array(0xFEB9, 0xFEBC, array(0x0635)), array(0xFEBD, 0xFEC0, array(0x0636)),
2116 - array(0xFEC1, 0xFEC4, array(0x0637)), array(0xFEC5, 0xFEC8, array(0x0638)),
2117 - array(0xFEC9, 0xFECC, array(0x0639)), array(0xFECD, 0xFED0, array(0x063A)),
2118 - array(0xFED1, 0xFED4, array(0x0641)), array(0xFED5, 0xFED8, array(0x0642)),
2119 - array(0xFED9, 0xFEDC, array(0x0643)), array(0xFEDD, 0xFEE0, array(0x0644)),
2120 - array(0xFEE1, 0xFEE4, array(0x0645)), array(0xFEE5, 0xFEE8, array(0x0646)),
2121 - array(0xFEE9, 0xFEEC, array(0x0647)), array(0xFEED, 0xFEEE, array(0x0648)),
2122 - array(0xFEEF, 0xFEF0, array(0x0649)), array(0xFEF1, 0xFEF4, array(0x064A)),
2123 - // Form B lam-alef ligatures decompose to two letters.
2124 - array(0xFEF5, 0xFEF6, array(0x0644, 0x0622)), array(0xFEF7, 0xFEF8, array(0x0644, 0x0623)),
2125 - array(0xFEF9, 0xFEFA, array(0x0644, 0x0625)), array(0xFEFB, 0xFEFC, array(0x0644, 0x0627)),
2126 - // Form A: Persian / Urdu letters in common use.
2127 - array(0xFB56, 0xFB59, array(0x067E)), array(0xFB66, 0xFB69, array(0x0679)),
2128 - array(0xFB7A, 0xFB7D, array(0x0686)), array(0xFB88, 0xFB89, array(0x0688)),
2129 - array(0xFB8A, 0xFB8B, array(0x0698)), array(0xFB8E, 0xFB91, array(0x06A9)),
2130 - array(0xFB92, 0xFB95, array(0x06AF)), array(0xFBA6, 0xFBA9, array(0x06C1)),
2131 - array(0xFBAA, 0xFBAD, array(0x06BE)), array(0xFBAE, 0xFBAF, array(0x06D2)),
2132 - array(0xFBFC, 0xFBFF, array(0x06CC)),
2133 - );
2134 - $map = array();
2135 - foreach ($ranges as $range) {
2136 - $base = '';
2137 - foreach ($range[2] as $cp) {
2138 - $base .= self::pdf_rtl_cp_to_utf8($cp);
2139 - }
2140 - for ($cp = $range[0]; $cp <= $range[1]; $cp++) {
2141 - $map[self::pdf_rtl_cp_to_utf8($cp)] = $base;
2142 - }
2143 - }
2144 - return $map;
2145 -}
2146 -
2147 -/**
2148 - * Codepoint to UTF-8 without ext-intl / mbstring entity tricks (PHP 7.2 floor).
2149 - */
2150 -private static function pdf_rtl_cp_to_utf8($cp) {
2151 - if ($cp < 0x80) {
2152 - return chr($cp);
2153 - }
2154 - if ($cp < 0x800) {
2155 - return chr(0xC0 | ($cp >> 6)) . chr(0x80 | ($cp & 0x3F));
2156 - }
2157 - if ($cp < 0x10000) {
2158 - return chr(0xE0 | ($cp >> 12)) . chr(0x80 | (($cp >> 6) & 0x3F)) . chr(0x80 | ($cp & 0x3F));
2159 - }
2160 - return chr(0xF0 | ($cp >> 18)) . chr(0x80 | (($cp >> 12) & 0x3F)) . chr(0x80 | (($cp >> 6) & 0x3F)) . chr(0x80 | ($cp & 0x3F));
2161 1067 }
2162 1068 }