knowledge = $knowledge_instance; // Store the knowledge instance // Initialize chat log admin if (!class_exists('AnswerRails_Chatlog_Admin')) { require_once plugin_dir_path(dirname(__FILE__)) . 'admin/class-answerrails-chatlog-admin.php'; } $this->chatlog_admin = new AnswerRails_Chatlog_Admin(); // The ideas admin moved out at #174 S2 (D9): AnswerRails Pro // constructs it and registers the Business Intelligence menu // through the E10 action fired in add_admin_menu(). add_action('admin_menu', array($this, 'add_admin_menu')); add_action('admin_init', array($this, 'register_settings')); add_action('admin_enqueue_scripts', array($this, 'enqueue_admin_assets')); add_action('admin_notices', array($this, 'admin_notices')); // Model-retirement advisories (#78): site-wide hook — the method // itself scopes the mild tier to AnswerRails screens and lets the // urgent/past tiers escalate to every admin screen. add_action('admin_notices', array($this, 'model_retirement_notice')); add_action('admin_init', array($this, 'handle_retirement_snooze')); // #261 D4: consent receipts that could not be written. This admin // owns the notice because it owns the consent surface (the #263 D5 // notice sits with the escalation surface for the mirrored reason). add_action('admin_notices', array($this, 'consent_ledger_failure_notice')); // #103: one-time explanation after migration 1.4.0 switched a // Gemini-configured install to Mistral. add_action('admin_notices', array($this, 'gemini_removal_notice')); add_action('admin_init', array($this, 'handle_gemini_removal_dismiss')); $this->initialize_ajax_handler(); } /** * Add admin menu pages */ public function add_admin_menu() { // Main menu page add_menu_page( __('AnswerRails', 'answerrails'), __('AnswerRails', 'answerrails'), 'manage_options', 'answerrails', array($this, 'overview_page'), 'dashicons-format-chat', 30 ); // Overview add_submenu_page( 'answerrails', __('Overview', 'answerrails'), __('Overview', 'answerrails'), 'manage_options', 'answerrails', array($this, 'overview_page') ); // Chat Logs submenu (decision-12 boundary clarification): seeing // what the assistant told a visitor is ACCOUNTABILITY (trust // layer), not leverage — and the site owner is data controller for // transcripts this plugin stores in their own DB, so access to the // records the GDPR machinery governs belongs beside it. // Conversation ANALYTICS (metrics, quality, gaps) is the Business // Intelligence surface of AnswerRails Pro, a separate plugin, and // registers through E10 below. The viewer's Ideas/Escalation // columns are honest by construction: the capture code is absent // from this build, so nothing is ever recorded and they render "—". add_submenu_page( 'answerrails', __('Chat Logs', 'answerrails'), __('Chat Logs', 'answerrails'), 'manage_options', 'answerrails_chatlogs', array($this->chatlog_admin, 'render_list_page') ); // The Ideas submenu moved out at #174 S2, D9: AnswerRails Pro, a // separate plugin, registers its Business Intelligence menu — // ideas included — through E10 below. // Chat Settings (Core - always available) add_submenu_page( 'answerrails', __('Chat Settings', 'answerrails'), __('Chat Settings', 'answerrails'), 'manage_options', 'answerrails-settings', array($this, 'settings_page') ); // Advanced Features — a static label (#174). On its own the page // is the About AnswerRails Pro surface; when that separate plugin // is installed, its E11 consumers render the real settings // sections there. add_submenu_page( 'answerrails', __('Advanced Features', 'answerrails'), __('Advanced Features', 'answerrails'), 'manage_options', 'answerrails-pro', array($this, 'pro_features_page') ); // Knowledge Base add_submenu_page( 'answerrails', __('Knowledge Base', 'answerrails'), __('Knowledge Base', 'answerrails'), 'manage_options', 'answerrails-knowledge', array($this, 'knowledge_page') ); // Account and support live on the Freemius SDK's own submenu (#95); // the Advanced Features page carries the CTAs. // E10 (#174, `answerrails_admin_menus`): fires after this plugin's // own menus so a registered add-on can add theirs — AnswerRails // Pro's Business Intelligence menu (D9) registers here. do_action('answerrails_admin_menus', array('parent' => 'answerrails')); } /** * Register settings for all sections */ public function register_settings() { // Core settings group // #176: every registration below carries a sanitize_callback — // helpers in AnswerRails_Settings_Sanitise, failure policy D2 // (enums → documented default, numerics → clamped, arrays → drop // invalid entries, valid input byte-identical). Each enum names the // read-time authority its allowed set mirrors; the two layers must // agree (SettingsSanitisationTest pins the agreement). register_setting('answerrails_core_settings', 'answerrails_chat_enabled', array('type' => 'boolean', 'default' => true, 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'boolean'))); // Authority: AnswerRails_Core::should_display_chat() switch // (includes/class-answerrails-core.php) — its default branch is // home_only, and the settings view offers exactly these five. register_setting('answerrails_core_settings', 'answerrails_display_mode', array('type' => 'string', 'default' => 'home_only', 'sanitize_callback' => function ($v) { return AnswerRails_Settings_Sanitise::enum($v, array('disabled', 'home_only', 'all_pages', 'custom_pages', 'shortcode_only'), 'home_only'); })); // Mixed id/token list, NOT a plain id list — see page_targets(). register_setting('answerrails_core_settings', 'answerrails_custom_pages', array('type' => 'array', 'default' => array(), 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'page_targets'))); register_setting('answerrails_core_settings', 'answerrails_business_name', array('type' => 'string', 'default' => 'Your Business', 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'text'))); register_setting('answerrails_core_settings', 'answerrails_chat_title', array('type' => 'string', 'default' => 'How can we help you today?', 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'text'))); // Widget theme (frontend overhaul Phase B): one accent, derived scheme. register_setting('answerrails_core_settings', 'answerrails_theme_accent', array('type' => 'string', 'default' => AnswerRails_Theme::DEFAULT_ACCENT, 'sanitize_callback' => 'sanitize_hex_color')); // Authority: AnswerRails_Theme::settings() (includes/class-answerrails-theme.php). register_setting('answerrails_core_settings', 'answerrails_theme_header', array('type' => 'string', 'default' => 'accent', 'sanitize_callback' => function ($v) { return AnswerRails_Settings_Sanitise::enum($v, array('accent', 'surface', 'custom'), 'accent'); })); register_setting('answerrails_core_settings', 'answerrails_theme_header_color', array('type' => 'string', 'default' => AnswerRails_Theme::DEFAULT_ACCENT, 'sanitize_callback' => 'sanitize_hex_color')); // Authority: AnswerRails_Theme::settings(). register_setting('answerrails_core_settings', 'answerrails_theme_mode', array('type' => 'string', 'default' => 'light', 'sanitize_callback' => function ($v) { return AnswerRails_Settings_Sanitise::enum($v, array('light', 'dark', 'auto'), 'light'); })); // Window preset (frontend overhaul Phase C): default desktop window // geometry; visitor drag/resize overrides locally (localStorage). register_setting('answerrails_core_settings', 'answerrails_window_preset', array( 'type' => 'string', 'default' => 'standard', 'sanitize_callback' => array($this, 'sanitize_window_preset'), )); register_setting('answerrails_core_settings', 'answerrails_launcher_tagline', array( 'type' => 'string', 'default' => __('Chat with us', 'answerrails'), 'sanitize_callback' => 'sanitize_text_field', )); register_setting('answerrails_core_settings', 'answerrails_launcher_position', array( 'type' => 'string', 'default' => 'bottom-right', 'sanitize_callback' => array($this, 'sanitize_launcher_position'), )); register_setting('answerrails_core_settings', 'answerrails_launcher_size', array( 'type' => 'string', 'default' => 'comfortable', 'sanitize_callback' => array($this, 'sanitize_launcher_size'), )); register_setting('answerrails_core_settings', 'answerrails_launcher_bg_color', array( 'type' => 'string', 'default' => '#1f2937', 'sanitize_callback' => array($this, 'sanitize_launcher_bg_color'), )); register_setting('answerrails_core_settings', 'answerrails_launcher_text_color', array( 'type' => 'string', 'default' => '#ffffff', 'sanitize_callback' => array($this, 'sanitize_launcher_text_color'), )); register_setting('answerrails_core_settings', 'answerrails_launcher_visual', array( 'type' => 'string', 'default' => 'bubble', 'sanitize_callback' => array($this, 'sanitize_launcher_visual'), )); register_setting('answerrails_core_settings', 'answerrails_welcome_message', array('type' => 'string', 'default' => 'Hi! I\'m here to help with any questions about our products and services.', 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'textarea'))); // #98: "Powered by" footer link is explicit opt-in (WordPress.org // guideline: no embedded external links without permission). // Default MUST stay false — default-on fails the .org review. register_setting('answerrails_core_settings', 'answerrails_show_powered_by', array('type' => 'boolean', 'default' => false, 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'boolean'))); register_setting('answerrails_core_settings', 'answerrails_avatar_image_id', array( 'type' => 'integer', 'default' => 0, 'sanitize_callback' => array($this, 'sanitize_avatar_image_id') )); register_setting('answerrails_core_settings', 'answerrails_avatar_url', array( 'type' => 'string', 'default' => '', 'sanitize_callback' => array($this, 'sanitize_avatar_url') )); register_setting('answerrails_core_settings', 'answerrails_system_prompt', array('type' => 'string', 'default' => self::get_default_system_prompt(), 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'textarea'))); // Sources toggle (decision-12 cycle, #104): citations are part of // the trust layer, so the owner toggle registers in core settings // and renders in AI Assistant Behavior. Consumer: rest-chat // Sources block. register_setting('answerrails_core_settings', 'answerrails_require_citations', array('type' => 'boolean', 'default' => true, 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'boolean'))); // Structured-outputs toggle (decision-12 cycle, #104): the // MECHANISM is free (it is how citations and policy flags come back // reliably), so the owner toggle registers in core settings and // renders next to the model picker (AI Provider), where the // capability-axis JS can disable it with a reason on model change. register_setting('answerrails_core_settings', 'answerrails_structured_outputs', array('type' => 'boolean', 'default' => true, 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'boolean'))); register_setting('answerrails_core_settings', 'answerrails_active_provider', array( 'type' => 'string', 'default' => 'openai', // Save-time validation only: an id outside the provider // registry falls back to the stored value. 'sanitize_callback' => array($this, 'sanitize_active_provider'), )); // Provider keys are OPAQUE tokens (D1: no format guessing) — trim // only, so a valid key round-trips byte-identical; a key that text // sanitisation would alter is rejected with a settings error and // the stored key kept (#365 F6). register_setting('answerrails_core_settings', 'answerrails_provider_openai_api_key', array('type' => 'string', 'default' => '', 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'openai_api_key'))); register_setting('answerrails_core_settings', 'answerrails_provider_openai_model', array('type' => 'string', 'default' => AnswerRails_Utils::default_model('openai'), 'sanitize_callback' => function ($v) { return AnswerRails_Admin::resolve_model_choice($v, 'answerrails_provider_openai_model'); })); register_setting('answerrails_core_settings', 'answerrails_provider_mistral_api_key', array('type' => 'string', 'default' => '', 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'mistral_api_key'))); register_setting('answerrails_core_settings', 'answerrails_provider_mistral_model', array('type' => 'string', 'default' => AnswerRails_Utils::default_model('mistral'), 'sanitize_callback' => function ($v) { return AnswerRails_Admin::resolve_model_choice($v, 'answerrails_provider_mistral_model'); })); register_setting('answerrails_core_settings', 'answerrails_provider_anthropic_api_key', array('type' => 'string', 'default' => '', 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'anthropic_api_key'))); register_setting('answerrails_core_settings', 'answerrails_provider_anthropic_model', array('type' => 'string', 'default' => AnswerRails_Utils::default_model('anthropic'), 'sanitize_callback' => function ($v) { return AnswerRails_Admin::resolve_model_choice($v, 'answerrails_provider_anthropic_model'); })); // Default 0.4 — standardised (#104): the activation defaults // array and the Utils reader both said 0.4 while this said 0.2. // Numeric bounds mirror the shipped field constraints in // admin/views/settings.php (temperature 0–2, tokens 100–8192, // retention 1–365 days, TTL 0+ with no ceiling). D2: out-of-range // CLAMPS to the nearest bound; a field that carries no number keeps // the stored value. register_setting('answerrails_core_settings', 'answerrails_chat_temperature', array('type' => 'number', 'default' => 0.4, 'sanitize_callback' => function ($v) { return AnswerRails_Settings_Sanitise::bounded_float($v, 0.0, 2.0, get_option('answerrails_chat_temperature', 0.4)); })); register_setting('answerrails_core_settings', 'answerrails_chat_max_tokens', array('type' => 'integer', 'default' => 2048, 'sanitize_callback' => function ($v) { return AnswerRails_Settings_Sanitise::bounded_int($v, 100, 8192, get_option('answerrails_chat_max_tokens', 2048)); })); register_setting('answerrails_core_settings', 'answerrails_privacy_log_conversations', array('type' => 'boolean', 'default' => false, 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'boolean'))); // Authority: AnswerRails_Logger::logging_mode() — the single // authority for the effective mode (#109); anything but 'identified' // collapses to 'anonymized' there, so that is this callback's default. register_setting('answerrails_core_settings', 'answerrails_privacy_logging_mode', array('type' => 'string', 'default' => 'anonymized', 'sanitize_callback' => function ($v) { return AnswerRails_Settings_Sanitise::enum($v, array('anonymized', 'identified'), 'anonymized'); })); register_setting('answerrails_core_settings', 'answerrails_privacy_retention_days', array('type' => 'integer', 'default' => 180, 'sanitize_callback' => function ($v) { return AnswerRails_Settings_Sanitise::bounded_int($v, 1, 365, get_option('answerrails_privacy_retention_days', 180)); })); // FIXED: Added missing retention period // #174 (advanced_privacy promoted, decision 25 D4): the widget // storage-policy pair registers here and is always honoured. // Authority: AnswerRails_Core::get_effective_privacy_mode(). register_setting('answerrails_core_settings', 'answerrails_privacy_mode', array('type' => 'string', 'default' => 'session', 'sanitize_callback' => function ($v) { return AnswerRails_Settings_Sanitise::enum($v, array('none', 'session', 'local'), 'session'); })); // No ceiling in the shipped field (min 0, "0 disables") — none invented here. register_setting('answerrails_core_settings', 'answerrails_privacy_ttl', array('type' => 'integer', 'default' => 0, 'sanitize_callback' => function ($v) { return AnswerRails_Settings_Sanitise::bounded_int($v, 0, null, get_option('answerrails_privacy_ttl', 0)); })); // #286 D6: what an ERASURE does with consent evidence. A commercial // plugin cannot hardcode its author's compliance stance — where the // law permits two defensible positions, that is a setting. Default // retain_evidence = today's behaviour (strip the receipt, report it // retained); erase_fully deletes it. Governs the erasure path only: // the explicit "Delete everything" action is the owner's deliberate // act, already says so in its confirm dialog, and is unchanged. register_setting('answerrails_core_settings', 'answerrails_privacy_consent_erasure_stance', array('type' => 'string', 'default' => 'retain_evidence', 'sanitize_callback' => function ($v) { return AnswerRails_Settings_Sanitise::enum($v, array('retain_evidence', 'erase_fully'), 'retain_evidence'); })); register_setting('answerrails_core_settings', 'answerrails_delete_data_on_uninstall', array('type' => 'boolean', 'default' => false, 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'boolean'))); // #127 D2: opt-in delete-all-on-uninstall, default OFF (data = controller's business records, decision 15) register_setting('answerrails_core_settings', 'answerrails_capture_ideas', array('type' => 'boolean', 'default' => true, 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'boolean'))); register_setting('answerrails_core_settings', 'answerrails_lead_capture', array('type' => 'boolean', 'default' => true, 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'boolean'))); register_setting('answerrails_core_settings', 'answerrails_lead_message', array('type' => 'string', 'default' => '', 'sanitize_callback' => array($this, 'sanitize_lead_message'))); register_setting('answerrails_core_settings', 'answerrails_require_consent', array('type' => 'boolean', 'default' => false, 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'boolean'))); // HTML-bearing: the SAME allowlist authority the output path applies // (#178 D2 — a[href|target|rel], br, em, strong). sanitize_textarea_field // would destroy the legitimate anchor; a second allowlist here would be // the "two membership lists" trap. register_setting('answerrails_core_settings', 'answerrails_disclaimer_text', array('type' => 'string', 'default' => AnswerRails_Core::default_disclaimer_text(), 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'disclaimer'))); register_setting('answerrails_core_settings', 'answerrails_recaptcha_enabled', array('type' => 'boolean', 'default' => false, 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'boolean'))); register_setting('answerrails_core_settings', 'answerrails_recaptcha_site_key', array('type' => 'string', 'default' => '', 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'text'))); register_setting('answerrails_core_settings', 'answerrails_recaptcha_secret', array('type' => 'string', 'default' => '', 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'text'))); register_setting('answerrails_core_settings', 'answerrails_recaptcha_min_score', array('type' => 'number', 'default' => 0.5, 'sanitize_callback' => function ($v) { // Clamp to reCAPTCHA v3's score range [0, 1]; fall back to 0.5. $v = is_numeric($v) ? (float) $v : 0.5; return max(0.0, min(1.0, $v)); })); // Knowledge Management Settings // Opaque OpenAI id (vs_…): text class, no format guessing — and no // preserve-on-empty guard, because clearing the field is how the // owner disconnects a store by hand (the Unlink button uses // delete_option). // #243: the vector-search key's SECOND home, not a second option. // Vector search runs on OpenAI for EVERY chat provider, so the // Knowledge form must be able to save the OpenAI key without the // owner first switching chat provider. Same option, same // sanitiser as the Chat Settings panel (admin.php register above): // register_setting's add_filter is idempotent for an identical // callback, so the sanitiser runs once, not twice. register_setting('answerrails_knowledge_settings', 'answerrails_provider_openai_api_key', array('type' => 'string', 'default' => '', 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'openai_api_key'))); register_setting('answerrails_knowledge_settings', 'answerrails_knowledge_vector_store_id', array('type' => 'string', 'default' => '', 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'text'))); register_setting('answerrails_knowledge_settings', 'answerrails_knowledge_enabled', array('type' => 'boolean', 'default' => true, 'sanitize_callback' => array('AnswerRails_Settings_Sanitise', 'boolean'))); register_setting('answerrails_knowledge_settings', 'answerrails_retrieval_engine', array( 'type' => 'string', 'default' => 'auto', // #92: engine choice — 'auto' (vector when configured, with // lexical error-fallback) or 'local' (keyword-only bypass). 'sanitize_callback' => function ($value) { return in_array($value, array('auto', 'local'), true) ? $value : 'auto'; }, )); // ⚠ No knowledge-depth dial is registered here. Depth comes from // AnswerRails_Retrieval::retrieval_depth(), whose E3 // `answerrails_retrieval_depth` filter applies its default when // nothing hooks it (#174). // #144 DD3: the site-index size dial — recommended default 150, // hard ceiling AnswerRails_Site_Index::MAX_ENTRIES (500, attention // argument on the constant). The dial itself is never conditional; // what the index REACHES is decided at render. The null-preserve // guard is kept for symmetry with its sibling and costs nothing. register_setting('answerrails_knowledge_settings', 'answerrails_site_index_max_entries', array( 'type' => 'integer', 'default' => AnswerRails_Site_Index::DEFAULT_ENTRIES, 'sanitize_callback' => function ($value) { if (null === $value || '' === $value) { return (int) get_option('answerrails_site_index_max_entries', AnswerRails_Site_Index::DEFAULT_ENTRIES); } return max(1, min(AnswerRails_Site_Index::MAX_ENTRIES, (int) $value)); }, )); // Every option this plugin owns registers in the groups above // (#174). The leverage-surface options — handoff message, // grounding/capture instructions, sensitivity — are registered by // AnswerRails Pro, a separate plugin, alongside its escalation and // moderation-tuning options. READS of those options here carry // their own shipped defaults, so this code behaves the same // whether or not that plugin is installed (decision 25 D5 // read-time shape). // // ⚠ Migration 1.9.0 deletes the three legacy option rows this // plugin no longer stores (#95). // Link Resolution Settings register_setting('answerrails_core_settings', 'answerrails_enable_auto_links', array( 'type' => 'boolean', 'default' => true, 'sanitize_callback' => 'rest_sanitize_boolean' )); register_setting('answerrails_core_settings', 'answerrails_link_priority', array( 'type' => 'string', 'default' => 'products', 'sanitize_callback' => array($this, 'sanitize_link_priority') )); register_setting('answerrails_core_settings', 'answerrails_show_external_indicator', array( 'type' => 'boolean', 'default' => true, 'sanitize_callback' => 'rest_sanitize_boolean' )); // answerrails_variation_config UNREGISTERED (UI pruned 2026-07-26, // decision-12 cycle delta): the Title Variation UI was a save // trap (its sanitizer force-disabled case variations, which had no // field). The option remains a documented READ-ONLY hidden override // consumed by AnswerRails_Link_Processor::load_configuration(); // migration 1.5.0 deletes stored (trap-corrupted) values. register_setting('answerrails_core_settings', 'answerrails_primary_domains', array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field' )); register_setting('answerrails_core_settings', 'answerrails_remote_validation_allowed_domains', array( 'type' => 'string', 'default' => '', 'sanitize_callback' => array($this, 'sanitize_domain_list') )); } /** * Enqueue admin assets */ public function enqueue_admin_assets($hook_suffix) { // Only load on our admin pages if (strpos($hook_suffix, 'answerrails') === false) { return; } wp_enqueue_media(); wp_enqueue_style( 'answerrails-admin', ANSWERRAILS_PLUGIN_URL . 'assets/css/admin.css', array(), ANSWERRAILS_VERSION ); wp_enqueue_script( 'answerrails-admin', ANSWERRAILS_PLUGIN_URL . 'assets/js/admin.js', array('jquery'), ANSWERRAILS_VERSION, true ); // #139 Batch 5 (debt-tracker Low #4, Asset Organization): the // ⚠ There is ONE admin script and ONE admin stylesheet: admin.js // and admin.css. A second pair enqueued on the same screens // assigns window.AnswerRailsAdmin twice, and the later assignment // silently clobbers the earlier one. // #132 W5: per-screen assets (former inline view blocks — new files // because the blocks were large, per the design's W5 rule). Scoped to // their own screen so the two .ar-save-section handlers (this // settings script vs the advanced-page script registered by // AnswerRails Pro) can never double-bind on one page. if ('answerrails_page_answerrails-settings' === $hook_suffix) { wp_enqueue_script( 'answerrails-settings-page', ANSWERRAILS_PLUGIN_URL . 'assets/js/settings-page.js', array('jquery', 'answerrails-admin'), ANSWERRAILS_VERSION, true ); wp_localize_script('answerrails-settings-page', 'answerrailsSettingsPage', array( 'saving' => __('Saving...', 'answerrails'), 'saveFailed' => __('Save failed. Please try again.', 'answerrails'), 'saveSection' => __('Save Section', 'answerrails'), /* translators: %s: the selected model's display label from the model picker */ 'notSupported' => __('Not supported by %s.', 'answerrails'), // #290 D3 (O4): the identified-without-consent notice, shown by // settings-page.js when identified logging is on and the consent // prompt is off. Owner-facing: it states what the plugin is doing // and hands the judgement to the controller — it does not tell // the owner what their obligations are (limb 2, HOUSE_RULES §6). 'identifiedWithoutConsent' => __('Identified logging is on and the consent prompt is off: IP addresses and browser details are being logged with no consent record. Whether that is right for your site is your decision as data controller.', 'answerrails'), )); } if ('answerrails_page_answerrails-pro' === $hook_suffix) { wp_enqueue_style( 'answerrails-pro-features-page', ANSWERRAILS_PLUGIN_URL . 'assets/css/pro-features.css', array('answerrails-admin'), ANSWERRAILS_VERSION ); // #174: the page's script enqueue went with its form elements // — the About page is static marketing. AnswerRails Pro // enqueues its own script and styles for the settings sections // it renders here via E11. } if ('toplevel_page_answerrails' === $hook_suffix) { wp_enqueue_style( 'answerrails-overview-page', ANSWERRAILS_PLUGIN_URL . 'assets/css/overview.css', array('answerrails-admin'), ANSWERRAILS_VERSION ); // ⚠ The Overview page enqueues no page script (#174): it has no // behaviour of its own. AnswerRails Pro enqueues its own // overview styles for the E9 analytics sections. } // UPDATED: Enhanced localize script with AJAX data wp_localize_script('answerrails-admin', 'answerrailsAdmin', array( 'ajaxUrl' => admin_url('admin-ajax.php'), 'restUrl' => rest_url('answerrails/v1/'), 'nonce' => wp_create_nonce('answerrails_admin_nonce'), // #139: admin.js renders the same surface everywhere, so the // localize array carries strings and endpoints only. 'strings' => array( 'chooseAvatar' => __('Select Avatar', 'answerrails'), 'useAvatar' => __('Use this avatar', 'answerrails'), 'removeAvatar' => __('Use default avatar', 'answerrails') ), )); // AJAX-specific localization — it rides the one 'answerrails-admin' // handle enqueued above. wp_localize_script('answerrails-admin', 'answerrailsAjax', array( 'ajaxurl' => admin_url('admin-ajax.php'), 'nonces' => array( 'admin' => wp_create_nonce('answerrails_admin_nonce'), 'test_apis' => wp_create_nonce('answerrails_test_apis'), // ⚠ No reset-health nonce: there is no one-click purge // button, because purging destroys health evidence // (metrics + fallback events) and staleness self-heals via // error decay. The admin-only REST /api/reset/{api} // endpoint is the deliberate break-glass. ), 'strings' => array( 'testing_apis' => __('Testing APIs...', 'answerrails'), 'success' => __('Success', 'answerrails'), 'error' => __('Error', 'answerrails'), ) )); } /** * UPDATED: Enhanced admin notices with dynamic notice container */ public function admin_notices() { $screen = get_current_screen(); if (!$screen || strpos($screen->id, 'answerrails') === false) { return; } // ADD NEW: Container for dynamic AJAX notices echo '
'; // #92: unmissable testing-override indicator — the Local engine is // FORCED while vector search is fully configured. Shown on every // AnswerRails screen; supported keyless local mode never triggers it. if (class_exists('AnswerRails_Retrieval') && AnswerRails_Retrieval::override_active()) { echo '
'; echo '

' . esc_html__('Retrieval engine override active:', 'answerrails') . ' '; echo esc_html__('knowledge retrieval is forced to Local keyword only. Vector search is configured but not being used.', 'answerrails') . ' '; echo '' . esc_html__('Change in Knowledge Base → Settings', 'answerrails') . '

'; echo '
'; } // Two chat failure classes nag on every AnswerRails screen, because // the owner must act and nothing clears them by itself: // - #99 `credentials`, from the last-error record (a key to fix), // shown only while the ACTIVE provider's chat streak is // outstanding (#365 F3): the record is history and survives a // success, so on its own it outlived the fault. One successful // call (a chat or a passing Test APIs) ends the streak and the // notice with it; the record itself is kept for the Overview; // - #328 D5 `no_capacity`, from the ACTIVE provider's chat streak — // present exactly while the streak's class is no_capacity, gone on // the next successful chat call or a provider switch; not // dismissible, because the fault is real while it shows. // vendor_busy clears by itself, and quota is shown on the Overview // card (Down, with its diagnosis); neither has a notice here. if (class_exists('AnswerRails_API_Health_Monitor')) { $monitor = new AnswerRails_API_Health_Monitor(); $provider = AnswerRails_Utils::get_active_provider(); $last_chat_error = $monitor->get_last_chat_error($provider); if ($last_chat_error && null !== $monitor->get_chat_streak($provider) && (time() - (int) $last_chat_error['time']) < DAY_IN_SECONDS) { $diagnosis = AnswerRails_API_Health_Monitor::classify_error( $last_chat_error['type'], $last_chat_error['message'], is_array($last_chat_error['evidence'] ?? null) ? $last_chat_error['evidence'] : array() ); if ('credentials' === $diagnosis['class']) { echo '
'; echo '

' . esc_html__('AnswerRails chat is failing:', 'answerrails') . ' '; echo esc_html($diagnosis['text']) . ' '; echo '' . esc_html__('Open Chat Settings', 'answerrails') . '

'; echo '
'; } } $streak = $monitor->get_chat_streak($provider); if ($streak && 'no_capacity' === $streak['class']) { echo '
'; echo '

' . esc_html__('AnswerRails chat is not answering visitors:', 'answerrails') . ' '; echo esc_html(AnswerRails_API_Health_Monitor::class_text('no_capacity', $provider)) . ' '; echo '' . esc_html__('Open Chat Settings', 'answerrails') . '

'; echo '
'; } } // ⚠ There is no every-page upgrade notice (#140): a persistent // banner is a nag posture, and its copy sold trust-layer // capabilities as Pro. The promotion surfaces are exactly two — the // About AnswerRails Pro page (its two CTAs) and the knowledge // add-form's one contextual line — and both are pinned. This build // renders no disabled control anywhere. } /** * #261 D4: warn the owner that consent receipts are not reaching the * ledger. * * Without this the failure is UNDETECTABLE from the admin: visitors are * refused and told, and the site owner learns nothing. The pattern * follows the pro escalations notice (#263 D5) — one option, one notice, * one clearing rule — but registers FREE-SIDE, because consent is a free * feature and this machinery exists on every install. * * NOT dismissible: it clears itself the next time a receipt is written, * so it cannot outlive the fault. * * The copy names the visitor-facing consequence (§6 Q5) because this * marker CAN evidence it: the only writer is handle_consent(), and a * failed write is exactly the path that answers the visitor 500 * `consent_record_failed`. There is no branch that increments this * count without refusing the visitor. */ public function consent_ledger_failure_notice() { if (!current_user_can('manage_options')) { return; } if (!class_exists('AnswerRails_REST_Auth')) { return; } $marker = get_option(AnswerRails_REST_Auth::CONSENT_LEDGER_FAILURE_OPTION, array()); $count = is_array($marker) ? (int) ($marker['count'] ?? 0) : 0; if ($count < 1) { return; } $screen = get_current_screen(); if (!$screen || false === strpos((string) $screen->id, 'answerrails')) { return; } $last = is_array($marker) ? (string) ($marker['last'] ?? '') : ''; // Codex P2 (PR #285), ACCEPTED: wp_date(), not date_i18n(). The marker // stamps current_time('mysql', true) — UTC — and date_i18n()'s second // argument is a timestamp with the offset ALREADY APPLIED, so handing // it an ordinary Unix timestamp renders the UTC clock reading and // labels it as the site's local time. wp_date() converts into // wp_timezone(). ⚠ The same shape is in the escalations // send-failure notice in AnswerRails Pro (out of scope here — // reported on the PR, not fixed). $when = ('' === $last) ? '' : wp_date(get_option('date_format', 'j F Y') . ' H:i', strtotime($last . ' UTC')); $message = ('' === $when) ? sprintf( /* translators: %s: number of consent receipts that could not be written */ _n( 'AnswerRails could not record %s consent choice.', 'AnswerRails could not record %s consent choices.', $count, 'answerrails' ), number_format_i18n($count) ) : sprintf( /* translators: 1: number of consent receipts that could not be written, 2: date and time of the most recent failure */ _n( 'AnswerRails could not record %1$s consent choice — the most recent at %2$s.', 'AnswerRails could not record %1$s consent choices — the most recent at %2$s.', $count, 'answerrails' ), number_format_i18n($count), $when ); // Codex P2 (PR #285), ACCEPTED and it was real. This read "…so nothing // was logged for them", which is FALSE and false in the direction that // matters: a failed WITHDRAWAL happens after a conversation, so that // visitor's chat logs and their earlier consent receipt both still // exist — and the failure path deliberately leaves them untouched, // which is D4's whole point. An owner reading "nothing was logged" // could answer a DSAR or scope an incident wrongly on the strength of // it. The notice now describes exactly the record that is missing. // 📌 A REMEDY'S OWN COPY IS A CLAIM, AND IS GATED LIKE ANY OTHER — // the #263 lesson, met a second time in the same shape. $explain = __('Those visitors were refused and told their choice could not be recorded, so the consent receipt for that choice is missing. Nothing else was changed: any conversations already logged for them are untouched, and their recorded consent state is exactly what it was before. WordPress being unable to create a post is a site or hosting condition rather than an AnswerRails setting — check the database and the disk space with your host.', 'answerrails'); echo '

' . esc_html($message) . ' ' . esc_html($explain) . '

'; } /** * Model-retirement advisory notice (#78). * * Warns when the ACTIVE provider's configured model has an announced * vendor retirement (data: AnswerRails_Model_Capabilities::RETIREMENTS). * Tiers (D3/D4): * - warning (≤60 days): notice-warning, AnswerRails screens only, * snoozable for 7 days; * - urgent (≤14 days): notice-error on ALL admin screens, no snooze; * - past: notice-error on ALL admin screens, "may be failing" wording. * Cheap by construction: option reads + one preg_match, no queries. */ public function model_retirement_notice() { if (!current_user_can('manage_options')) { return; } if (!class_exists('AnswerRails_Model_Capabilities') || !class_exists('AnswerRails_Utils')) { return; } $provider = AnswerRails_Utils::get_active_provider(); $model = AnswerRails_Utils::get_model(); if ('' === $model) { return; } $info = AnswerRails_Model_Capabilities::retirement($provider, $model); if (null === $info) { return; } $tier = AnswerRails_Model_Capabilities::retirement_tier($info['retires'], current_time('Y-m-d')); if (null === $tier) { return; } if ('warning' === $tier) { $screen = get_current_screen(); if (!$screen || strpos($screen->id, 'answerrails') === false) { return; } if (get_transient($this->retirement_snooze_key($provider, $model, $info['retires']))) { return; } } $provider_names = array( 'openai' => 'OpenAI', 'mistral' => 'Mistral', 'anthropic' => 'Anthropic', ); $vendor = $provider_names[$provider] ?? ucfirst($provider); $date_display = date_i18n(get_option('date_format', 'j F Y'), strtotime($info['retires'] . ' 00:00:00 UTC')); $settings_url = admin_url('admin.php?page=answerrails-settings'); if ('past' === $tier) { $message = sprintf( /* translators: 1: AI vendor name, 2: model id, 3: date */ __('%1$s retired the model %2$s on %3$s — your chat assistant may be failing.', 'answerrails'), esc_html($vendor), '' . esc_html($model) . '', esc_html($date_display) ); } else { $message = sprintf( /* translators: 1: AI vendor name, 2: model id, 3: date */ __('%1$s retires the model %2$s on %3$s.', 'answerrails'), esc_html($vendor), '' . esc_html($model) . '', esc_html($date_display) ); } $action = sprintf( ' %s', esc_url($settings_url), esc_html__('Switch models in AnswerRails Settings', 'answerrails') ); if (!empty($info['replacement'])) { $action .= ' ' . sprintf( /* translators: %s: model id */ esc_html__('(suggested replacement: %s)', 'answerrails'), esc_html($info['replacement']) ); } if ('warning' === $tier) { $snooze_url = wp_nonce_url( add_query_arg('answerrails_snooze_retirement', '1'), 'answerrails_snooze_retirement' ); $action .= sprintf( ' | %s', esc_url($snooze_url), esc_html__('Remind me in a week', 'answerrails') ); } $class = ('warning' === $tier) ? 'notice notice-warning' : 'notice notice-error'; echo '

' . wp_kses_post($message . $action) . '

'; } /** * Snooze the warning-tier retirement notice for 7 days (#78 D4). * Keyed on provider+model+date so a model change or a new retirement * announcement re-arms the notice immediately. */ public function handle_retirement_snooze() { if (empty($_GET['answerrails_snooze_retirement'])) { return; } if (!current_user_can('manage_options')) { return; } check_admin_referer('answerrails_snooze_retirement'); $provider = AnswerRails_Utils::get_active_provider(); $model = AnswerRails_Utils::get_model(); $info = AnswerRails_Model_Capabilities::retirement($provider, $model); if (null !== $info) { set_transient( $this->retirement_snooze_key($provider, $model, $info['retires']), 1, 7 * DAY_IN_SECONDS ); } wp_safe_redirect(remove_query_arg(array('answerrails_snooze_retirement', '_wpnonce'))); exit; } private function retirement_snooze_key(string $provider, string $model, string $date): string { return 'answerrails_retire_snooze_' . md5($provider . '|' . strtolower($model) . '|' . $date); } /** * One-time explanatory notice after migration 1.4.0 switched a * Gemini-configured install to Mistral (#103 D2). Renders on all admin * screens until dismissed — the site owner must learn WHY the provider * changed and that chat needs a Mistral key before it answers again. * The #99 credentials notice supplies the actionable fix path if a * turn fails before a key is entered; the two are complementary. */ public function gemini_removal_notice() { if (!current_user_can('manage_options') || !get_option('answerrails_gemini_removal_notice')) { return; } $dismiss_url = wp_nonce_url( add_query_arg('answerrails_dismiss_gemini_removal', '1'), 'answerrails_dismiss_gemini_removal' ); $settings_url = admin_url('admin.php?page=answerrails-settings'); echo '
'; echo '

' . esc_html__('AnswerRails no longer supports Google Gemini.', 'answerrails') . ' '; echo esc_html__('Gemini\'s API terms are incompatible with an embedded public-site chat (under-18 site exclusion, EEA/CH/UK unpaid-use restriction, and free-tier training without an opt-out), so the integration was removed rather than leave sites in a breach they cannot detect or fix. Your assistant has been switched to Mistral and needs a Mistral API key before it can answer again — a free key takes about two minutes to set up.', 'answerrails') . ' '; echo '' . esc_html__('Open the setup walkthrough', 'answerrails') . ''; echo ' | ' . esc_html__('Dismiss this notice', 'answerrails') . '

'; echo '
'; } /** * Dismiss handler for the Gemini-removal notice (#103) — same nonce'd * GET pattern as the retirement snooze above. */ public function handle_gemini_removal_dismiss() { if (empty($_GET['answerrails_dismiss_gemini_removal'])) { return; } if (!current_user_can('manage_options')) { return; } check_admin_referer('answerrails_dismiss_gemini_removal'); delete_option('answerrails_gemini_removal_notice'); wp_safe_redirect(remove_query_arg(array('answerrails_dismiss_gemini_removal', '_wpnonce'))); exit; } /** * ADD NEW: Get API health status for dashboard */ /** * Map an Overview range key (the 24h/7d/30d/90d/all selector) to a * lookback window in seconds (null = all retained history). Public so * the Overview's AJAX refresh reads the same range as the page (#328). */ public function range_window($range_key) { $windows = array( '24h' => DAY_IN_SECONDS, '7d' => 7 * DAY_IN_SECONDS, '30d' => 30 * DAY_IN_SECONDS, '90d' => 90 * DAY_IN_SECONDS, 'all' => null, ); return array_key_exists($range_key, $windows) ? $windows[$range_key] : DAY_IN_SECONDS; } /** * Human label for a range key, for metric lines ("in the last 7 days"). */ private function range_phrase($range_key) { switch ($range_key) { case '7d': return __('in the last 7 days', 'answerrails'); case '30d': return __('in the last 30 days', 'answerrails'); case '90d': return __('in the last 90 days', 'answerrails'); case 'all': return __('in recorded history', 'answerrails'); default: return __('in the last 24 hours', 'answerrails'); } } /** * The health counts' window for a range key, in words (#328 D6). The * monitor counts whole UTC days and keeps 31 of them, so "24h" reads * today and yesterday and the 90d / all ranges read the 31 kept days. */ private function health_window_phrase($range_key) { switch ($range_key) { case '7d': return __('the last 7 days (UTC)', 'answerrails'); case '30d': return __('the last 30 days (UTC)', 'answerrails'); case '90d': case 'all': return __('the last 31 days (UTC)', 'answerrails'); default: return __('today and yesterday (UTC)', 'answerrails'); } } public function get_api_health_status($range_key = '24h') { if (!class_exists('AnswerRails_API_Health_Monitor')) { return array( 'overall_status' => 'unknown', 'chat' => array('status' => 'unknown'), 'chat_completions' => array('status' => 'unknown'), 'responses' => array('status' => 'unknown'), 'retrieval' => array('status' => 'unknown'), 'retrieval_fallback' => array('active' => false, 'count_24h' => 0), ); } // Provider/model badge (#87 immunity): resolve EXACTLY as the // runtime does — never via the settings dropdown, which renders the // first catalogue option when the stored value left the catalogue. $provider = AnswerRails_Utils::get_active_provider(); // Health numbers come from the monitor's dashboard snapshot (single // source of truth, shared with the AJAX refresh). Provider-aware: // OpenAI records under responses/chat_completions; Mistral and // Anthropic record under their own endpoint keys. $status = (new AnswerRails_API_Health_Monitor()) ->get_dashboard_snapshot($provider, $this->range_window($range_key)); $status['provider'] = $provider; $status['model'] = AnswerRails_Utils::get_model(); // Vector-store state from the last Test-APIs retrieval probe // (on-demand/cached — the store-GET half of the #86 diagnostic). $status['store_state'] = get_transient('answerrails_retrieval_probe'); return $status; } /** * ADD NEW: Helper method to get post count with optional time filter */ private function get_post_count($post_type, $time_period = null) { $args = array( 'post_type' => $post_type, 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids' ); if ($time_period) { $args['date_query'] = array( array( 'after' => $time_period . ' ago' ) ); } $posts = get_posts($args); return count($posts); } /** * ADD NEW: Render API health widget for dashboard */ public function render_api_health_widget($range_key = '24h') { $health_status = $this->get_api_health_status($range_key); $fallback_active = !empty($health_status['retrieval_fallback']['active']); $fallback_count = (int) ($health_status['retrieval_fallback']['count'] ?? 0); $range_phrase = $this->range_phrase($range_key); $store_state = $health_status['store_state'] ?? false; // #92: engine-aware rendering — the strip and Retrieval card // describe the engine actually in use. Local mode is a supported // state (healthy, honest), never dressed as degraded. $engine = class_exists('AnswerRails_Retrieval') ? AnswerRails_Retrieval::engine() : array('engine' => 'vector', 'reason' => 'default'); $local_mode = ('local' === $engine['engine']); $override_on = $local_mode && 'option' === $engine['reason'] && AnswerRails_Retrieval::override_active(); // Tooltip copy — written for a site owner, not an API engineer //. Formulas live only in the metric tooltips. $tip_overall = $fallback_active ? __('Worst of the Chat and Knowledge search stages. Knowledge search is currently degraded, but answers are still grounded from the local fallback index in your WordPress database.', 'answerrails') : __('The worst status across the two stages below: Knowledge search and the Chat model.', 'answerrails'); $tip_stage_visitor = __('A visitor asks a question in the chat widget on your site.', 'answerrails'); $tip_stage_moderation = __('Every visitor message is checked against your AI provider\'s content-safety service BEFORE anything else runs. Abusive content gets a neutral deflection and never reaches your provider account; self-harm signals get a supportive signposting reply. If this check cannot run, chat fails closed (visitors are asked to retry) — so a red dot here means visitors are not getting answers.', 'answerrails'); $tip_stage_retrieval = $local_mode ? __('Your knowledge base is searched for relevant passages before the AI answers. Search runs on the local keyword index in your own WordPress database — your knowledge base is stored and searched locally, and only the passages relevant to a question are shared with your AI provider to answer it. Adding an OpenAI key enables higher-quality vector search.', 'answerrails') : __('Your knowledge base is searched for relevant passages before the AI answers. This search runs on OpenAI\'s vector store even when chat uses Mistral or Claude. If it fails, AnswerRails falls back to searching a copy of your knowledge base stored in your own WordPress database.', 'answerrails'); $tip_stage_chat = __('The AI model that writes the reply, grounded in the passages found by knowledge search. Shown here exactly as the runtime resolves it — the per-message record in your chat log is always the ground truth.', 'answerrails'); $tip_stage_answer = __('Guardrails check the draft reply. If the AI cannot answer reliably from your knowledge base, the visitor is offered a handoff to your team instead of a guess.', 'answerrails'); // #328 D6: the counts are whole UTC days — each tip states exactly // the days the selected range reads (see window_days()). $window_phrase = $this->health_window_phrase($range_key); // #377 D6: a passing Test APIs restarts each count, so a 30-day card // that reads 1 call after a test says why (A ZERO IS A REAL COUNT; // AN EMPTY STATE IS A CLAIM). $tip_restart = __('A passing Test APIs restarts the count from that test.', 'answerrails'); /* translators: %s: the counting window, e.g. "today and yesterday (UTC)" */ $tip_success = sprintf(__('Successful calls divided by total calls, counted over %s.', 'answerrails'), $window_phrase) . ' ' . $tip_restart; /* translators: %s: the counting window, e.g. "today and yesterday (UTC)" */ $tip_rtime = sprintf(__('Average response time of the calls counted over %s, in milliseconds.', 'answerrails'), $window_phrase) . ' ' . $tip_restart; /* translators: %s: the counting window, e.g. "today and yesterday (UTC)" */ $tip_calls = sprintf(__('Calls recorded over %s.', 'answerrails'), $window_phrase) . ' ' . $tip_restart; // #328 D2 / #377 D6: the one status rule, in owner words. Only chat // is re-checked automatically (#377 D8 scope), so only the Chat // badge carries that sentence (#377 R5). $tip_status_chat = self::status_tooltip(true); $tip_status_retrieval = self::status_tooltip(false); ?>

get_status_icon($health_status['overall_status'])); ?>
→ 0) : ?> → → →

get_status_icon($health_status['chat']['status'])); ?>
get_chat_outage($health_status['provider'])); if ('' !== $recovering_line) { echo '

' . esc_html($recovering_line) . '

'; } } // #99: when chat is unhealthy, say WHY in owner language — // the status alone can't distinguish vendor congestion // from a setup error, which is exactly the distinction a // new install needs. if (('healthy' !== ($health_status['chat']['status'] ?? '')) && class_exists('AnswerRails_API_Health_Monitor')) { // #328 D3: classified with the failure's vendor // evidence, escalated copy once a busy streak is // no longer a busy period. $diagnosis = (new AnswerRails_API_Health_Monitor())->chat_diagnosis($health_status['provider']); if ($diagnosis && (time() - $diagnosis['time']) < DAY_IN_SECONDS) { ?>

fmt_success_rate($health_status['chat']['success_rate'] ?? null)); ?>
fmt_response_time($health_status['chat']['response_time_avg'] ?? null)); ?>

render_chat_api_subcard('chat-completions', __('Chat Completions API', 'answerrails'), $health_status['chat_completions'], $tip_success, $tip_rtime, $tip_calls, 'chat_completions', (string) ($health_status['chat']['serving_api'] ?? ''), (string) $health_status['model']); ?> render_chat_api_subcard('responses', __('Responses API', 'answerrails'), $health_status['responses'], $tip_success, $tip_rtime, $tip_calls, 'responses', (string) ($health_status['chat']['serving_api'] ?? ''), (string) $health_status['model']); ?>

render_chat_api_subcard($health_status['provider'], /* translators: %s: AI provider name, e.g. Mistral */ sprintf(__('%s API', 'answerrails'), ucfirst($health_status['provider'])), $health_status[$health_status['provider']] ?? array(), $tip_success, $tip_rtime, $tip_calls); ?>

get_status_icon($health_status['retrieval']['status'])); ?>
fmt_success_rate($health_status['retrieval']['success_rate'] ?? null)); ?>
fmt_response_time($health_status['retrieval']['response_time_avg'] ?? null)); ?>

0) { printf( /* translators: 1: number of fallback uses, 2: time-range phrase e.g. "in the last 7 days" */ esc_html(_n('Local keyword fallback: %1$d use %2$s', 'Local keyword fallback: %1$d uses %2$s', $fallback_count, 'answerrails')), esc_html($fallback_count), esc_html($range_phrase) ); $avg_score = $health_status['retrieval_fallback']['avg_score'] ?? null; $last_score = $health_status['retrieval_fallback']['last_score'] ?? null; if ($avg_score !== null) { printf( /* translators: 1: average keyword match score, 2: last keyword match score */ ' · ' . esc_html__('keyword match score: avg %1$s, last %2$s', 'answerrails'), esc_html($avg_score), esc_html($last_score) ); } } else { /* translators: %s: time-range phrase, e.g. "in the last 7 days" */ printf(esc_html__('Local keyword fallback: not used %s', 'answerrails'), esc_html($range_phrase)); } ?>

count() : 0; $item_count = (int) $this->get_post_count('answerrails_kb'); printf( /* translators: 1: e.g. "26 chunks", 2: e.g. "3 items" */ esc_html__('Index: %1$s across %2$s', 'answerrails'), /* translators: %d: number of knowledge chunks */ esc_html(sprintf(_n('%d chunk', '%d chunks', $chunk_count, 'answerrails'), $chunk_count)), /* translators: %d: number of knowledge items */ esc_html(sprintf(_n('%d item', '%d items', $item_count, 'answerrails'), $item_count)) ); if (is_array($store_state) && isset($store_state['lexical'])) { $probe_query = (string) ($store_state['lexical']['query'] ?? ''); printf( /* translators: 1: hit count, 2: probe query, 3: top score, 4: timestamp */ ' · ' . esc_html__('last self-test: %1$d hits for “%2$s”, top keyword score %3$s (%4$s)', 'answerrails'), (int) ($store_state['lexical']['hits'] ?? 0), esc_html($probe_query), esc_html($store_state['lexical']['top_score'] ?? '0'), esc_html($store_state['tested_at'] ?? '') ); } ?>

'unknown'); ?>

get_status_icon($health['status'] ?? 'unknown')); ?>
' . esc_html(self::idle_endpoint_line((string) $model, $monitor->last_recorded_call((string) $api), $monitor->next_recovery_check((string) $api), false !== ($health['reachable'] ?? true))) . '

'; } ?>
fmt_success_rate($health['success_rate'] ?? null)); ?>
fmt_response_time($health['response_time_avg'] ?? null)); ?>
'--'. */ private function fmt_response_time($ms) { return $ms === null ? '--' : esc_html($ms) . 'ms'; } private function get_status_icon($status) { switch ($status) { case 'healthy': return '🟢'; case 'recovering': return '🔵'; // #377 D6 case 'degraded': return '🟡'; case 'down': return '🔴'; default: return '⚫'; } } /** * The status badge's tooltip (#328 D2, #377 D6): the status rule in * owner words. $with_recheck adds the D8 sentence — only chat endpoints * are re-checked automatically, so only the Chat badge says so * (#377 R5). */ private static function status_tooltip(bool $with_recheck): string { $tip = __('Down: the provider is refusing requests now — in a way retrying cannot clear (no request capacity, usage limit reached, or API key rejected), or 3 or more times in a row over at least 10 minutes. Recovering: requests are succeeding again after an outage, but under 95% of the counted calls succeeded because the outage\'s failures are still counted. Degraded: the latest request failed; or under 95% of the counted calls succeeded with no outage behind them; or they averaged over 10 seconds. Unknown: no calls counted and no failure outstanding — or, on OpenAI, the selected model cannot use this endpoint, so its last state is kept until you choose a model that can. Healthy: none of these. A failure stays outstanding until the next successful call.', 'answerrails'); if ($with_recheck) { $tip .= ' ' . __('AnswerRails re-checks a Down endpoint the selected model can use automatically — 10 minutes after the failure, then at growing intervals up to every 6 hours — and straight away when an API key is saved.', 'answerrails'); } return $tip . ' ' . __('A passing Test APIs starts the count afresh.', 'answerrails'); } /** * The Chat card's recovering line (#377 D6), from the serving key's * outage record; '' unless the status is `recovering` and the record * exists. * * @param string $status The Chat card's status. * @param array|null $outage get_chat_outage(). */ private static function recovering_line(string $status, ?array $outage): string { if ('recovering' !== $status || null === $outage) { return ''; } return sprintf( /* translators: %s: date and time requests began succeeding again */ __('Requests have succeeded again since %s; the success rate below still counts the outage. Run Test APIs to start the count afresh.', 'answerrails'), wp_date(get_option('date_format', 'j F Y') . ' ' . get_option('time_format', 'H:i'), (int) $outage['ended']) ); } /** * The idle OpenAI endpoint's line (#377 D7 step 5, D8). With no * recorded call the line ends at the first sentence; while a re-check * is pending it says when. A key the model cannot reach (#381 A4.3) * says so instead, and never promises a check: Test APIs and the * re-check both skip it, whatever event is still pending. * * @param string $model The active model id. * @param int|null $last last_recorded_call() of the key. * @param int|null $next next_recovery_check() of the key. * @param bool $reachable The snapshot's `reachable` flag for the key. */ private static function idle_endpoint_line(string $model, ?int $last, ?int $next, bool $reachable): string { $format = get_option('date_format', 'j F Y') . ' ' . get_option('time_format', 'H:i'); $line = (null === $last) /* translators: %s: model id */ ? sprintf(__('Not serving visitors with %s.', 'answerrails'), $model) : sprintf( /* translators: 1: model id, 2: date and time of the endpoint's last recorded call */ __('Not serving visitors with %1$s. Last checked %2$s.', 'answerrails'), $model, wp_date($format, $last) ); if (!$reachable) { /* translators: %s: model id */ return $line . ' ' . sprintf(__('%s cannot use this endpoint, so Test APIs and automatic checks skip it.', 'answerrails'), $model); } if (null !== $next) { /* translators: %s: date and time */ $line .= ' ' . sprintf(__('Next automatic check %s.', 'answerrails'), wp_date($format, $next)); } return $line; } /** * The Overview's chat success figure (#377 D4): the Experience Scores * tile and the Performance Metrics row read the Chat card's own figure * and status — success_rate × 100, rounded as before; the state from * the status: healthy → good / Excellent, recovering and degraded → * warn / Fair, down → alert / Poor, unknown → neutral (no value). * * @param array $chat get_dashboard_snapshot()['chat']. * @return array{value:float|null,state:string,word:string|null,row_class:string} */ public static function chat_success_summary(array $chat): array { $status = (string) ($chat['status'] ?? 'unknown'); $rate = $chat['success_rate'] ?? null; $map = array( 'healthy' => array('good', __('Excellent', 'answerrails'), 'status-good'), 'recovering' => array('warn', __('Fair', 'answerrails'), 'status-warning'), 'degraded' => array('warn', __('Fair', 'answerrails'), 'status-warning'), 'down' => array('alert', __('Poor', 'answerrails'), 'status-error'), ); if (!isset($map[$status])) { return array('value' => null, 'state' => 'neutral', 'word' => null, 'row_class' => 'status-neutral'); } return array( 'value' => null !== $rate ? round((float) $rate * 100) : null, 'state' => $map[$status][0], 'word' => $map[$status][1], 'row_class' => $map[$status][2], ); } /** * Overview page (unified dashboard + analytics) */ public function overview_page() { $this->current_page = 'overview'; include ANSWERRAILS_PLUGIN_DIR . 'admin/views/overview.php'; } /** * Chat Settings page */ public function settings_page() { $this->current_page = 'settings'; include ANSWERRAILS_PLUGIN_DIR . 'admin/views/settings.php'; } /** * Pro Features page */ public function pro_features_page() { $this->current_page = 'pro'; include ANSWERRAILS_PLUGIN_DIR . 'admin/views/pro-features.php'; } /** * Knowledge Base page */ public function knowledge_page() { $this->current_page = 'knowledge'; include ANSWERRAILS_PLUGIN_DIR . 'admin/views/knowledge.php'; } /** * Render admin header */ public function render_header($title, $description = '') { ?>

current_page !== 'overview'): ?> |

render_nav_tabs(); ?>
array( 'title' => __('Overview', 'answerrails'), 'url' => admin_url('admin.php?page=answerrails'), 'icon' => 'dashboard' ), 'settings' => array( 'title' => __('Chat Settings', 'answerrails'), 'url' => admin_url('admin.php?page=answerrails-settings'), 'icon' => 'admin-settings' ), 'pro' => array( 'title' => __('Advanced Features', 'answerrails'), 'url' => admin_url('admin.php?page=answerrails-pro'), 'icon' => 'star-filled' ), 'knowledge' => array( 'title' => __('Knowledge Base', 'answerrails'), 'url' => admin_url('admin.php?page=answerrails-knowledge'), 'icon' => 'book' ), ); echo ''; } // ⚠ There is no section renderer here (#174): the settings view and the // pro section views build their own section markup. render_field() // below has live callers throughout admin/views/settings.php. /** * #122 (D5): accessible contrast warning beside a colour field. * * WARNS, never blocks — brand mandates exist and the site owner is the * data controller of their own conformance. Rendered on every load * (hidden while passing) so the admin.js live echo only has to toggle * and re-number it; the SERVER computation here is the authority * (AnswerRails_Theme::contrast_ratio(), the single contrast authority). * * One-click fix: for an explicit bg/text pair, the best text colour for * the background; when the background itself is the problem (both text * options fail — pathological mid-tone colours), the nearest compliant * shade of the background instead. * * $args: bg_field, bg_default, label; optional text_field, text_default. */ private function render_contrast_guard(array $args) { $bg_default = (string) ($args['bg_default'] ?? AnswerRails_Theme::DEFAULT_ACCENT); $bg = sanitize_hex_color((string) get_option($args['bg_field'], $bg_default)); if (!$bg) { $bg = $bg_default; } $text_field = (string) ($args['text_field'] ?? ''); if ($text_field) { $text = sanitize_hex_color((string) get_option($text_field, (string) ($args['text_default'] ?? '#ffffff'))); if (!$text) { $text = (string) ($args['text_default'] ?? '#ffffff'); } } else { $text = AnswerRails_Theme::text_on($bg); // Derived pair (D6: best available). } $ratio = AnswerRails_Theme::contrast_ratio($bg, $text); $best_text = AnswerRails_Theme::text_on($bg); if ($text_field && AnswerRails_Theme::contrast_ratio($bg, $best_text) >= 4.5) { $fix_field = $text_field; $fix_value = $best_text; } else { $fix_field = (string) $args['bg_field']; $fix_value = AnswerRails_Theme::nearest_compliant($bg); } printf( '

' . '%s ' . '

', esc_attr($args['bg_field']), esc_attr($text_field), esc_attr($args['label']), ($ratio >= 4.5) ? ' hidden' : '', esc_html(sprintf( /* translators: 1: which colour pairing, 2: computed ratio */ __('%1$s: contrast ratio %2$s:1 is below the WCAG AA minimum of 4.5:1.', 'answerrails'), $args['label'], number_format_i18n($ratio, 2) )), esc_attr($fix_field), esc_attr($fix_value), esc_html(sprintf( /* translators: %s: recommended hex colour */ __('Use recommended (%s)', 'answerrails'), $fix_value )) ); } /** * Render individual form field - FIXED to handle null values */ private function render_field($field) { // Retrieve the saved value without conflating an intentional falsey value with a missing option $sentinel = uniqid('answerrails_missing_', true); $raw_value = get_option($field['id'], $sentinel); // Determine the default value $registered_setting = get_registered_settings()[$field['id']] ?? null; $default = $field['default'] ?? ($registered_setting['default'] ?? ''); $has_saved_value = ($raw_value !== $sentinel); // A field offering Restore Default treats BLANK as meaningful — its // read-time resolver owns the fallback, not this renderer. Pre-filling // such a field with the generated default hands the next save of the // section that snapshot as the owner's OWN text, freezing a live value // into a stale literal: the disclaimer default is logging-aware, so a // save made while logging was off would persist "This conversation is // not stored." and go on telling visitors that after logging was // turned on. The default still feeds the preview and the restore // button below — offered, never pre-filled. $prefill = empty($field['restore_default']) ? $default : ''; $value = $has_saved_value ? $raw_value : $prefill; // ⚠ This renderer has no lock model (#174): no effective-state // override, and no per-field key that emits `disabled` or // `readonly`. The free build renders no disabled control, so a // renderer that CAN emit one is a lock waiting for a caller. A // control that must not be changed is not rendered at all. // Special handling for checkboxes so that an intentionally saved "false" is respected if ($field['type'] === 'checkbox') { $is_checked = $has_saved_value ? (bool) $raw_value : (bool) $default; printf( '', esc_attr($field['id']), esc_attr($field['id']), checked($is_checked, true, false), esc_html($field['label'] ?? __('Enable', 'answerrails')) ); return; } switch ($field['type']) { case 'text': printf( '', esc_attr($field['id']), esc_attr($field['id']), esc_attr($value) ); break; case 'textarea': printf( '', esc_attr($field['id']), esc_attr($field['id']), esc_textarea($value) ); // #129: fields with a code-owned default get a preview of // that default plus a one-click restore. Restore INSERTS // the default text into the field (owner saves to commit) — // deliberately NOT clear-to-blank: blank has field-specific // meanings (idea/lead capture instructions treat blank as // "silence entirely"), so clearing is not a safe universal // restore. Insert-then-save is honest everywhere, and adds // no new options (#174). // Preview-before-commit per the settings-truthfulness // convention (v3.9.0). if (!empty($field['restore_default']) && '' !== (string) $default) { printf( '
%s
%s
' . ' ' . '', esc_html__('View built-in default', 'answerrails'), esc_html($default), esc_attr($field['id']), esc_html__('Restore built-in default', 'answerrails') ); } break; case 'select': printf(''; break; case 'number': printf( '', esc_attr($field['id']), esc_attr($field['id']), esc_attr($value), esc_attr($field['min'] ?? '0'), esc_attr($field['max'] ?? ''), esc_attr($field['step'] ?? '1') ); break; case 'password': printf( '', esc_attr($field['id']), esc_attr($field['id']), esc_attr($value) ); break; case 'color': printf( '', esc_attr($field['id']), esc_attr($field['id']), esc_attr($value) ); break; } } /** * The shipped default system prompt. * * ⚠ #131 D1: this text instructs the model in no ungrounded ACTION — * no "offer to help the customer contact us directly", no "direct * customers to contact us at …". The plugin performs a human handoff * only where the escalation machinery is available, and the base prompt * is gate-BLIND: anything promised here survives everywhere, including * where the prompt-gate rule forbids the offer. The defect that taught * this was the assistant volunteering "I can connect you with our team" * and the integrity layer correctly deflecting the visitor's uptake one * turn later. * * What the model may say when it lacks information is CODE-OWNED and * gated: see AnswerRails_REST_OpenAI::knowledge_context_instruction() * and ::get_structured_output_instructions(). The base prompt is * deliberately silent on it — anything said here cannot be gated. * * The business name resolves from the site name — no placeholder token * ships in the prompt text. Pinned by FreeTierHonestyTest. */ public static function get_default_system_prompt() { $business = trim((string) get_bloginfo('name')); if ('' === $business) { $business = __('our business', 'answerrails'); } $default_prompt = "You are a helpful AI assistant for {$business}, designed to provide accurate information about our products, services, and policies. CORE GUIDELINES: - Always be friendly, professional, and helpful - If you find relevant information in our knowledge base, use it and cite your sources - If you don't have specific information, say so honestly and do not guess - Never make promises about pricing, delivery dates, or policies without confirming from our official information RESPONSE STYLE: - Keep responses conversational but informative - Use bullet points for lists when helpful - Always end with \"Is there anything else I can help you with?\" - If referencing our knowledge base, mention the source (e.g., \"According to our FAQ...\" or \"Based on our product guide...\") Remember: You're representing our business, so maintain a helpful and professional tone that reflects our values of customer service and reliability."; return $default_prompt; } /** * Initialize AJAX handler */ private function initialize_ajax_handler() { if (is_admin() && wp_doing_ajax()) { require_once plugin_dir_path(dirname(__FILE__)) . 'includes/class-answerrails-admin-ajax.php'; $this->ajax_handler = new AnswerRails_Admin_Ajax($this); } } /** * Sanitize avatar image ID */ public function sanitize_avatar_image_id($value) { $value = absint($value); if ($value <= 0) { return 0; } $attachment = get_post($value); if (!$attachment || 'attachment' !== $attachment->post_type) { return 0; } $mime_type = get_post_mime_type($attachment); if (empty($mime_type) || strpos($mime_type, 'image/') !== 0) { return 0; } return $value; } /** * Sanitize avatar URL */ /** * Sanitize the active AI provider: input validation only — rejects * unknown provider ids, keeping the saved value. Any provider in the * registry is selectable, and nothing clamps or grandfathers the * choice (#174). Runs on every * update_option for answerrails_active_provider (registered sanitize * callback), which covers both the Settings API and the section-save * AJAX path. */ public function sanitize_active_provider($value) { $value = sanitize_key((string) $value); $current = (string) get_option('answerrails_active_provider', 'openai'); if (!in_array($value, AnswerRails_Provider_Factory::get_provider_ids(), true)) { return $current; } return $value; } /** * Model picker (UI unification, Jul 2026): one firm dropdown per * provider, fed from the capability-matrix catalogue — the single * source for model lists; NEVER hardcode a model list in a view — plus * a "Custom model id…" escape hatch for vendor renames between * releases. A saved id that is not in the catalogue is kept as a * "(current setting)" option so saving the page never silently * switches models (e.g. a site still on a retired id). * * The companion custom text field is deliberately NOT a registered * setting: resolve_model_choice() folds it into the model option at * save time and it is never stored itself. */ public function render_model_picker(string $provider, string $option_name, string $default): void { $catalog = AnswerRails_Model_Capabilities::models_for($provider); $current = (string) get_option($option_name, $default); $custom_id = $option_name . '_custom'; ?> 0) { $attachment_id = $posted_id; } if ($attachment_id <= 0) { return ''; } $attachment = get_post($attachment_id); if (!$attachment || 'attachment' !== $attachment->post_type) { return ''; } $mime_type = get_post_mime_type($attachment); if (empty($mime_type) || strpos($mime_type, 'image/') !== 0) { return ''; } $uploads = wp_get_upload_dir(); if (!empty($uploads['baseurl'])) { $baseurl = trailingslashit($uploads['baseurl']); if (strpos($value, $baseurl) !== 0) { // Enforce media library storage policy by using the canonical upload URL $value = wp_get_attachment_image_url($attachment_id, 'full'); } } $image_url = wp_get_attachment_image_url($attachment_id, 'thumbnail'); if (!$image_url) { $image_url = wp_get_attachment_url($attachment_id); } if (!$image_url) { return ''; } return esc_url_raw($image_url); } public function sanitize_launcher_position($value) { $allowed = array('bottom-right', 'bottom-left', 'bottom-center'); return in_array($value, $allowed, true) ? $value : 'bottom-right'; } public function sanitize_launcher_size($value) { $allowed = array('compact', 'comfortable', 'spacious'); return in_array($value, $allowed, true) ? $value : 'comfortable'; } public function sanitize_launcher_visual($value) { $allowed = array('bubble', 'avatar'); return in_array($value, $allowed, true) ? $value : 'bubble'; } public function sanitize_window_preset($value) { $allowed = array('compact', 'standard', 'tall'); return in_array($value, $allowed, true) ? $value : 'standard'; } /** * Sanitize the lead reply message; if left blank, restore the built-in * default. (Core-registered: the option is read by rest-chat. The * grounding/capture-instruction sanitizers moved to the Pro settings * surface with their register_setting sites — #174) */ public function sanitize_lead_message($value) { $clean = trim(sanitize_textarea_field((string) $value)); return $clean !== '' ? $clean : AnswerRails_REST_Chat::default_lead_message(); } public function sanitize_launcher_bg_color($value) { $sanitized = sanitize_hex_color($value); return $sanitized ?: '#1f2937'; } public function sanitize_launcher_text_color($value) { $sanitized = sanitize_hex_color($value); return $sanitized ?: '#ffffff'; } /** * Sanitize link priority setting. * * #331 D6: the only LOOSE in_array() left in this file's enum family. * The loose form already returned the default for anything unmatched, * so strictness closes type juggling only — PHP 7 would have matched * 0 == 'products' — which no legitimate caller sends. Behaviour is * therefore unchanged for every real input, and the shape now matches * its four siblings above. */ public function sanitize_link_priority($value) { $valid = array('products', 'recent', 'confidence'); if (!is_string($value)) { return 'products'; } return in_array($value, $valid, true) ? $value : 'products'; } // ⚠ answerrails_variation_config has no sanitiser here, because it has // no UI field: a sanitiser for a field the form never posts force-stores // its own defaults on every section save. /** * Sanitize comma separated domain list. */ public function sanitize_domain_list($value) { if (is_array($value)) { $value = implode(',', $value); } $domains = array_filter(array_map('trim', explode(',', (string) $value))); if (empty($domains)) { return ''; } $sanitized = array(); foreach ($domains as $domain) { $domain = strtolower(sanitize_text_field($domain)); if ($domain === '') { continue; } // Only allow hostname-safe characters. $domain = preg_replace('/[^a-z0-9\.-]/', '', $domain); if ($domain === '') { continue; } $sanitized[] = $domain; } if (empty($sanitized)) { return ''; } $sanitized = array_unique($sanitized); return implode(', ', $sanitized); } }