0.45, 'completeness' => 0.20, 'citations' => 0.15, 'response_time' => 0.10, 'api_performance' => 0.10, ); // Calculate individual component scores from reliable signals. $components = array( 'grounding' => $this->score_grounding( $grounded, $top_score, $outcome ), 'completeness' => $this->score_completeness( $response_data ), 'citations' => $citation_num > 0 ? 1.0 : 0.0, 'response_time' => $this->score_response_time( isset( $response_data['response_time_ms'] ) ? $response_data['response_time_ms'] : 0 ), 'api_performance' => $this->score_api_performance( isset( $response_data['api_used'] ) ? $response_data['api_used'] : '', isset( $response_data['fallback_used'] ) ? $response_data['fallback_used'] : false ), ); // Calculate weighted overall score. $overall_score = 0; foreach ( $components as $component => $score ) { $overall_score += $score * $weights[ $component ]; } // Determine confidence level. $confidence_level = $this->determine_confidence_level( $overall_score ); return array( 'score' => round( $overall_score * 100 ), // 0-100 scale. 'confidence_level' => $confidence_level, 'components' => $components, 'weights' => $weights, 'timestamp' => current_time( 'mysql' ), 'grounded' => $grounded, 'outcome' => $outcome, 'is_handoff' => $is_handoff, 'retrieval_top_score' => round( $top_score, 4 ), // #143 pass-through: near-miss diagnostics recorded per turn. // Deliberately NOT scoring inputs — score_grounding() only reads // top_score on grounded turns, where below_threshold is false. 'retrieval_below_threshold' => ! empty( $response_data['retrieval_below_threshold'] ), 'retrieval_rows' => isset( $response_data['retrieval_rows'] ) ? (int) $response_data['retrieval_rows'] : 0, // #142 pass-through: focus-resolution provenance. Like #143's // fields, deliberately NOT scoring inputs. 'grounding_passes' => isset( $response_data['grounding_passes'] ) ? (int) $response_data['grounding_passes'] : 1, 'pivot_term' => isset( $response_data['pivot_term'] ) ? (string) $response_data['pivot_term'] : '', 'pass2_top_score' => isset( $response_data['pass2_top_score'] ) ? (float) $response_data['pass2_top_score'] : 0.0, // #364 pass-through: follow-up context-pass provenance. Not // scoring inputs; the #142 keys keep their focus-pass meaning. 'context_turns' => isset( $response_data['context_turns'] ) ? (int) $response_data['context_turns'] : 0, 'context_pass_top_score' => isset( $response_data['context_pass_top_score'] ) ? (float) $response_data['context_pass_top_score'] : 0.0, 'retrieval_source' => isset( $response_data['retrieval_source'] ) ? (string) $response_data['retrieval_source'] : '', 'citation_count' => $citation_num, 'provider' => isset( $response_data['provider'] ) ? (string) $response_data['provider'] : '', 'model' => isset( $response_data['model'] ) ? (string) $response_data['model'] : '', // Deflection now comes from the real outcome, not phrase scanning. 'deflection_detected' => $is_handoff, ); } /** * Score grounding quality from real retrieval signals. * * @param bool $grounded Whether retrieval injected relevant context. * @param float $top_score Best retrieval relevance score. * @param string $outcome grounded | answered_ungrounded | handoff. * @return float Score 0-1. */ private function score_grounding( $grounded, $top_score, $outcome ) { if ( 'handoff' === $outcome ) { // Handing off when grounding is missing is correct behaviour, not a failure. return 0.6; } if ( $grounded ) { // Scale by retrieval relevance: ~0.3 (just over threshold) up to >=0.6 = full. return max( 0.6, min( 1.0, $top_score / 0.6 ) ); } // Answered from general knowledge (no KB match) — fine for general questions. return 0.5; } // get_analytics() moved to AnswerRails_Pro_Analytics (#174 — // the BI assembly half; per-turn recording and the free grounding- // outcomes card metrics stay in this class). /** * Aggregate vendor-agnostic Overview metrics from per-turn signals. * * Scans recent conversations and reads the rag_metrics stored with each turn * (grounding, retrieval relevance, outcome, provider, citations) so every * figure reflects what actually happened rather than response-text heuristics. * * @param string $period Time period key. * @return array Overview metrics. */ public function get_overview_metrics( $period = '7_days' ) { global $wpdb; $days = 7; if ( '30_days' === $period ) { $days = 30; } elseif ( '24_hours' === $period ) { $days = 1; } elseif ( '90_days' === $period ) { $days = 90; } elseif ( 'all_time' === $period ) { $days = null; } $start_date = ( null !== $days ) ? gmdate( 'Y-m-d H:i:s', strtotime( "-{$days} days" ) ) : null; $limit = (int) apply_filters( 'answerrails_rag_overview_limit', 500 ); if ( $limit <= 0 ) { $limit = 500; } // phpcs:disable WordPress.DB.PreparedSQL -- fixed SQL over core tables; every variable clause goes through $wpdb->prepare() below (the sniff cannot see the reassignment). $query = "SELECT p.post_content FROM {$wpdb->posts} p WHERE p.post_type = 'answerrails_chatlog' AND p.post_status = 'publish'"; if ( null !== $start_date ) { $query .= ' AND p.post_date >= %s ORDER BY p.post_date DESC LIMIT %d'; $query = $wpdb->prepare( $query, $start_date, $limit ); } else { $query .= ' ORDER BY p.post_date DESC LIMIT %d'; $query = $wpdb->prepare( $query, $limit ); } $rows = $wpdb->get_results( $query ); // phpcs:enable $answered = 0; $grounded = 0; $ungrounded = 0; $handoff = 0; $conversational = 0; // #276 D2 / #278 D3: the three populations the loop used to lose — // integrity deflections (which fell through into grounded/ungrounded) // and moderated turns (which the no-rag_metrics `continue` discarded). $integrity_deflected = 0; $moderated = 0; $moderation_unavailable = 0; $relevance_sum = 0.0; $relevance_n = 0; $quality_sum = 0; $quality_n = 0; $providers = array(); $sources = array(); foreach ( $rows as $row ) { $turns = json_decode( $row->post_content, true ); if ( ! is_array( $turns ) ) { continue; } foreach ( $turns as $turn ) { $metrics = isset( $turn['metadata']['rag_metrics'] ) ? $turn['metadata']['rag_metrics'] : null; if ( ! is_array( $metrics ) ) { // #278 (D3): a turn with NO rag_metrics but an array // `metadata.moderation` is a moderated turn — the // content-safety layer answered before any AI ran, so // there are no retrieval metrics to have. Total Messages // counted it all along; the Overview did not. Turns with // neither stay the legacy/system skip below. $moderation = isset( $turn['metadata']['moderation'] ) ? $turn['metadata']['moderation'] : null; if ( is_array( $moderation ) ) { // The writer already draws this line at // class-answerrails-rest-chat.php:1010 — "fail-closed // is an infrastructure event, not a policy flag." // One says your guardrail worked, the other says fix // your provider; they never share a count. if ( 'fail_closed' === ( isset( $moderation['action'] ) ? (string) $moderation['action'] : '' ) ) { $moderation_unavailable++; } else { $moderated++; } } // Moderated turns enter NO rate: $answered is not // incremented, so every denominator is unchanged (D3). continue; // No reliable signal (legacy/system turn). } $answered++; $outcome = isset( $metrics['outcome'] ) ? (string) $metrics['outcome'] : ''; $is_hand = ( 'handoff' === $outcome ) || ! empty( $metrics['is_handoff'] ); $provider = ( isset( $metrics['provider'] ) && '' !== $metrics['provider'] ) ? (string) $metrics['provider'] : 'unknown'; if ( ! isset( $providers[ $provider ] ) ) { $providers[ $provider ] = 0; } $providers[ $provider ]++; if ( $is_hand ) { $handoff++; continue; // Handoffs are not answers; excluded from grounding/quality. } // #136 (DD12, 2026-08-06): conversational turns are their // own segment — never grounded/ungrounded, never quality, // never relevance. A greeting's 0.948 grounding component // was noise in the metric the owner tunes by. if ( 'conversational' === $outcome ) { $conversational++; continue; } // #276 D2 (2026-08-29): the #124/#128 integrity deflections // are DEFLECTIONS, not answers — the visitor was declined a // substantive answer. They used to fall through to the // `grounded` flag below, so a `fabricated` turn stored with // `grounded: true` counted as "answered from your knowledge // base" (and fed Avg Retrieval Relevance), an // `ungrounded_deflected` turn counted as "answered with // general knowledge", and BOTH carried their forced score 0 // into Answer Quality. They now feed grounding, quality and // relevance alike: none of them. if ( 'fabricated' === $outcome || 'ungrounded_deflected' === $outcome ) { $integrity_deflected++; continue; } // Answer quality over answered (non-handoff) turns. if ( isset( $metrics['score'] ) && is_numeric( $metrics['score'] ) ) { $quality_sum += (int) $metrics['score']; $quality_n++; } // #276 D2: outcome is the visitor-facing truth; `grounded` is // a RETRIEVAL signal (it means evidence was found above // threshold) and is no longer read as an outcome. Records // written before the vocabulary carry no outcome and keep // their flag-based classification — historical records are // never relabelled (the #136/DD12 precedent). if ( 'grounded' === $outcome || 'answered_ungrounded' === $outcome ) { $is_grounded = ( 'grounded' === $outcome ); } else { $is_grounded = ! empty( $metrics['grounded'] ); } if ( $is_grounded ) { $grounded++; $score = isset( $metrics['retrieval_top_score'] ) ? (float) $metrics['retrieval_top_score'] : 0.0; if ( $score > 0 ) { $relevance_sum += $score; $relevance_n++; } } else { $ungrounded++; } // Top sources used (from per-turn citations, when logged). if ( ! empty( $turn['metadata']['citations'] ) && is_array( $turn['metadata']['citations'] ) ) { foreach ( $turn['metadata']['citations'] as $citation ) { if ( ! is_array( $citation ) ) { continue; } $label = isset( $citation['label'] ) ? trim( (string) $citation['label'] ) : ''; if ( '' === $label ) { continue; } if ( ! isset( $sources[ $label ] ) ) { $sources[ $label ] = array( 'label' => $label, 'url' => isset( $citation['url'] ) ? (string) $citation['url'] : '', 'count' => 0, ); } $sources[ $label ]['count']++; } } } } $answered_real = $grounded + $ungrounded; // Non-handoff answered turns. uasort( $sources, static function ( $a, $b ) { return $b['count'] - $a['count']; } ); arsort( $providers ); return array( 'period' => $period, 'answered_turns' => $answered, 'grounded_turns' => $grounded, 'ungrounded_turns' => $ungrounded, 'handoff_turns' => $handoff, 'conversational_turns' => $conversational, // #276 D2 / #278 D3 — new counts; every existing key above and // below keeps its name AND its definition. 'integrity_deflected_turns' => $integrity_deflected, 'moderated_turns' => $moderated, 'moderation_unavailable_turns' => $moderation_unavailable, 'grounding_rate' => $answered_real > 0 ? round( ( $grounded / $answered_real ) * 100, 1 ) : null, 'handoff_rate' => $answered > 0 ? round( ( $handoff / $answered ) * 100, 1 ) : null, 'avg_retrieval_relevance' => $relevance_n > 0 ? round( $relevance_sum / $relevance_n, 2 ) : null, 'answer_quality' => $quality_n > 0 ? (int) round( $quality_sum / $quality_n ) : null, 'providers' => $providers, 'top_sources' => array_slice( array_values( $sources ), 0, 8 ), ); } // detect_knowledge_gaps() and its gap helpers (classify_gap_turn, // normalise_question, mask_pii_text, generate_gap_recommendation) // moved to AnswerRails_Pro_Analytics (#174 — gap mining is a // BI surface). /** * Score response time performance * * @since 1.0.0 * @param int $response_time_ms Response time in milliseconds. * @return float Score 0-1. */ private function score_response_time( $response_time_ms ) { if ( $response_time_ms <= 0 ) { return 0.5; // No data. } // Scoring thresholds (in milliseconds). if ( $response_time_ms < 1000 ) { return 1.0; // Excellent. } if ( $response_time_ms < 2000 ) { return 0.9; // Very good. } if ( $response_time_ms < 3000 ) { return 0.7; // Good. } if ( $response_time_ms < 5000 ) { return 0.5; // Acceptable. } if ( $response_time_ms < 10000 ) { return 0.3; // Slow. } return 0.1; // Very slow. } /** * Score response completeness * * @since 1.0.0 * @param array $response_data Response data. * @return float Score 0-1. */ private function score_completeness( $response_data ) { $text = isset( $response_data['text'] ) ? $response_data['text'] : ''; if ( empty( $text ) ) { return 0; } // Basic completeness metrics. $word_count = str_word_count( $text ); $has_greeting = preg_match( '/hello|hi|welcome/i', $text ); $has_closing = preg_match( '/anything else|help you|let me know/i', $text ); $score = 0.5; // Base score. // Adjust based on length (sweet spot is 50-200 words). if ( $word_count >= 50 && $word_count <= 200 ) { $score += 0.3; } elseif ( $word_count > 200 ) { $score += 0.2; // Still good but might be verbose. } elseif ( $word_count >= 20 ) { $score += 0.1; } if ( $has_closing ) { $score += 0.2; } return min( 1, $score ); } /** * Score API performance * * @since 1.0.0 * @param string $api_used API used. * @param bool $fallback_used Whether fallback was used. * @return float Score 0-1. */ private function score_api_performance( $api_used, $fallback_used ) { if ( $fallback_used ) { return 0.7; // Fallback was needed. } if ( in_array( $api_used, array( 'responses', 'mistral', 'anthropic' ), true ) ) { return 1.0; // Primary path worked (single-endpoint providers included). } return 0.9; // Chat completions API (OpenAI's fallback-preferred endpoint). } /** * Determine confidence level from score * * @since 1.0.0 * @param float $score Overall score. * @return string Confidence level. */ private function determine_confidence_level( $score ) { if ( $score >= self::CONFIDENCE_THRESHOLD_HIGH ) { return 'high'; } if ( $score >= self::CONFIDENCE_THRESHOLD_MEDIUM ) { return 'medium'; } if ( $score >= self::CONFIDENCE_THRESHOLD_LOW ) { return 'low'; } return 'very_low'; } }