| @@ -255,10 +255,8 @@ | ||
| 255 | 255 | $max_attempts = 3; |
| 256 | 256 | $current_content = $safe_content; |
| 257 | 257 | $result = false; |
| 258 | 258 | |
| 259 | - $active_model = self::get_active_embedding_model(); | |
| 260 | - | |
| 261 | 259 | while ($attempt <= $max_attempts && $result === false) { |
| 262 | 260 | try { |
| 263 | 261 | if ($existing_id) { |
| 264 | 262 | //error_log('[MXCHAT-DB] Found existing entry (ID: ' . $existing_id . '). Updating... (Attempt ' . $attempt . ')'); |
| @@ -271,13 +269,12 @@ | ||
| 271 | 269 | 'article_content' => $current_content, |
| 272 | 270 | 'embedding_vector' => $embedding_vector_serialized, |
| 273 | 271 | 'source_url' => $source_url, |
| 274 | 272 | 'content_type' => $content_type, |
| 275 | - 'embedding_model' => $active_model, | |
| 276 | 273 | 'timestamp' => current_time('mysql'), |
| 277 | 274 | ), |
| 278 | 275 | array('id' => $existing_id), |
| 279 | - array('%s','%s','%s','%s','%s','%s','%s'), | |
| 276 | + array('%s','%s','%s','%s','%s','%s'), | |
| 280 | 277 | array('%d') |
| 281 | 278 | ); |
| 282 | 279 | } else { |
| 283 | 280 | //error_log('[MXCHAT-DB] No existing entry found. Inserting new row... (Attempt ' . $attempt . ')'); |
| @@ -291,12 +288,11 @@ | ||
| 291 | 288 | 'article_content' => $current_content, |
| 292 | 289 | 'embedding_vector' => $embedding_vector_serialized, |
| 293 | 290 | 'source_url' => $source_url, // Now unique for manual content |
| 294 | 291 | 'content_type' => $content_type, |
| 295 | - 'embedding_model' => $active_model, | |
| 296 | 292 | 'timestamp' => current_time('mysql'), |
| 297 | 293 | ), |
| 298 | - array('%s','%s','%s','%s','%s','%s','%s') | |
| 294 | + array('%s','%s','%s','%s','%s','%s') | |
| 299 | 295 | ); |
| 300 | 296 | } |
| 301 | 297 | |
| 302 | 298 | if ($result === false) { |
| @@ -419,9 +415,8 @@ | ||
| 419 | 415 | 'type' => $content_type, // Now supports: post, page, pdf, url, manual, product, etc. |
| 420 | 416 | 'last_updated' => time(), |
| 421 | 417 | 'created_at' => time(), // Add creation timestamp |
| 422 | 418 | 'bot_id' => $bot_id, // Add bot identification |
| 423 | - 'embedding_model' => self::get_active_embedding_model() // 3.2.3: track which model produced this vector | |
| 424 | 419 | ); |
| 425 | 420 | |
| 426 | 421 | $vector_data = array( |
| 427 | 422 | 'id' => $vector_id, |
| @@ -493,11 +488,20 @@ | ||
| 493 | 488 | } else { |
| 494 | 489 | $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id); |
| 495 | 490 | $options = !empty($bot_options) ? $bot_options : get_option('mxchat_options'); |
| 496 | 491 | } |
| 497 | - | |
| 492 | + | |
| 493 | + // Opt-in: when the custom provider is selected for embeddings, route the KB | |
| 494 | + // INDEX side through the same custom endpoint the query side uses, so stored | |
| 495 | + // vectors and query vectors come from the same model. Default-off behavior | |
| 496 | + // below is untouched. | |
| 497 | + if (isset($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') { | |
| 498 | + $custom = self::generate_embedding_custom($text, $options); | |
| 499 | + return is_array($custom) ? $custom : null; | |
| 500 | + } | |
| 501 | + | |
| 498 | 502 | $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002'; |
| 499 | - | |
| 503 | + | |
| 500 | 504 | // Determine endpoint and API key based on model |
| 501 | 505 | if (strpos($selected_model, 'voyage') === 0) { |
| 502 | 506 | $endpoint = 'https://api.voyageai.com/v1/embeddings'; |
| 503 | 507 | $api_key = $options['voyage_api_key'] ?? ''; |
| @@ -588,8 +592,81 @@ | ||
| 588 | 592 | } |
| 589 | 593 | } |
| 590 | 594 | |
| 591 | 595 | /** |
| 596 | + * Generate an embedding via a Custom (OpenAI-compatible) provider's /embeddings route. | |
| 597 | + * Shared by every embedding entry point so the KNOWLEDGE-BASE INDEX side and the | |
| 598 | + * QUERY side route through the same model when the opt-in | |
| 599 | + * 'custom_provider_for_embeddings' setting is on. Mirrors the query-path logic in | |
| 600 | + * MxChat_Integrator::mxchat_generate_embedding_custom() but takes an explicit | |
| 601 | + * $options array so it is callable statically from utils + knowledge-manager. | |
| 602 | + * | |
| 603 | + * Returns a numeric array (the embedding vector) on success, or a human-readable | |
| 604 | + * error string on failure (so callers expecting a string error, like the | |
| 605 | + * knowledge-manager, can surface it directly; callers expecting array|null wrap it). | |
| 606 | + * | |
| 607 | + * @param string $text Text to embed. | |
| 608 | + * @param array $options The resolved mxchat options (must contain the custom_provider_* keys). | |
| 609 | + * @return array|string Embedding vector on success; error string on failure. | |
| 610 | + */ | |
| 611 | +public static function generate_embedding_custom($text, $options) { | |
| 612 | + if (empty($text)) { | |
| 613 | + return 'No text provided for embedding generation'; | |
| 614 | + } | |
| 615 | + | |
| 616 | + $base_url = isset($options['custom_provider_base_url']) ? rtrim(trim((string) $options['custom_provider_base_url']), '/') : ''; | |
| 617 | + if (empty($base_url)) { | |
| 618 | + return 'Custom provider Base URL is not configured.'; | |
| 619 | + } | |
| 620 | + | |
| 621 | + $api_key = isset($options['custom_provider_api_key']) ? trim((string) $options['custom_provider_api_key']) : ''; | |
| 622 | + $auth_scheme = isset($options['custom_provider_auth_scheme']) ? $options['custom_provider_auth_scheme'] : 'bearer'; | |
| 623 | + $api_version = isset($options['custom_provider_api_version']) ? trim((string) $options['custom_provider_api_version']) : ''; | |
| 624 | + | |
| 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'); | |
| 629 | + | |
| 630 | + $embed_url = $base_url . '/embeddings'; | |
| 631 | + if (!empty($api_version)) { | |
| 632 | + $embed_url .= (strpos($embed_url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($api_version); | |
| 633 | + } | |
| 634 | + | |
| 635 | + $headers = ['Content-Type' => 'application/json']; | |
| 636 | + if (!empty($api_key)) { | |
| 637 | + if ($auth_scheme === 'api-key') { | |
| 638 | + $headers['api-key'] = $api_key; | |
| 639 | + } else { | |
| 640 | + $headers['Authorization'] = 'Bearer ' . $api_key; | |
| 641 | + } | |
| 642 | + } | |
| 643 | + | |
| 644 | + $response = wp_remote_post($embed_url, [ | |
| 645 | + 'headers' => $headers, | |
| 646 | + 'body' => wp_json_encode(['input' => $text, 'model' => $model]), | |
| 647 | + 'timeout' => 60, | |
| 648 | + ]); | |
| 649 | + if (is_wp_error($response)) { | |
| 650 | + return 'Connection error when generating embeddings (custom provider): ' . $response->get_error_message(); | |
| 651 | + } | |
| 652 | + | |
| 653 | + $status = wp_remote_retrieve_response_code($response); | |
| 654 | + $body = json_decode(wp_remote_retrieve_body($response), true); | |
| 655 | + if ($status !== 200) { | |
| 656 | + $msg = isset($body['error']['message']) ? $body['error']['message'] : 'HTTP ' . $status; | |
| 657 | + return 'Custom embedding endpoint error: ' . $msg; | |
| 658 | + } | |
| 659 | + if (isset($body['data'][0]['embedding']) && is_array($body['data'][0]['embedding'])) { | |
| 660 | + // Stamp the custom model identity so the active-embedding-model mismatch | |
| 661 | + // warning reflects the real (custom) model rather than the built-in setting. | |
| 662 | + self::stamp_active_embedding_model('custom:' . $model); | |
| 663 | + return $body['data'][0]['embedding']; | |
| 664 | + } | |
| 665 | + return 'Invalid embedding response from custom provider.'; | |
| 666 | +} | |
| 667 | + | |
| 668 | +/** | |
| 592 | 669 | * Submit content as multiple chunks |
| 593 | 670 | * |
| 594 | 671 | * Splits large content into chunks, generates embeddings for each, |
| 595 | 672 | * and stores them with chunk metadata for later reassembly. |
| @@ -636,8 +713,36 @@ | ||
| 636 | 713 | |
| 637 | 714 | foreach ($chunks as $index => $chunk_text) { |
| 638 | 715 | // Generate chunk metadata |
| 639 | 716 | $chunk_metadata = MxChat_Chunker::create_chunk_metadata($index, $total_chunks, $source_url); |
| 717 | + | |
| 718 | + // AI-Engine-style aliases so external consumers (Pinecone/Qdrant/Chroma) can rely on | |
| 719 | + // a stable shorthand ('source'/'part_index'/'part_total') without parsing our internal names. | |
| 720 | + $chunk_metadata['source'] = $source_url; | |
| 721 | + $chunk_metadata['part_index'] = (int) $index; | |
| 722 | + $chunk_metadata['part_total'] = (int) $total_chunks; | |
| 723 | + | |
| 724 | + /** | |
| 725 | + * Filter the per-chunk metadata blob before it's written to the KB store. | |
| 726 | + * | |
| 727 | + * @param array $chunk_metadata Metadata array (source, part_index, part_total, chunk_index, total_chunks, source_url, parent_url_hash, document_type, ...). | |
| 728 | + * @param string $chunk_text The chunk text being stored. | |
| 729 | + * @param array $context ['bot_id' => string, 'content_type' => string, 'source_url' => string, 'part_index' => int, 'part_total' => int] | |
| 730 | + * @return array Updated metadata array. | |
| 731 | + */ | |
| 732 | + $chunk_metadata = apply_filters( | |
| 733 | + 'mxchat_embedding_chunk_metadata', | |
| 734 | + $chunk_metadata, | |
| 735 | + $chunk_text, | |
| 736 | + array( | |
| 737 | + 'bot_id' => $bot_id, | |
| 738 | + 'content_type' => $content_type, | |
| 739 | + 'source_url' => $source_url, | |
| 740 | + 'part_index' => (int) $index, | |
| 741 | + 'part_total' => (int) $total_chunks, | |
| 742 | + ) | |
| 743 | + ); | |
| 744 | + | |
| 640 | 745 | $chunk_vector_id = MxChat_Chunker::generate_chunk_vector_id($source_url, $index); |
| 641 | 746 | |
| 642 | 747 | //error_log('[MXCHAT-CHUNK] Processing chunk ' . ($index + 1) . '/' . $total_chunks . ' (ID: ' . $chunk_vector_id . ')'); |
| 643 | 748 | |
| @@ -737,9 +842,8 @@ | ||
| 737 | 842 | 'parent_url_hash' => $chunk_metadata['parent_url_hash'], |
| 738 | 843 | 'last_updated' => time(), |
| 739 | 844 | 'created_at' => time(), |
| 740 | 845 | 'bot_id' => $bot_id, |
| 741 | - 'embedding_model' => self::get_active_embedding_model() | |
| 742 | 846 | ); |
| 743 | 847 | |
| 744 | 848 | $vector_data = array( |
| 745 | 849 | 'id' => $vector_id, |
| @@ -792,12 +896,11 @@ | ||
| 792 | 896 | 'article_content' => $content_with_metadata, |
| 793 | 897 | 'embedding_vector' => $embedding_vector_serialized, |
| 794 | 898 | 'source_url' => $source_url, |
| 795 | 899 | 'content_type' => $content_type, |
| 796 | - 'embedding_model' => self::get_active_embedding_model(), | |
| 797 | 900 | 'timestamp' => current_time('mysql') |
| 798 | 901 | ), |
| 799 | - array('%s', '%s', '%s', '%s', '%s', '%s', '%s') | |
| 902 | + array('%s', '%s', '%s', '%s', '%s', '%s') | |
| 800 | 903 | ); |
| 801 | 904 | |
| 802 | 905 | if ($result === false) { |
| 803 | 906 | return new WP_Error('database_failed', 'Failed to insert chunk: ' . $wpdb->last_error); |