indexer = new RSVL_Indexer(); } /** The Training tab's data. Only the open tab gets more than its panel. */ public function get_tab_data(bool $is_active): array { $data = [ 'is_active' => $is_active, 'active_panel' => self::get_active_panel(), 'show_product_fields' => true, ]; if (! $is_active) { return $data; } // Shown above the sub-panel nav, since every panel here trains and so // every panel is blocked when no embedding provider is connected. $data['embedding_connected'] = RSVL_Settings_Page::is_embedding_connected(); $data['model_tab_url'] = RSVL_Settings_Page::get_settings_tab_url('model'); RSVL_Indexer::check_stalled_run(); $data['indexing'] = RSVL_Indexing_Status::progress(); $data['pages'] = $this->get_training_pages_panel_data(); $data['instructions'] = $this->get_instructions_panel_data(); // The Pro panels. Their templates here only show the upgrade notice, // which needs no data; Pro fills these in along with its own templates. $data['notes'] = []; $data['pdfs'] = []; $data['custom_fields'] = []; $data['product_fields'] = []; return $data; } /** * The Pages table's count label ("32 pages"), for the AJAX actions that * add or remove its rows. The count is only known here, so the script * shows this rather than building the label itself. */ public function get_pages_count_label(): string { [, $training_posts, $trained_posts] = $this->get_pages_table_posts(); return self::format_pages_count(count($trained_posts) + count($training_posts)); } /** * Trainable posts of a single type for the Add Page modal, fetched on demand * when the user opens that type's tab. * * Loading one type at a time keeps the heavy posts_per_page => -1 query out * of page load entirely, and means a site whose bulk is one giant post type * only pays for it if the user actually opens that tab. * * Comes with the tab's count label ("8 posts"). The script fetches the * type again after anything changes what it has left to add, rather than * counting and labelling rows itself. */ public function get_modal_data(string $post_type): array { // Posts training now are already in the Pages table. $excluded_ids = array_merge( RSVL_Index::get_post_ids(), array_map('intval', wp_list_pluck($this->indexer->get_training_queue_posts(), 'post_id')) ); $posts = $this->get_trainable_posts_for_modal_type($post_type, $excluded_ids); return [ 'posts' => $posts, 'count_label' => self::format_modal_count($post_type, count($posts)), ]; } /** * AJAX: Return the Add Page modal's trainable posts for a single post type. * * Loaded on demand when the user opens that type's tab, so neither the post * query nor the payload is part of every Training tab page load, and a giant * post type is only fetched if its tab is actually opened. */ public function ajax_get_modal_posts() { $post_type = RSVL_Ajax::key('post_type'); if ('' === $post_type) { wp_send_json_error(['message' => __('Missing post type.', 'resolver-ai-chatbot')]); } wp_send_json_success($this->get_modal_data($post_type)); } /** * AJAX: Queue a specific selection of post IDs for indexing. */ public function ajax_train_selected_posts() { $post_ids = RSVL_Ajax::ids('post_ids'); if (empty($post_ids)) { wp_send_json_error(['message' => __('No pages selected.', 'resolver-ai-chatbot')]); } $count = $this->indexer->start_selective_index($post_ids); if (false === $count) { wp_send_json_error(['message' => __('Training is already in progress. Please wait for it to finish before starting another run.', 'resolver-ai-chatbot')]); } wp_send_json_success([ /* translators: %s: number of posts queued for training. */ 'message' => sprintf(_n('Indexing started for %s post.', 'Indexing started for %s posts.', $count, 'resolver-ai-chatbot'), number_format_i18n($count)), 'total' => $count, 'count_label' => $this->get_pages_count_label(), ]); } /** * AJAX: Get indexing status. */ public function ajax_indexing_status() { RSVL_Indexer::check_stalled_run(); wp_send_json_success([ 'indexing' => RSVL_Indexing_Status::get(), 'stats' => RSVL_Index::get_stats(), ]); } /** * AJAX: Return indexed status and stats for a list of post IDs. */ public function ajax_get_indexed_post_data() { $post_ids = RSVL_Ajax::ids('post_ids'); $data = RSVL_Index::get_post_data($post_ids); // The Pages table's Date and Fields columns, as on page load. foreach ($data as &$info) { $info['indexed_date'] = ! empty($info['indexed_at']) ? wp_date(resolver_date_format(), strtotime($info['indexed_at'])) : '—'; $info['found_field_labels'] = array_map('resolver_field_label', (array) ($info['found_fields'] ?? [])); } unset($info); wp_send_json_success($data); } /** * AJAX: Remove a single post from the knowledge base index. */ public function ajax_delete_indexed_post() { $post_id = RSVL_Ajax::int('post_id'); if (! $post_id) { wp_send_json_error(['message' => __('Invalid post ID.', 'resolver-ai-chatbot')]); } RSVL_Index::delete_post($post_id); wp_send_json_success(['count_label' => $this->get_pages_count_label()]); } /** * AJAX: Remove multiple posts from the knowledge base index in one request. */ public function ajax_bulk_delete_indexed_posts() { $post_ids = RSVL_Ajax::ids('post_ids'); if (empty($post_ids)) { wp_send_json_error(['message' => __('No posts specified.', 'resolver-ai-chatbot')]); } foreach ($post_ids as $post_id) { RSVL_Index::delete_post($post_id); } wp_send_json_success(['count_label' => $this->get_pages_count_label()]); } /** * AJAX: Run a training batch now. A non-blocking loopback request from * RSVL_Indexer, never the browser. */ public function ajax_run_index_batch() { $batch_id = RSVL_Ajax::text('batch_id'); if ($batch_id === '') { wp_die(); } $this->indexer->process_bulk_index_batch($batch_id, RSVL_Ajax::int('offset')); wp_die(); } /** * When private or password-protected pages stop being trainable (their * setting was turned off), remove the ones that were trained. * * @param mixed $old_value Previous option value. * @param mixed $new_value New option value. */ public function maybe_purge_private_posts($old_value, $new_value): void { foreach (['include_private_pages', 'include_password_pages'] as $key) { if (! empty($old_value[ $key ]) && empty($new_value[ $key ])) { RSVL_Index::delete_posts_where(fn (WP_Post $post) => ! RSVL_Indexer::is_trainable($post)); return; } } } /** * The Pages table's rows: [indexed posts grouped by type, posts training * now, trained posts not training]. */ private function get_pages_table_posts(): array { $indexed_posts = RSVL_Index::get_posts_by_type(); // PDFs train from their own panel and never belong in the Pages table // (get_posts_by_type() likewise excludes them once trained). $training_posts = array_values(array_filter( $this->indexer->get_training_queue_posts(), static fn ($post) => 'rsvl-pdf' !== $post['type_slug'] )); $trained_posts = $this->get_trained_posts_sorted_by_date($indexed_posts, wp_list_pluck($training_posts, 'post_id')); return [$indexed_posts, $training_posts, $trained_posts]; } private function get_training_pages_panel_data(): array { $settings = RSVL_Settings::all(); $embedding_provider = RSVL_Settings_Page::get_current_embedding_provider(); $include_private = ! empty($settings['include_private_pages']); $include_protected = ! empty($settings['include_password_pages']); [$indexed_posts, $training_posts, $trained_posts] = $this->get_pages_table_posts(); $indexed_by_id = $this->flatten_indexed_posts_by_id($indexed_posts); $count_pages = count($trained_posts) + count($training_posts); $field_filters = $this->get_field_filter_options($trained_posts, $training_posts, $indexed_by_id); return [ 'is_pro' => resolver_is_pro(), 'embedding_connected' => RSVL_Settings_Page::is_provider_connected($embedding_provider), 'api_keys_url' => admin_url('options-connectors.php'), 'model_tab_url' => RSVL_Settings_Page::get_settings_tab_url('model'), 'include_private' => $include_private, 'include_protected' => $include_protected, 'include_excerpts' => ! empty($settings['include_excerpts']), 'include_author' => ! empty($settings['include_author']), 'disable_auto_retrain' => ! empty($settings['disable_auto_retrain']), 'auto_train_types' => wp_list_pluck($this->get_modal_post_types(), 'label'), 'auto_train_selected' => (array) ($settings['auto_train_post_types'] ?? []), 'training_rows' => $this->build_training_queue_rows($training_posts, $indexed_by_id), 'trained_rows' => $this->build_trained_rows($trained_posts), 'has_any_rows' => $count_pages > 0, 'count_label' => self::format_pages_count($count_pages), 'table_types' => $this->get_table_type_filters($training_posts, $trained_posts), 'custom_field_filters' => $field_filters['custom'], 'wc_field_filters' => $field_filters['wc'], // The Add Page modal loads one post type at a time via AJAX (see // get_modal_data()), so a site with thousands of posts pays nothing // on page load, and only pays for a given type when its tab is // opened. The shell just needs the (cheaply-computed) list of type // tabs. 'modal' => $this->get_modal_shell_data(), ]; } /** * Static shell data for the Add Page modal — the type tabs and which one is * active by default. Uses wp_count_posts() (cheap, cached) rather than the * full post query so it's safe to compute on every Training tab page load. */ private function get_modal_shell_data(): array { $statuses = RSVL_Indexer::trainable_statuses(); $tabs = []; foreach ($this->get_modal_post_types() as $slug => $post_type) { $counts = (array) wp_count_posts($slug); if (array_sum(array_intersect_key($counts, array_flip($statuses))) > 0) { $tabs[] = [ 'slug' => $slug, 'label' => $post_type->labels->name, 'icon' => RSVL_Settings_Page::get_post_type_icon($slug), ]; } } // Default to Pages when present, otherwise the first available tab. $default_type = ''; foreach ($tabs as $tab) { if ('page' === $tab['slug']) { $default_type = 'page'; break; } } if ('' === $default_type && ! empty($tabs)) { $default_type = $tabs[0]['slug']; } return [ 'type_tabs' => $tabs, 'default_type' => $default_type, ]; } /** * Index entries keyed by post ID, so training rows for retrains can show * their existing url, char count, and indexed date. */ private function flatten_indexed_posts_by_id(array $grouped): array { $by_id = []; foreach ($grouped as $group) { foreach ($group['posts'] as $post) { $by_id[ $post['post_id'] ] = $post; } } return $by_id; } /** * Indexed posts flattened and sorted newest-first. Posts currently * mid-retrain are skipped so they don't also show a stale "Trained" row. */ private function get_trained_posts_sorted_by_date(array $grouped, array $training_post_ids): array { $posts = []; foreach ($grouped as $group) { foreach ($group['posts'] as $post) { if (in_array($post['post_id'], $training_post_ids, true)) { continue; } $posts[] = $post; } } usort($posts, fn ($a, $b) => strcmp($b['indexed_at'] ?? '', $a['indexed_at'] ?? '')); return $posts; } private function build_training_queue_rows(array $training_posts, array $indexed_by_id): array { $rows = []; foreach ($training_posts as $post) { $previous = $indexed_by_id[ $post['post_id'] ] ?? []; $rows[] = $this->build_table_row(array_merge($post, [ 'url' => ! empty($previous['url']) ? $previous['url'] : ($post['url'] ?? ''), 'char_count' => $previous['char_count'] ?? 0, 'found_fields' => $previous['found_fields'] ?? [], 'indexed_at' => $previous['indexed_at'] ?? '', ]), 'training'); } return $rows; } private function build_trained_rows(array $trained_posts): array { return array_map(fn ($post) => $this->build_table_row($post, 'trained'), $trained_posts); } /** * Shape one indexed/queued post into a render-ready training table row. */ private function build_table_row(array $post, string $status): array { $training_error = $post['training_error'] ?? null; if ($training_error && 'training' !== $status) { $status = 'error'; } return [ 'post_id' => $post['post_id'], 'title' => $post['title'], 'url' => $post['url'] ?? '', 'type_slug' => $post['type_slug'], 'type_label' => $post['type_label'], 'type_icon' => RSVL_Settings_Page::get_post_type_icon($post['type_slug']), 'char_count' => ! empty($post['char_count']) ? number_format_i18n($post['char_count']) : '—', 'found_fields' => array_values((array) ($post['found_fields'] ?? [])), 'found_field_labels' => array_map('resolver_field_label', array_values((array) ($post['found_fields'] ?? []))), 'indexed_date' => ! empty($post['indexed_at']) ? wp_date(resolver_date_format(), strtotime($post['indexed_at'])) : '—', 'status' => $status, 'training_error' => $training_error, ]; } /** * Unique type slugs/labels present in the table, in priority order. */ private function get_table_type_filters(array $training_posts, array $trained_posts): array { $types = []; foreach (array_merge($training_posts, $trained_posts) as $row) { if (! isset($types[ $row['type_slug'] ])) { $types[ $row['type_slug'] ] = $row['type_label']; } } uksort($types, [RSVL_Settings_Page::class, 'compare_post_type_priority']); return $types; } /** * Unique found_fields across all rows, for the field-filter dropdown, as * key => name, sorted by name: custom fields under 'custom', WooCommerce * product fields under 'wc'. */ private function get_field_filter_options(array $trained_posts, array $training_posts, array $indexed_by_id): array { $rows = $trained_posts; foreach ($training_posts as $post) { if (isset($indexed_by_id[ $post['post_id'] ])) { $rows[] = $indexed_by_id[ $post['post_id'] ]; } } $fields = []; foreach ($rows as $row) { foreach ((array) ($row['found_fields'] ?? []) as $field) { $fields[ $field ] = resolver_field_label($field); } } asort($fields); /** * Filter the field-filter dropdown's groups. Every field starts under * 'custom'; Pro moves its WooCommerce product fields to 'wc'. * * @param array $groups ['custom' => array, 'wc' => array], * each field key => name. */ return (array) apply_filters('resolver_field_filter_groups', ['custom' => $fields, 'wc' => []]); } /** * Public post types for the Add Page modal, pages and posts first. */ private function get_modal_post_types(): array { $post_types = RSVL_Settings_Page::get_public_post_types(); uksort($post_types, [RSVL_Settings_Page::class, 'compare_post_type_priority']); return $post_types; } /** * Posts of a single type selectable in the Add Page modal: the trainable * ones. The requested type is validated against the modal's allowed post * types before querying. */ private function get_trainable_posts_for_modal_type(string $slug, array $excluded_ids): array { $modal_types = $this->get_modal_post_types(); if (! isset($modal_types[ $slug ])) { return []; } $post_type = $modal_types[ $slug ]; $posts = []; foreach (get_posts(RSVL_Indexer::trainable_query_args($slug)) as $post) { if (in_array($post->ID, $excluded_ids, true)) { continue; } $posts[] = [ 'id' => $post->ID, 'title' => $post->post_title, 'url' => get_permalink($post->ID), 'type_slug' => $slug, 'type_label' => $post_type->labels->singular_name, 'type_icon' => RSVL_Settings_Page::get_post_type_icon($slug), ]; } return $posts; } private function get_instructions_panel_data(): array { $settings = RSVL_Settings::all(); $defaults = RSVL_Settings::defaults(); $sections = [ [ 'key' => 'identity', 'setting' => 'prompt_identity', 'label' => __('Identity & Purpose', 'resolver-ai-chatbot'), 'description' => __('Tell your chatbot who it represents, what you do, and what it\'s here to help with.', 'resolver-ai-chatbot'), ], [ 'key' => 'rules', 'setting' => 'prompt_rules', 'label' => __('Rules & Limits', 'resolver-ai-chatbot'), 'description' => __('Tell your chatbot what it should never say. This prevents a confidently incorrect bot.', 'resolver-ai-chatbot'), ], [ 'key' => 'tone', 'setting' => 'prompt_tone', 'label' => __('Tone & Style', 'resolver-ai-chatbot'), 'description' => __('How your chatbot should sound, and how long its replies should be.', 'resolver-ai-chatbot'), ], [ 'key' => 'fallback', 'setting' => 'prompt_fallback', 'label' => __('Human Requests', 'resolver-ai-chatbot'), 'description' => __('What your chatbot should do when someone explicitly requests a person to talk to. Include your email address(es) or contact page here.', 'resolver-ai-chatbot'), ], ]; foreach ($sections as &$section) { $section['value'] = $settings[ $section['setting'] ] ?? ''; $section['default'] = $defaults[ $section['setting'] ]; } unset($section); return [ 'sections' => $sections, 'base' => [ 'value' => $settings['prompt_base'] ?? '', 'default' => $defaults['prompt_base'], 'description' => __('General context for the chatbot. Avoid editing these instructions unless you find it necessary.', 'resolver-ai-chatbot'), ], ]; } /** Register the AJAX endpoints and hooks. */ public static function register(): void { $controller = new self(); add_action('update_option_resolver_settings', [$controller, 'maybe_purge_private_posts'], 10, 2); // Nonce and capability checks are applied by RSVL_Ajax::register() // from the manifest below — never inside the handlers themselves. RSVL_Ajax::register(self::ajax_routes(), $controller); } /** The open Training panel (the panel query parameter), Pages by default. */ public static function get_active_panel(): string { $allowed = ['pages', 'notes', 'pdfs', 'instructions', 'custom-fields', 'product-fields']; return isset($_GET['panel']) && in_array($_GET['panel'], $allowed, true) ? sanitize_key($_GET['panel']) : 'pages'; } private static function format_pages_count(int $count): string { /* translators: %s: number of pages. */ return sprintf(_n('%s page', '%s pages', $count, 'resolver-ai-chatbot'), number_format_i18n($count)); } /** * The Add Page modal's count for a post type's tab ("8 posts"). A post * type's own labels have no plural forms for other languages, so types * without a string here are counted as items. */ private static function format_modal_count(string $post_type, int $count): string { $number = number_format_i18n($count); return match ($post_type) { 'page' => self::format_pages_count($count), /* translators: %s: number of posts. */ 'post' => sprintf(_n('%s post', '%s posts', $count, 'resolver-ai-chatbot'), $number), /* translators: %s: number of products. */ 'product' => sprintf(_n('%s product', '%s products', $count, 'resolver-ai-chatbot'), $number), /* translators: %s: number of items, e.g. of a custom post type. */ default => sprintf(_n('%s item', '%s items', $count, 'resolver-ai-chatbot'), $number), }; } /** * The Training tab's AJAX endpoints, with the guards they require. * * @return array */ private static function ajax_routes(): array { $manifest = RSVL_Ajax::admin_routes([ 'resolver_indexing_status' => 'ajax_indexing_status', 'resolver_get_modal_posts' => 'ajax_get_modal_posts', 'resolver_train_selected_posts' => 'ajax_train_selected_posts', 'resolver_delete_indexed_post' => 'ajax_delete_indexed_post', 'resolver_bulk_delete_indexed_posts' => 'ajax_bulk_delete_indexed_posts', 'resolver_get_indexed_post_data' => 'ajax_get_indexed_post_data', ]); // Internal loopback target: started by RSVL_Indexer, never by the // browser, and nothing reads its response — so it carries the batch // nonce and fails with a bare wp_die() rather than a JSON body. $manifest['resolver_run_index_batch'] = [ 'method' => 'ajax_run_index_batch', 'nonce' => 'batch', 'silent' => true, ]; return $manifest; } }