load_dependencies(); $this->initialize_components(); $this->setup_hooks(); AnswerRails_Utils::debug_log('knowledge-loader:construct-complete'); } /** * Load required files */ private function load_dependencies() { AnswerRails_Utils::debug_log('knowledge-loader:load-dependencies'); $plugin_path = plugin_dir_path(dirname(__FILE__)); // Load repository and data layer require_once $plugin_path . 'includes/knowledge/class-answerrails-knowledge-repository.php'; // Load OpenAI gateway (lives with its provider since the Phase 1 // provider abstraction) require_once $plugin_path . 'includes/providers/class-answerrails-openai-gateway.php'; // Load link processor require_once $plugin_path . 'includes/knowledge/class-answerrails-link-processor.php'; // Load chunker + chunk store (lexical fallback / #89 foundation) require_once $plugin_path . 'includes/knowledge/class-answerrails-knowledge-chunker.php'; require_once $plugin_path . 'includes/knowledge/class-answerrails-chunk-store.php'; // Load service layer require_once $plugin_path . 'includes/knowledge/class-answerrails-knowledge-service.php'; // Load REST controller require_once $plugin_path . 'includes/class-answerrails-knowledge-rest.php'; // Load admin interface if in admin if (is_admin()) { require_once $plugin_path . 'admin/class-answerrails-knowledge-admin.php'; // DO NOT LOAD THE LIST TABLE HERE! // The list table extends WP_List_Table which is only available // when rendering admin pages, not during plugin initialization. // The Knowledge Admin class will load it on-demand when needed. } } /** * Initialize components */ private function initialize_components() { AnswerRails_Utils::debug_log('knowledge-loader:init-components:start'); // Initialize shared service instance $this->service = new AnswerRails_Knowledge_Service(); // Initialize REST controller with shared service $this->rest_controller = new AnswerRails_Knowledge_REST($this->service); // Initialize admin interface if in admin with shared service if (is_admin()) { $this->admin = new AnswerRails_Knowledge_Admin($this->service); } AnswerRails_Utils::debug_log('knowledge-loader:init-components:complete'); } /** * Setup WordPress hooks */ private function setup_hooks() { AnswerRails_Utils::debug_log('knowledge-loader:setup-hooks'); // ⚠ This class registers NO post type. The main plugin // (answerrails.php) is the single registration site for all post // types: a duplicate here runs later on the same hook and silently // replaces the main registration, discarding its manage_options // capabilities mapping. // Schedule sync for URL and Post types add_action('answerrails_hourly_sync', array($this, 'scheduled_sync')); if (!wp_next_scheduled('answerrails_hourly_sync')) { AnswerRails_Utils::debug_log('knowledge-loader:schedule-hourly-sync'); wp_schedule_event(time(), 'hourly', 'answerrails_hourly_sync'); } // Hook into post updates for auto-sync add_action('post_updated', array($this, 'on_post_updated'), 10, 3); // Migration hook for existing installations add_action('admin_init', array($this, 'maybe_migrate_data')); } /** * Scheduled sync for auto-sync enabled items */ public function scheduled_sync() { AnswerRails_Utils::debug_log('knowledge-loader:scheduled-sync:start'); $page = 1; $per_page = 100; do { $items = $this->service->get_all_knowledge_items(array( 'meta_query' => array( array( 'key' => '_answerrails_auto_sync', 'value' => '1', 'compare' => '=' ) ), 'per_page' => $per_page, 'page' => $page, )); if (empty($items)) { AnswerRails_Utils::debug_log('knowledge-loader:scheduled-sync:empty-batch', array( 'page' => $page, )); break; } AnswerRails_Utils::debug_log('knowledge-loader:scheduled-sync:batch', array( 'page' => $page, 'batch_size' => count($items), )); foreach ($items as $item) { if (!$this->service->type_tracks_source($item['type'])) { continue; } // #174: unregistered types are sync-frozen — skip // without calling sync_item() (whose own refusal remains the // authoritative gate) so the hourly cron neither re-reads // absent-module sources nor logs an error per frozen item // per hour. if (is_wp_error($this->service->source_type_registered($item['type']))) { continue; } $this->service->sync_item($item['id']); } $page++; } while (count($items) === $per_page); AnswerRails_Utils::debug_log('knowledge-loader:scheduled-sync:complete', array( 'final_page' => $page - 1, )); } /** * Handle post updates for knowledge sync */ public function on_post_updated($post_id, $post_after, $post_before) { // Skip if this is a knowledge item itself if (get_post_type($post_id) === 'answerrails_kb') { AnswerRails_Utils::debug_log('knowledge-loader:on-post-updated:skip-self', array( 'post_id' => $post_id, )); return; } // Find knowledge items linked to this post $linked_items = $this->service->get_all_knowledge_items(array( 'meta_query' => array( array( 'key' => '_answerrails_source_post_id', 'value' => $post_id, 'compare' => '=' ) ), 'per_page' => -1 )); AnswerRails_Utils::debug_log('knowledge-loader:on-post-updated:linked-items', array( 'post_id' => $post_id, 'linked_count' => is_array($linked_items) ? count($linked_items) : 0, )); foreach ($linked_items as $item) { // Update if auto-sync is enabled if (!empty($item['auto_sync'])) { $this->service->sync_item($item['id']); } } } /** * Check if migration is needed */ public function maybe_migrate_data() { $migration_version = get_option('answerrails_knowledge_migration_version', '0'); AnswerRails_Utils::debug_log('knowledge-loader:maybe-migrate', array( 'version' => $migration_version, )); if (version_compare($migration_version, '2.0.0', '<')) { $this->migrate_from_legacy(); update_option('answerrails_knowledge_migration_version', '2.0.0'); AnswerRails_Utils::debug_log('knowledge-loader:maybe-migrate:updated', array( 'new_version' => '2.0.0', )); } } /** * Migrate from legacy monolithic structure */ private function migrate_from_legacy() { AnswerRails_Utils::debug_log('knowledge-loader:migrate-from-legacy:start'); $this->migrate_legacy_post_types(); // Add link metadata to existing knowledge items $this->add_link_metadata_to_existing(); AnswerRails_Utils::debug_log('knowledge-loader:migrate-from-legacy:complete'); } /** * Add link metadata to existing knowledge items */ private function add_link_metadata_to_existing() { if (!class_exists('AnswerRails_Link_Processor')) { return; } $batch_size = 100; $offset = 0; $link_processor = new AnswerRails_Link_Processor(); AnswerRails_Utils::debug_log('knowledge-loader:add-link-metadata:start'); do { $ids = get_posts(array( 'post_type' => 'answerrails_kb', 'post_status' => 'publish', 'posts_per_page' => $batch_size, 'offset' => $offset, 'orderby' => 'ID', 'order' => 'ASC', 'fields' => 'ids', )); if (empty($ids)) { AnswerRails_Utils::debug_log('knowledge-loader:add-link-metadata:empty-batch', array( 'offset' => $offset, )); break; } AnswerRails_Utils::debug_log('knowledge-loader:add-link-metadata:batch', array( 'offset' => $offset, 'batch_size' => count($ids), )); foreach ($ids as $post_id) { $post = get_post($post_id); if (!$post) { continue; } $type = get_post_meta($post_id, '_answerrails_knowledge_type', true); // Generate title variations $variations = $link_processor->generate_variations($post->post_title); update_post_meta($post_id, '_answerrails_title_variations', $variations); switch ($type) { case 'post': case 'product': case 'url': update_post_meta($post_id, '_answerrails_is_linkable', '1'); break; case 'document': case 'json': update_post_meta($post_id, '_answerrails_is_linkable', '0'); break; } update_post_meta( $post_id, '_answerrails_is_external', $type === 'url' ? '1' : '0' ); } $offset += count($ids); } while (count($ids) === $batch_size); AnswerRails_Utils::debug_log('knowledge-loader:add-link-metadata:complete', array( 'processed' => $offset, )); } /** * Convert legacy post types in manageable batches */ private function migrate_legacy_post_types() { $batch_size = 100; $offset = 0; do { $old_posts = get_posts(array( 'post_type' => array('c3d_knowledge', 'c3d_chatlog', 'c3d_idea'), 'post_status' => 'any', 'posts_per_page' => $batch_size, 'offset' => $offset, 'orderby' => 'ID', 'order' => 'ASC', )); if (empty($old_posts)) { break; } // #331 D5: the target names are mapped explicitly. This built // them with str_replace('c3d_', 'ar_', …), and the `ar_` post // types were retired by the rename, so the old form pointed at // names nothing registers. // // ⚠ NO SITE REACHES THIS (ruled 2026-10-24: no install still // carries c3d_ post types). It is corrected rather than left // dangling so the rename leaves nothing aimed at a retired name; // the function as a whole is dead and is reported as a // dead-code candidate, not removed here. // // An explicit map rather than a second str_replace, because the // prefixes no longer correspond one-to-one: c3d_knowledge // becomes answerrails_kb, since answerrails_knowledge is 21 // characters against register_post_type()'s cap of 20. $legacy_types = array( 'c3d_knowledge' => 'answerrails_kb', 'c3d_chatlog' => 'answerrails_chatlog', 'c3d_idea' => 'answerrails_idea', ); foreach ($old_posts as $old_post) { if (!isset($legacy_types[$old_post->post_type])) { continue; } $new_type = $legacy_types[$old_post->post_type]; wp_update_post(array( 'ID' => $old_post->ID, 'post_type' => $new_type, )); $old_meta = get_post_meta($old_post->ID); foreach ($old_meta as $key => $values) { if (strpos($key, '_c3d_') !== 0) { continue; } $new_key = str_replace('_c3d_', '_answerrails_', $key); $value = is_array($values) ? reset($values) : $values; update_post_meta($old_post->ID, $new_key, $value); delete_post_meta($old_post->ID, $key); } } $offset += count($old_posts); } while (count($old_posts) === $batch_size); } /** * Get service instance for external use */ public function get_service() { return $this->service; } } // ===== TEMPORARY COMPATIBILITY BRIDGE ===== // This allows the old class name to work during transition if (!class_exists('AnswerRails_Knowledge')) { class AnswerRails_Knowledge extends AnswerRails_Knowledge_Loader { // Empty extension for backward compatibility } }