openai_handler = new AnswerRails_REST_OpenAI(); // Response_Service takes no logger — its ledger writers live in the // capture module (#174). $this->response_service = $response_service ?: new AnswerRails_Response_Service(); // ADD: RAG Analytics initialization if (class_exists('AnswerRails_RAG_Analytics')) { $this->rag_analytics = new AnswerRails_RAG_Analytics(); } // ⚠ #163: link-cache invalidation does NOT register here. This // constructor runs only during REST chat requests // (rest-router.php), so a save/delete listener registered here never // fires in the ADMIN and REST requests where knowledge items are // saved and deleted. The listeners live unconditionally in // AnswerRails::init_components(), following the #144 site-index // precedent. } /** * The turn writer this handler logs through. * * #128 D6 needs it from the ROUTER: the failed-turn record is written * one layer up, where the copy the visitor was actually shown exists. * Exposing the existing instance keeps that write going through the * SAME service — and therefore the same #116 write authority — as every * other transcript write, rather than constructing a second one. * * @since 3.20.0 * @return AnswerRails_Response_Service */ public function get_response_service(): AnswerRails_Response_Service { return $this->response_service; } /** * Process complete chat request with dual API support */ public function process_chat_request(\WP_REST_Request $req, array $payload, string $session_id, array $messages, array $attachments) { $start_time = microtime(true); try { // 1) Normalize and validate messages $normalized_messages = $this->normalize_messages($messages); if (empty($normalized_messages)) { return new \WP_Error('invalid_messages', 'No valid messages found'); } $user_text = $this->find_last_user_text($normalized_messages); // #262 D6: the per-message bound's SERVER companion. // // ⚠ This is not the remedy and must never be mistaken for it. // The failure #262 documents happens BEFORE the origin: an // intermediary refuses the oversized request and WordPress never // sees it, so for a widget visitor this line can never fire. The // remedy is the widget's live byte check. This exists so a // NON-widget caller — a script, an integration, a stale embed — // gets a readable refusal instead of whatever the intermediary // does, and it is the one half of D6 the unit suite can execute. // // Sited after normalisation because `$user_text` is a string here // whatever shape the caller sent (`extract_text_from_content()` // owns that), and BEFORE moderation so a 50 KB paste is refused // without being posted to a classifier. $message_limit = AnswerRails_REST_Auth::message_byte_limit(); if ($message_limit > 0 && strlen($user_text) > $message_limit) { return new \WP_Error( 'message_too_long', __('That message is too long to send. Please shorten it and try again.', 'answerrails'), array('status' => 413) ); } // #119: visitor-input moderation — the 3b choke point. // Runs BEFORE the escalation flow, retrieval, and any // model call, which is what makes D6 suppression STRUCTURAL: a // refused, deflected, or signposted turn can never trip // escalation/contact capture, feed idea/lead capture, or count // as answerable in rag analytics. Mandatory, with no // off-switch (D12). $moderated = $this->maybe_handle_moderated_turn($req, $session_id, $user_text, $start_time); if (null !== $moderated) { return $moderated; // Response array, or WP_Error (fail closed). } // E6 (#174, `answerrails_chat_turn_handler`): the turn // interception point. Default null — the AI answers normally. // The pro escalations module returns its handler (contact // capture, re-prompt, escalation-only turns — AI skipped when // the handler returns a full response array). $turn_handler = apply_filters('answerrails_chat_turn_handler', null); if (is_callable($turn_handler)) { $handled = call_user_func($turn_handler, array( 'request' => $req, 'session_id' => $session_id, 'messages' => $normalized_messages, 'user_text' => $user_text, 'start_time' => $start_time, 'chat_turn' => $this->build_chat_turn_context($req, $session_id, $normalized_messages, $user_text), )); if (is_array($handled)) { return $handled; } } // 2) Attachments pass through as-is. Knowledge grounding happens // via context injection inside process_openai_request(), never // by attaching a vector store to the model (#54). $all_attachments = $attachments; // 4) Process with OpenAI (using enhanced dual API handler) $ai_result = $this->openai_handler->process_openai_request($normalized_messages, $all_attachments, $session_id, $req); // #137 (GROUNDING_COMPLETENESS_DESIGN, DD13 as amended): // ONE code-owned authority reads the model's `conversational` // flag; the four consumers below (citation credit, the // conversational outcome, the enforcement bypass, visitor // citation display) all consume THIS, never the raw flag. $conversational_accepted = !is_wp_error($ai_result) && AnswerRails_Response_Service::conversational_accepted($ai_result); // RAG Analytics Integration $rag_metrics = null; if ($this->rag_analytics && !is_wp_error($ai_result)) { // Prepare response data for RAG scoring. Feed the reliable, // vendor-agnostic per-turn signals from the chat flow (grounding, // retrieval relevance, real citation count, outcome, provider) so // the scorer relies on no provider-specific text heuristic. $response_data = array( 'text' => isset($ai_result['text']) ? $ai_result['text'] : '', 'model' => isset($ai_result['model']) ? $ai_result['model'] : '', // #204 D4 (F5b): the provider value is ALREADY ms (the // provider-interface contract) — the old ×1000 here fed // the scorer µs-scale numbers and permanently floored // score_response_time() for every LLM turn. 'response_time_ms' => isset($ai_result['response_time']) ? (float) $ai_result['response_time'] : 0, 'api_used' => isset($ai_result['api_used']) ? $ai_result['api_used'] : '', 'fallback_used' => isset($ai_result['fallback_used']) ? $ai_result['fallback_used'] : false, 'grounded' => !empty($ai_result['grounded']), 'retrieval_top_score' => isset($ai_result['retrieval_top_score']) ? (float) $ai_result['retrieval_top_score'] : 0.0, // #143: near-miss diagnostics — on ungrounded turns // top_score is the best SUB-threshold score when // below_threshold is true; rows distinguishes "store // returned nothing" from "nothing cleared threshold". 'retrieval_below_threshold' => !empty($ai_result['retrieval_below_threshold']), 'retrieval_rows' => isset($ai_result['retrieval_rows']) ? (int) $ai_result['retrieval_rows'] : 0, // #142: focus-resolution provenance (pass count, pivot, // pass-2 score) — recorded per turn for diagnostics. 'grounding_passes' => isset($ai_result['grounding_passes']) ? (int) $ai_result['grounding_passes'] : 1, 'pivot_term' => isset($ai_result['pivot_term']) ? (string) $ai_result['pivot_term'] : '', 'pass2_top_score' => isset($ai_result['pass2_top_score']) ? (float) $ai_result['pass2_top_score'] : 0.0, // #364: follow-up context-pass provenance — not scoring // inputs; the #142 keys keep their focus-pass meaning. 'context_turns' => isset($ai_result['context_turns']) ? (int) $ai_result['context_turns'] : 0, 'context_pass_top_score' => isset($ai_result['context_pass_top_score']) ? (float) $ai_result['context_pass_top_score'] : 0.0, // #135: a CONVERSATIONAL turn earns no citation credit. // Retrieval runs unconditionally, so a greeting still // returns nearest neighbours (chatlog 553: "Hello" pulled // the default "Hello world!" post at 0.5688 — ABOVE // threshold) and the model dutifully lists them. Since // the #136 decision (DD12) this feeds the minimal // conversational shape below; reads via the ONE #137 // authority. 'citation_count' => $conversational_accepted ? 0 : (isset($ai_result['citation_count']) ? (int) $ai_result['citation_count'] : 0), 'outcome' => isset($ai_result['outcome']) ? (string) $ai_result['outcome'] : '', // Which engine grounded the turn: vector / lexical (local // fallback during a search outage) / none. Scores are NOT // comparable across sources — this is the segmentation key. 'retrieval_source' => isset($ai_result['retrieval_source']) ? (string) $ai_result['retrieval_source'] : '', 'provider' => isset($ai_result['provider']) ? (string) $ai_result['provider'] : 'openai', ); $context = array( 'session_id' => $session_id, 'attachments' => $all_attachments, 'chatlog_id' => null // Will be set after logging ); // #136 (DD12, decided 2026-08-06): an accepted // conversational turn is NOT scored as a retrieval outcome // — its 0.948-class grounding component was noise diluting // the metric the owner tunes by. It records the // `conversational` outcome with the per-turn FACTS // (retrieval evidence, provenance) and no score/components; // aggregates and the gaps panel exclude it (its own // Overview segment). Historical records are not relabelled. if ($conversational_accepted) { $rag_metrics = array( 'outcome' => 'conversational', 'conversational_accepted' => true, 'score' => null, 'confidence_level' => '', 'components' => array(), 'deflection_detected' => false, 'grounded' => !empty($response_data['grounded']), 'retrieval_top_score' => $response_data['retrieval_top_score'], 'retrieval_source' => $response_data['retrieval_source'], 'retrieval_below_threshold' => $response_data['retrieval_below_threshold'], 'retrieval_rows' => $response_data['retrieval_rows'], 'grounding_passes' => $response_data['grounding_passes'], 'pivot_term' => $response_data['pivot_term'], 'pass2_top_score' => $response_data['pass2_top_score'], // #364: not scoring inputs; the #142 keys keep their // focus-pass meaning. 'context_turns' => $response_data['context_turns'], 'context_pass_top_score' => $response_data['context_pass_top_score'], 'citation_count' => 0, 'provider' => $response_data['provider'], 'model' => $response_data['model'], 'timestamp' => current_time('mysql'), ); } else { // Calculate RAG metrics. The scorer returns the grounding/outcome/ // provider signals back out, ready for store_rag_metrics(). $rag_metrics = $this->rag_analytics->calculate_rag_score($response_data, $context); } // Add RAG metrics to result for downstream processing $ai_result['rag_metrics'] = $rag_metrics; } if (is_wp_error($ai_result)) { // Log API failure for health monitoring $this->log_api_failure($ai_result, microtime(true) - $start_time); return $ai_result; } $assistant_text = $ai_result['text'] ?? ''; $model_used = $ai_result['model'] ?? 'unknown'; $api_used = $ai_result['api_used'] ?? 'unknown'; $fallback_used = $ai_result['fallback_used'] ?? false; $response_time = $ai_result['response_time'] ?? 0; if (empty($assistant_text)) { return new \WP_Error('empty_response', 'Empty response from AI service', [ 'debug_info' => $ai_result['raw'] ?? null, 'api_used' => $api_used, 'fallback_used' => $fallback_used ]); } // 6) Process response (formatting, truncation, idea extraction) $this->response_service->reset_url_violations(); $processed_response = $this->response_service->process_assistant_response($assistant_text, $session_id); // #123 Path B (Response Integrity II, decision 18): any // INVENTED INTERNAL URL in the model output is confabulation // evidence — the whole turn deflects via the #117 safe-error // machinery. Never strip-and-serve an internal fabrication. $fabricated_urls = $this->response_service->get_fabricated_internal_urls(); if (!empty($fabricated_urls)) { return $this->handle_fabricated_turn($req, $session_id, $user_text, $assistant_text, $fabricated_urls, $ai_result, $rag_metrics); } // #123 L3: caught external-invalid URLs (strip-and-serve // turns) cap the score — a violation turn must never report // grounded/85/high (the Monymusk case). Outcome is unchanged; // only the blessing is withdrawn. if (is_array($rag_metrics) && count($this->response_service->get_url_violations()) > 0) { $rag_metrics['score'] = (float) min((float) $rag_metrics['score'], self::URL_VIOLATION_SCORE_CAP); if ('high' === (string) ($rag_metrics['confidence_level'] ?? '')) { $rag_metrics['confidence_level'] = 'medium'; } $ai_result['rag_metrics'] = $rag_metrics; } // #124 S3 scope enforcement (Response Integrity II): an // answered_ungrounded SUBSTANTIVE turn is served as the // integrity deflection instead of the ungrounded answer // (chatlog 15425 turn 5: retrieval 0, zero citations, answered // anyway — an off-topic editorial in the customer's voice). // Grounded and handoff outcomes never reach this; conversational // niceties pass (the schema flag); flagless paths (Anthropic / // structured off / fallback) keep status-quo serve (D5 accepted // residual gap — no structural signal to enforce on). if ('answered_ungrounded' === (string) ($ai_result['outcome'] ?? '') && !empty($ai_result['structured']) && !$conversational_accepted // #137: via the ONE authority ) { return $this->handle_ungrounded_turn($req, $session_id, $user_text, $assistant_text, $ai_result, $rag_metrics, $start_time); } // Structured-output extras: append citations and carry handoff flags. $citations = (isset($ai_result['citations']) && is_array($ai_result['citations'])) ? $ai_result['citations'] : []; // Decision 12 (2026-07-25): citations are FREE — trust is free, // leverage is Pro. The control is the owner-facing Sources // toggle, answerrails_require_citations. The chat-log metadata // below keeps recording citations regardless (owner-side // analytics, not the visitor surface). // #135: never serve a Sources block on a CONVERSATIONAL turn. // "Hello" → "Hello! How can I help you today?" was shipping // "Sources: Hello world!, Clan Name Relationships" — citing two // documents the greeting demonstrably did not use. Asserting // provenance that isn't real is the visitor-facing twin of what // #124 prevents in the admin. The transcript metadata below still // records the model's citations verbatim (decision 12: owner-side // analytics are not the visitor surface, and the record of what // retrieval offered stays auditable). // // Flagless paths (Anthropic classifier, structured output off, // some fallbacks) never set `conversational`, so they keep // status-quo behaviour — the same accepted residual gap #124 S3 // carries as D5. Stated, not silently inherited. $is_conversational = $conversational_accepted; // #137: via the ONE authority // #152 (CITATION_PRECISION_DESIGN): on the grounded path the // rendered Sources list is the USED subset, not the retrieval // set — rendered_citations() is the ONE selection authority // (used-vs-retrieved filter, top-N cap, top-1 fallback). The // full recorded set still lands in the transcript below // (decision 12: record ≠ render), each entry flagged with // whether it was served. $visible_citations = ($this->sources_enabled() && !$is_conversational) ? $this->response_service->rendered_citations($citations, $assistant_text) : []; // #152: which recorded citations were actually served — keyed the // same way the builders dedupe (url, else label). $rendered_keys = array(); foreach ($visible_citations as $vc) { $vc_key = ($vc['url'] ?? '') !== '' ? (string) $vc['url'] : (string) ($vc['label'] ?? ''); if ($vc_key !== '') { $rendered_keys[$vc_key] = true; } } // #152: recorded-vs-rendered is a per-turn metric fact // (citation_count keeps its meaning — the RECORDED set feeds the // score; changing what the score means is the #128 D8 class, // explicitly out of this cycle's scope). if (is_array($rag_metrics)) { $rag_metrics['citations_rendered'] = count($visible_citations); $ai_result['rag_metrics'] = $rag_metrics; } // #204 D1: the internal signal finally carries its meaning — // "the model deflected" — under its own name. No escalation is // ever assembled on this path (escalation turns return earlier // through the E6 turn handler), so the payload's derived // handoff_triggered below is false by construction here. $deflection_triggered = !empty($ai_result['deflection_triggered']); $assistant_html = $processed_response['html']; // E5 (#174 S2): assemble this turn's capture payloads — the RAW // schema-native payload per registered E4 extension key, plus // the tag-extracted arrays the safety strip yields (both // mechanisms; the consumer merges them source-blind, v3.5.0) — // and hand them to the capture consumers. Free never interprets // or stores a capture: with nothing hooked the payloads are // empty (no extension ever instructed the model) and the action // is a no-op. $turn_captures = array(); foreach ((array) ($ai_result['captures'] ?? array()) as $ar_capture_key => $ar_capture_payload) { $turn_captures[$ar_capture_key]['schema'] = $ar_capture_payload; } foreach (array('ideas', 'leads') as $ar_tag_key) { if (!empty($processed_response[$ar_tag_key]) && is_array($processed_response[$ar_tag_key])) { $turn_captures[$ar_tag_key]['tags'] = $processed_response[$ar_tag_key]; } } $turn_context = array( 'session_id' => $session_id, 'user_text' => $user_text, 'deflection_triggered' => $deflection_triggered, 'structured' => !empty($ai_result['structured']), 'rag_metrics' => is_array($rag_metrics) ? $rag_metrics : array(), 'chat_turn' => $this->build_chat_turn_context($req, $session_id, $normalized_messages, $user_text), ); // Persistence is the consumer's business (the pro capture // module sanitises, gates — #151 sensitivity — and stores). // Fires BEFORE the turn is logged so consumers can contribute // honest counts to the turn metadata below (#158 ordering). do_action('answerrails_response_captured', $turn_captures, $turn_context); // Reply filter (#174 D4 companion): a capture consumer may // replace or append the visitor-facing reply — pro's lead // contact-ask (#70) replaces the generic handoff text or // appends the ask to a confident answer. $turn_reply = apply_filters('answerrails_chat_turn_reply', array( 'html' => $assistant_html, 'raw_text' => $processed_response['raw_text'], 'deflection_triggered' => $deflection_triggered, 'suppress_citations' => false, ), $turn_captures, $turn_context); $assistant_html = (string) ($turn_reply['html'] ?? $assistant_html); $processed_response['raw_text'] = (string) ($turn_reply['raw_text'] ?? $processed_response['raw_text']); if (!empty($turn_reply['suppress_citations'])) { // The lead contact-ask replaced the handoff text outright — // a Sources block under it would credit sources the visitor // was never shown the answer from. $citations = []; $visible_citations = []; } if (!empty($visible_citations)) { $assistant_html .= $this->response_service->format_citations($visible_citations); } // 7) Log the complete conversation turn with API performance data $metadata = [ 'model' => $model_used, 'api_used' => $api_used, 'fallback_used' => $fallback_used, 'response_time' => $response_time, // #204 D2: both truth signals recorded per turn. On this // path no escalation exists (escalation turns return via // the E6 handler and are deliberately never logged — PII // rule), so handoff_triggered is false by construction; // deflection carries the model's own low-confidence signal. 'handoff_triggered' => false, 'deflection' => $deflection_triggered, // #124 S3 audit trail (added 2026-07-29 after the first live // replay): record the scope-enforcement inputs so a served // answered_ungrounded turn is distinguishable in the chatlog // — "model flagged it conversational" vs "schema round-trip // fell back" (D5 path) cannot be told apart otherwise. 'structured' => !empty($ai_result['structured']), 'conversational' => !empty($ai_result['conversational']), // #137 (DD13 as amended): what the single authority DECIDED // — recorded beside the raw flag and the grounded fact so a // misflag is diagnosable from the log (the auditable // residual the amendment accepts). 'conversational_accepted' => $conversational_accepted, 'truncated' => $processed_response['truncated'], 'attachments' => $all_attachments, // #174 S2: the capture metadata (the #158 ideas trio, leads, // unanswered_captured) is contributed by the capture // consumer through the metadata filter below — free records // no capture claims of its own. 'rag_metrics' => $rag_metrics, // Slim citation records (label + url) so the Overview can report // which knowledge sources actually get used. #152: `rendered` // records per source whether the visitor was shown it (the // used-vs-retrieved audit); the internal threading keys // (grounding_text, score) are stripped HERE by construction — // the slim shape is the only one stored (test-pinned). 'citations' => array_map(static function ($c) use ($rendered_keys) { $label = isset($c['label']) ? (string) $c['label'] : ''; $url = isset($c['url']) ? (string) $c['url'] : ''; $key = $url !== '' ? $url : $label; return array( 'label' => $label, 'url' => $url, 'rendered' => $key !== '' && isset($rendered_keys[$key]), ); }, $citations), // #135: were those recorded citations actually SERVED? The // transcript keeps them either way (decision 12), so without // this flag the admin sees a "Sources:" line identical to the // visitor-facing block and cannot tell that the visitor saw // nothing — the same misreading, one layer up. // // Recorded as a FACT, not inferred from `conversational`: // turns written before this change had their citations served, // and re-deriving the answer from the flag would relabel // history with something false. Absent ⇒ the admin renders // the plain label, which is the honest reading of "we don't // know" (the #128 D2 principle). 'citations_withheld' => !empty($citations) && empty($visible_citations), ]; // #142 Layer C provenance: a repaired turn RECORDS the repair — // per-turn truth in the transcript metadata (the #128 // scope rule), the model's guess preserved as behaviour // evidence ({from, to, confidence}), never a new outcome // (GROUNDING_COMPLETENESS_DESIGN: path-vs-judgment). $link_repairs = $this->response_service->get_link_repairs(); if (!empty($link_repairs)) { $metadata['link_repairs'] = $link_repairs; if (is_array($rag_metrics)) { $rag_metrics['link_repairs'] = $link_repairs; $metadata['rag_metrics'] = $rag_metrics; } } // Capture-consumer metadata contributions (#174 D4 companion): // the pro capture module adds the honest counts — the #158 // ideas trio (found/accepted/stored), leads, and the // unanswered_captured flag. Free records no capture claims. $metadata = apply_filters('answerrails_chat_turn_metadata', $metadata, $turn_captures, $turn_context); $chatlog_id = $this->response_service->log_conversation_turn($session_id, $user_text, $processed_response['raw_text'], $metadata, $req); if ($chatlog_id && $rag_metrics) { $this->store_rag_metrics($chatlog_id, $rag_metrics); } if (!empty($link_repairs) && $chatlog_id) { // Conversation-scoped admin flag (last-turn-wins caveat // applies, as with every store_rag_metrics meta — per-turn // truth lives in the transcript metadata above). update_post_meta($chatlog_id, '_answerrails_link_repaired', 1); } $this->response_service->reset_url_violations(); // Post-log capture consumers (#174 D4 companion): with the // chatlog id in hand, the pro capture module bumps the // per-conversation ideas badge (#158), stamps the lead meta, // records the deterministic unanswered-question capture // (v3.4.0 — it genuinely needs the id), and sends the lead // notification (#70). do_action('answerrails_chat_turn_logged', (int) $chatlog_id, $turn_captures, $turn_context); // 9) Build enhanced response with API information $response = [ 'assistant_message' => $assistant_html, 'raw_message' => $processed_response['raw_text'], 'session_id' => $session_id, 'model' => $model_used, 'api_used' => $api_used, 'fallback_used' => $fallback_used, 'response_time' => $response_time, 'truncated' => $processed_response['truncated'], // Capture consumers own the count (0 with nothing hooked). 'ideas_found' => (int) ($metadata['ideas_found'] ?? 0), 'citations' => array_values(array_map(static function ($c) { return is_array($c) ? (string) ($c['label'] ?? '') : (string) $c; }, $visible_citations)), // #204 D2: handoff_triggered now tells the truth — true iff // the turn carries an `escalation` entry (trigger recorded). // This path never assembles one, so it is false here; the // pro escalation builder derives it from its own entry. // `deflection` carries the model's low-confidence deflection // signal. 'handoff_triggered' => false, 'deflection' => $deflection_triggered, 'finish_reason' => $ai_result['finish_reason'] ?? 'stop', 'rag_metrics' => $rag_metrics ? [ 'score' => $rag_metrics['score'], 'confidence' => $rag_metrics['confidence_level'], 'deflection' => $rag_metrics['deflection_detected'] ] : null ]; // Add debug info if in debug mode if (AnswerRails_REST_Router::is_debug($req)) { $response['debug'] = [ 'ideas_found' => (int) ($metadata['ideas_found'] ?? 0), 'original_length' => strlen($assistant_text), 'processed_length' => strlen($processed_response['html']), 'vector_stores' => $vector_attachments, 'api_performance' => [ 'api_used' => $api_used, 'fallback_used' => $fallback_used, 'response_time' => $response_time, 'fallback_reason' => $ai_result['fallback_reason'] ?? null ], 'rag_performance' => $rag_metrics, 'raw_response' => $ai_result['raw'] ?? null ]; } return $response; } catch (Exception $e) { error_log('AnswerRails Chat Processing Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- operational failure diagnostic: deliberately loud so provider/API failures reach server logs without WP_DEBUG // Log exception for health monitoring $this->log_api_failure( new \WP_Error('processing_exception', $e->getMessage()), microtime(true) - $start_time ); return new \WP_Error('processing_error', 'Error processing chat request'); } } /** * Log API failure for health monitoring */ private function log_api_failure(\WP_Error $error, float $response_time): void { try { // This helps the health monitor track overall system reliability error_log('AnswerRails API Failure: ' . $error->get_error_message() . ' (Response time: ' . round($response_time * 1000, 2) . 'ms)'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- operational failure diagnostic: deliberately loud so provider/API failures reach server logs without WP_DEBUG // Could enhance this to feed back into the health monitoring system // For now, just log for debugging } catch (Exception $e) { // Prevent logging failures from breaking the main request error_log('AnswerRails Logging Error: ' . $e->getMessage()); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- operational failure diagnostic: deliberately loud so provider/API failures reach server logs without WP_DEBUG } } /** * Normalize messages to consistent format */ private function normalize_messages(array $messages): array { $normalized = []; foreach ($messages as $message) { if (!is_array($message)) { continue; } // Require role and content if (!isset($message['role']) || !isset($message['content'])) { continue; } $role = isset($message['role']) ? $message['role'] : 'user'; if (is_string($role)) { $role = trim($role); if ($role !== '') { $role = function_exists('mb_strtolower') ? mb_strtolower($role, 'UTF-8') : strtolower($role); } } else { $role = 'user'; } $content = $message['content']; // Validate role if (!in_array($role, ['user', 'assistant', 'system'], true)) { $role = 'user'; // Default to user } // Normalize content if (is_array($content)) { $content = $this->extract_text_from_content($content); } else { $content = trim((string) $content); } // #228 D1: presentation never re-enters the model-facing // history. The rendered Sources block is appended to the DISPLAY // answer only; the widget stores that answer and replays it on // every later turn, so the model read a citation surface it had // not computed as its own prior words — and imitated it, down to // the widget's "(opens in new tab)" suffix. This is the ONE // ingest point for incoming history, which is why the strip also // heals sessions polluted before the fix, on their next request. // Assistant entries only: the same markup in a visitor's message // is quoted text, not presentation. Stored transcripts are never // rewritten (D4, the #136 precedent) — this is a read-time strip. if ($role === 'assistant') { $content = $this->strip_rendered_citations($content); } if (empty($content)) { continue; } $normalized[] = [ 'role' => $role, 'content' => $content ]; } return $normalized; } /** * Remove the rendered citations block from one history entry (#228 D1). * * The shape is read from its WRITER, `Response_Service`, which owns both * sides of the format (#144 format-contract rule) — this class must never * carry its own copy of the wrapper pattern. * * Content with no block is returned BYTE-IDENTICAL — the substring fast * path guarantees it, so this can never reshape an ordinary message. The * fast path is CASE-INSENSITIVE because the pattern it guards is (`~is`): * a case-folded wrapper the pattern would have removed must not be * waved through by a stricter pre-check (#228 gate note 6), and the * emission-side twin in `Response_Service` already reads this way. A * stripped result is re-trimmed so the caller's `empty()` guard drops an * entry that was nothing but a citations block. * * A PCRE failure returns the content unchanged rather than empty: the * leak this closes is a quality defect, and dropping a history entry * would be a functional one. */ private function strip_rendered_citations(string $content): string { if (stripos($content, AnswerRails_Response_Service::RENDERED_CITATIONS_CLASS) === false) { return $content; } $stripped = preg_replace(AnswerRails_Response_Service::RENDERED_CITATIONS_PATTERN, '', $content); if (!is_string($stripped)) { return $content; } return trim($stripped); } /** * Whether the visitor-facing Sources block may render. * * Decision 12 (2026-07-25): the site owner's Sources toggle (default * on) is the only control on this path (#104). */ private function sources_enabled(): bool { return (bool) get_option('answerrails_require_citations', true); } // ⚠ The escalation/contact turn flow lives in the pro escalations turn // handler, the E6 consumer — not here (#174). This class keeps the // generic chat-turn context builder below: it reads free security // plumbing and serves any E5/E6 consumer. /** * Default visitor-facing reply when a lead triggers alongside a content- * gating handoff (#70). Editable via answerrails_lead_message. Stays * free at S2: the Advanced Features page reads it for a field default * (a template is a caller); the pro capture module is the runtime * consumer. * * #203 D3: the copy is an OFFER that is true at send time. Nothing has * been "passed to our team" at this point — recording happens only when * a contact is captured — so the message must not claim it has (A * DRAFTED CLAIM EXPIRES AT SEND). Saved answerrails_lead_message * options are not migrated: owner copy is owner copy. */ public static function default_lead_message(): string { return __('That\'s exactly the kind of thing we can help with — if you\'d like a personal follow-up, share your email address or phone number and our team will be in touch.', 'answerrails'); } /** * The pre-#203 default lead message, retired for claiming an action that * had not happened. Kept verbatim because `sanitize_lead_message()` * bakes the CURRENT default into the option on an empty save — so any * site that ever saved the settings page stores the then-current default * word for word. A stored value equal to this string is therefore * provably not owner copy (#203). */ public const RETIRED_LEAD_MESSAGE = 'Great news — that\'s exactly the kind of thing we can help with! I\'ve passed your request to our team. To get you a personal quote, could you share your email address or phone number and we\'ll follow up directly?'; /** * Resolve the effective lead message from its stored option value * (#203): empty or the retired baked default → the current * honest default; anything else is owner copy, returned untouched. * The ONE resolution authority — the pro runtime consumer and the * settings view both read through it so the field never displays copy * the runtime would refuse to send. */ public static function resolve_lead_message(string $stored): string { $stored = trim($stored); if ('' === $stored || self::RETIRED_LEAD_MESSAGE === $stored) { return self::default_lead_message(); } return $stored; } /** * Build the chat-turn context payload handed to E5/E6 consumers (the * escalation service's turn shape). */ private function build_chat_turn_context(\WP_REST_Request $req, string $session_id, array $messages, string $user_text): array { $auth = new AnswerRails_REST_Auth(); $security = $auth->get_security_info($req); $timestamp = current_time('mysql', true); $conversation = []; $last_index = count($messages) - 1; foreach ($messages as $index => $message) { $conversation[] = array( 'role' => $message['role'] ?? 'user', 'content' => $message['content'] ?? '', 'time' => $index === $last_index ? $timestamp : '', ); } return array( 'session_id' => $session_id, 'ip' => $security['ip'] ?? '', 'user_agent' => $security['user_agent'] ?? '', 'user_text' => $user_text, 'timestamp' => $timestamp, 'site_id' => get_current_blog_id(), 'site_url' => home_url('/'), 'conversation' => $conversation, ); } /* ================================================================== * #119 visitor-input moderation (design: VISITOR_INPUT_MODERATION_DESIGN) * ================================================================== */ /** * Lazily resolve the moderation service (test-injectable property). */ private function moderation_service(): AnswerRails_Moderation_Service { if (!$this->moderation_service instanceof AnswerRails_Moderation_Service) { $this->moderation_service = new AnswerRails_Moderation_Service(); } return $this->moderation_service; } /** * The 3b moderation choke point. Returns: * - null → clean turn, the normal flow proceeds; * - array → a full widget-shaped response (deflection, signposting, * session end, or dropped-session refusal) — no model call; * - WP_Error → moderation itself failed → FAIL CLOSED (D9): the * distinct fail-closed message rides the error path so the * widget's #117 machinery restores the visitor's text. * * Log-before-enforce (test-pinned; the #114 rule applied to moderation): * on the third strike and the terminate tier the triggering turn AND the * session-end notice are written to the transcript BEFORE the session * drop is recorded — nothing is lost to enforcement. * * @return array|\WP_Error|null */ private function maybe_handle_moderated_turn(\WP_REST_Request $req, string $session_id, string $user_text, float $start_time) { if ('' === trim($user_text)) { return null; // Nothing to moderate; the normal flow handles it. } $service = $this->moderation_service(); // Dropped session: subsequent posts are refused politely — no // moderation call, no model call, no new transcript entries (the // drop itself was recorded when it happened). if ($service->is_session_dropped($session_id)) { return $this->build_moderation_response( $session_id, $service->response_string('session_end'), $start_time, array('tier' => '', 'session_ended' => true) ); } $verdict = $service->moderate($user_text); // Fail closed (D9): the turn is NOT sent to the model. Logged // as fail-closed (no strike, no policy flag); the distinct message // travels as a safe WP_Error so the widget restores the visitor's // text and offers retry (#117 machinery, 'transient' hint). if (!empty($verdict['error'])) { $this->log_moderated_turn($req, $session_id, $user_text, $service->response_string('fail_closed'), $verdict, 'fail_closed'); return new \WP_Error( 'moderation_unavailable', $service->response_string('fail_closed'), array('status' => 503, 'transient' => true) ); } if (!empty($verdict['allowed'])) { return null; // Clean turn. } $tier = (string) $verdict['tier']; // Terminate tier: immediate session end, no strike count, // automatic legal hold (D5/D11). Log-before-enforce ordering. if (AnswerRails_Moderation_Service::TIER_TERMINATE === $tier) { $served = $service->response_string('session_end'); $chatlog_id = $this->log_moderated_turn($req, $session_id, $user_text, $served, $verdict, 'terminate'); $this->response_service->append_moderation_notice( $session_id, __('Session ended — content policy.', 'answerrails'), array('tier' => AnswerRails_Moderation_Service::TIER_TERMINATE), $chatlog_id ); $this->place_moderation_hold($chatlog_id, $verdict); $service->drop_session($session_id); // AFTER the transcript writes. return $this->build_moderation_response($session_id, $served, $start_time, array( 'tier' => $tier, 'session_ended' => true, )); } // Tripwire tier: NO strike — a person in distress is not // an abuser. Signposting response, automatic legal hold, session // continues. if (AnswerRails_Moderation_Service::TIER_TRIPWIRE === $tier) { $served = $service->response_string('selfharm'); $chatlog_id = $this->log_moderated_turn($req, $session_id, $user_text, $served, $verdict, 'signpost'); $this->place_moderation_hold($chatlog_id, $verdict); return $this->build_moderation_response($session_id, $served, $start_time, array( 'tier' => $tier, 'session_ended' => false, )); } // Mid band (#124 S2, Response Integrity II): provider-flagged // but below the high-band threshold — deflect WITHOUT a strike, // neutral provenance record. Same visitor experience as a strike // deflection (the canned string is neutral and correct for a // customer-service widget regardless of the content's politics); // only the consequence differs (chatlog 15425: 0.81 on contested // mainstream speech must not count toward termination). if (AnswerRails_Moderation_Service::BAND_MID === (string) ($verdict['band'] ?? '')) { $served = $service->response_string('deflection'); $this->log_moderated_turn($req, $session_id, $user_text, $served, $verdict, 'deflect'); return $this->build_moderation_response($session_id, $served, $start_time, array( 'tier' => $tier, 'session_ended' => false, )); } // Strike tier (D2): each blocked turn is one strike; at the limit // the session drops. Log-before-enforce on the dropping turn. $strikes = $service->record_strike($session_id); if ($strikes >= $service->strike_limit()) { $served = $service->response_string('session_end'); $chatlog_id = $this->log_moderated_turn($req, $session_id, $user_text, $served, $verdict, 'session_end'); $this->response_service->append_moderation_notice( $session_id, __('Session ended — content policy (third strike).', 'answerrails'), array('tier' => AnswerRails_Moderation_Service::TIER_STRIKE, 'strikes' => $strikes), $chatlog_id ); $service->drop_session($session_id); // AFTER the transcript writes. return $this->build_moderation_response($session_id, $served, $start_time, array( 'tier' => $tier, 'session_ended' => true, )); } $served = $service->response_string('deflection'); $chatlog_id = $this->log_moderated_turn($req, $session_id, $user_text, $served, $verdict, 'deflect'); return $this->build_moderation_response($session_id, $served, $start_time, array( 'tier' => $tier, 'session_ended' => false, )); } /** * Log a moderated turn: VERBATIM + FLAG (D4) — the visitor's text is * stored unchanged as a normal turn (through the #116 * kses_safe_post_write() authority inside the turn writer), with a * `moderation` payload recording class(es), tier, provider model and * scores, and which canned response was served. Logging-mode off = off, * as everywhere (#109). * * D6 analytics honesty: the turn records outcome 'moderated' — it never * counts as an answerable turn. * * @return int Chatlog post id (0 when logging is off). */ private function log_moderated_turn(\WP_REST_Request $req, string $session_id, string $user_text, string $served, array $verdict, string $action): int { // #124 S1 (Response Integrity II): the stored record is // provenance-first — the judgment is attributed to the classifier // that made it (provider category + score + model). The internal // classes are stored under `routing_classes`: mapping vocabulary // (tiers, holds, filters), deliberately NOT named as a verdict — // this payload rides verbatim into the #110 DSAR transcript export. // Pre-provenance records render via the D3 neutral fallback; no // migration (pre-commercial policy). $metadata = array( 'model' => '', 'api_used' => 'moderation', 'fallback_used' => false, 'response_time' => 0, // #204 D2 (Codex P2 on PR #231): every logged-turn writer // records both truth signals — a moderated turn is neither a // handoff nor a model deflection (no model ran). 'handoff_triggered' => false, 'deflection' => false, 'moderation' => array( 'action' => $action, 'tier' => (string) ($verdict['tier'] ?? ''), 'band' => (string) ($verdict['band'] ?? ''), 'provenance' => array_values((array) ($verdict['provenance'] ?? array())), 'provider' => (string) ($verdict['provider'] ?? ''), 'moderation_model' => (string) ($verdict['model'] ?? ''), 'routing_classes' => array_values((array) ($verdict['classes'] ?? array())), 'scores' => (array) ($verdict['scores'] ?? array()), 'recorded' => array_values((array) ($verdict['recorded'] ?? array())), ), ); $chatlog_id = (int) $this->response_service->log_conversation_turn($session_id, $user_text, $served, $metadata, $req); if ($chatlog_id > 0) { update_post_meta($chatlog_id, '_answerrails_outcome', 'moderated'); // D7 owner visibility: policy flags are countable/filterable in // admin. Fail-closed is an infrastructure event, not a policy // flag — it is in the transcript but never counts as a flag. if ('fail_closed' !== $action) { $count = (int) get_post_meta($chatlog_id, '_answerrails_moderation_flag_count', true); update_post_meta($chatlog_id, '_answerrails_moderation_flag_count', $count + 1); update_post_meta($chatlog_id, '_answerrails_moderation_last', wp_json_encode(array( 'action' => $action, 'tier' => (string) ($verdict['tier'] ?? ''), 'band' => (string) ($verdict['band'] ?? ''), 'provenance' => array_values((array) ($verdict['provenance'] ?? array())), 'model' => (string) ($verdict['model'] ?? ''), 'time' => current_time('mysql'), ))); } } return $chatlog_id; } /* ================================================================== * #123 fabrication guardrail (Response Integrity II) * ================================================================== */ /** * Path B (decision 18): the model's answer contained internal URL(s) * that failed the L1 existence chain — the WHOLE TURN is confabulation * evidence. Log-before-enforce (the #114 rule), then serve the * integrity deflection as a safe WP_Error riding the #117 machinery * (repo-verified widget behaviour, zero widget changes: the message * shows verbatim, the visitor's input is restored, and the turn is * dropped from conversation history — which structurally prevents the * chatlog-15448 "doubled down on follow-up" failure). * * @param string[] $fabricated_urls Invented internal URLs (non-empty). * @param array $ai_result The provider result (for attribution). * @param array|null $rag_metrics Metrics computed for the turn, if any. * @return \WP_Error Always the response_integrity safe error. */ private function handle_fabricated_turn(\WP_REST_Request $req, string $session_id, string $user_text, string $assistant_text, array $fabricated_urls, array $ai_result, $rag_metrics): \WP_Error { $served = $this->moderation_service()->response_string('integrity'); // L3: the turn is scored as what it is — never grounded/87/high // (chatlog 15448). store_rag_metrics() writes _answerrails_outcome // from this override. if (is_array($rag_metrics)) { $rag_metrics['score'] = 0; $rag_metrics['confidence_level'] = 'low'; $rag_metrics['outcome'] = 'fabricated'; } $discarded = function_exists('mb_substr') ? mb_substr($assistant_text, 0, 1000) : substr($assistant_text, 0, 1000); $metadata = array( 'model' => (string) ($ai_result['model'] ?? ''), 'api_used' => (string) ($ai_result['api_used'] ?? ''), 'fallback_used' => !empty($ai_result['fallback_used']), 'response_time' => $ai_result['response_time'] ?? 0, // #204 D2 (Codex P2 on PR #231): EVERY logged-turn writer // records both truth signals, so new rows are distinguishable // from historical absent-field rows. No escalation on this path. 'handoff_triggered' => false, 'deflection' => !empty($ai_result['deflection_triggered']), 'rag_metrics' => $rag_metrics, // D2: the discarded answer is stored TRUNCATED for owner // diagnostics (what was almost served); full text goes to the // WP_DEBUG log only. 'fabrication' => array( 'urls' => array_values($fabricated_urls), 'provider' => (string) ($ai_result['provider'] ?? ''), 'model' => (string) ($ai_result['model'] ?? ''), 'discarded_answer' => $discarded, ), ); // Log-before-enforce (#114): the turn, its fabrication payload and // the url-violation metas (written inside log_conversation_turn) // are recorded BEFORE the deflection is served. $chatlog_id = (int) $this->response_service->log_conversation_turn($session_id, $user_text, $served, $metadata, $req); if ($chatlog_id > 0) { if (is_array($rag_metrics)) { $this->store_rag_metrics($chatlog_id, $rag_metrics); } else { update_post_meta($chatlog_id, '_answerrails_outcome', 'fabricated'); } update_post_meta($chatlog_id, '_answerrails_fabrication_detected', 1); } $this->response_service->reset_url_violations(); AnswerRails_Utils::debug_log('fabrication: internal URL(s) failed existence — turn deflected (Path B)', array( 'urls' => $fabricated_urls, 'discarded_answer' => $assistant_text, )); // The #119 fail-closed error shape: prepare_public_error() lists // response_integrity as a safe code, so the integrity string reaches // the visitor verbatim and data.transient engages the retry // affordance (a regenerated answer may well ground properly). // #157 (D1): data.deflection marks this as a DELIBERATE deflection — // an answer riding the error transport for its history-drop/retry // mechanics (decision 18) — so the widget renders it as the // assistant speaking, not as a red failure. The judgment of which // codes are deflections lives HERE, beside SAFE_ERROR_CODES, never // in JavaScript. moderation_unavailable and rate_limited are // genuine failures and must NOT carry this flag. return new \WP_Error( 'response_integrity', $served, array('status' => 503, 'transient' => true, 'deflection' => true) ); } /** * #124 S3 (Response Integrity II): serve the integrity deflection * in place of an answered_ungrounded substantive answer. Unlike #123 * Path B this is a NORMAL widget response (assistant bubble), not the * error path: the visitor's question was processed and deliberately * declined — input restoration and history-drop are not wanted, and * the declined exchange staying in history is correct. * * @return array Widget-shaped response carrying the integrity string. */ private function handle_ungrounded_turn(\WP_REST_Request $req, string $session_id, string $user_text, string $assistant_text, array $ai_result, $rag_metrics, float $start_time): array { $served = $this->moderation_service()->response_string('integrity'); // The turn is scored as what it is: a scope deflection, never an // ungrounded answer blessed by the Overview. if (is_array($rag_metrics)) { $rag_metrics['score'] = 0; $rag_metrics['confidence_level'] = 'low'; $rag_metrics['outcome'] = 'ungrounded_deflected'; } $discarded = function_exists('mb_substr') ? mb_substr($assistant_text, 0, 1000) : substr($assistant_text, 0, 1000); $metadata = array( 'model' => (string) ($ai_result['model'] ?? ''), 'api_used' => (string) ($ai_result['api_used'] ?? ''), 'fallback_used' => !empty($ai_result['fallback_used']), 'response_time' => $ai_result['response_time'] ?? 0, // #204 D2 (Codex P2 on PR #231): every logged-turn writer // records both truth signals; no escalation is ever assembled // on a scope-enforcement turn, and `deflection` carries the // model's own D1 signal (the SERVED deflection is already // recorded by the outcome, ungrounded_deflected). 'handoff_triggered' => false, 'deflection' => !empty($ai_result['deflection_triggered']), 'rag_metrics' => $rag_metrics, // #124 S3 audit trail: same scope-enforcement inputs as served // turns record, so deflected and served turns read consistently. 'structured' => !empty($ai_result['structured']), 'conversational' => !empty($ai_result['conversational']), // Log-both (the #119 log-before-enforce convention): what the // model said (truncated) AND what was served, so the owner can // audit the deflection decision. 'scope_enforcement' => array( 'reason' => 'ungrounded', 'provider' => (string) ($ai_result['provider'] ?? ''), 'model' => (string) ($ai_result['model'] ?? ''), 'discarded_answer' => $discarded, ), ); $chatlog_id = (int) $this->response_service->log_conversation_turn($session_id, $user_text, $served, $metadata, $req); if ($chatlog_id > 0) { if (is_array($rag_metrics)) { $this->store_rag_metrics($chatlog_id, $rag_metrics); } else { update_post_meta($chatlog_id, '_answerrails_outcome', 'ungrounded_deflected'); } } $this->response_service->reset_url_violations(); // A normal widget-shaped response (the build_moderation_response // shape WITHOUT the moderation block — this is scope enforcement, // not moderation; vocabulary rule). return array( 'assistant_message' => wp_kses_post(wpautop(make_clickable($served))), 'raw_message' => $served, 'session_id' => $session_id, 'model' => (string) ($ai_result['model'] ?? ''), 'api_used' => (string) ($ai_result['api_used'] ?? ''), 'fallback_used' => !empty($ai_result['fallback_used']), // #204 D3: response_time is milliseconds on EVERY response // shape (the provider-interface contract); this builder used // to emit seconds through the same field. 'response_time' => (microtime(true) - $start_time) * 1000, 'truncated' => false, 'ideas_found' => 0, 'citations' => array(), // #204 D2: no escalation entry on a scope-enforcement turn, so // handoff_triggered is honestly false; `deflection` carries the // model's own D1 signal (this turn's deflection is SERVED by // scope enforcement, which the outcome — ungrounded_deflected — // already records). 'handoff_triggered' => false, 'deflection' => !empty($ai_result['deflection_triggered']), 'finish_reason' => 'stop', 'rag_metrics' => is_array($rag_metrics) ? array( 'score' => $rag_metrics['score'], 'confidence' => $rag_metrics['confidence_level'], 'deflection' => $rag_metrics['deflection_detected'] ?? false, ) : null, ); } /** * Automatic legal hold on tripwire/terminate records (D5/D11) — * preservation under the Art. 17(3)(b) legal-obligation basis. */ private function place_moderation_hold(int $chatlog_id, array $verdict): void { if ($chatlog_id > 0 && class_exists('AnswerRails_Privacy_Tools')) { AnswerRails_Privacy_Tools::place_legal_hold( $chatlog_id, 'moderation_' . (string) ($verdict['tier'] ?? ''), implode(',', (array) ($verdict['classes'] ?? array())) ); } } /** * Build a widget-shaped response for a moderated turn (no AI call). * Same shape as the escalation responses, plus the `moderation` block * the widget reads (`session_ended` disables the input client-side). */ private function build_moderation_response(string $session_id, string $message, float $start_time, array $moderation): array { return array( // make_clickable: same treatment as the escalation builder, so // helpline URLs in the signposting string (default or the // owner's regional override) render as tappable links. 'assistant_message' => wp_kses_post(wpautop(make_clickable($message))), 'raw_message' => $message, 'session_id' => $session_id, // #204 D5: '' means "no model ran this turn" — deliberately not // a sentinel like 'none', which would pollute any consumer // grouping by model string. 'model' => '', 'api_used' => 'moderation', 'fallback_used' => false, // #204 D3: milliseconds everywhere. 'response_time' => (microtime(true) - $start_time) * 1000, 'truncated' => false, 'ideas_found' => 0, 'citations' => array(), // #204 D2: no escalation entry, no model deflection — a // moderated turn is neither a handoff nor a model deflection. 'handoff_triggered' => false, 'deflection' => false, 'finish_reason' => 'stop', 'rag_metrics' => null, 'moderation' => array( 'blocked' => true, 'tier' => (string) ($moderation['tier'] ?? ''), 'session_ended' => !empty($moderation['session_ended']), ), ); } /** * Extract text from complex content structures */ private function extract_text_from_content($content): string { if (is_string($content)) { return $content; } if (!is_array($content)) { return (string) $content; } $text_parts = []; foreach ($content as $part) { if (is_string($part)) { $text_parts[] = $part; } elseif (is_array($part)) { if (isset($part['type']) && $part['type'] === 'text' && isset($part['text'])) { $text_parts[] = $part['text']; } elseif (isset($part['text'])) { $text_parts[] = $part['text']; } } } return implode(' ', $text_parts); } /** * Find the last user message text for logging */ private function find_last_user_text(array $messages): string { // Search backwards for the last user message for ($i = count($messages) - 1; $i >= 0; $i--) { $msg = $messages[$i]; $role = isset($msg['role']) && is_string($msg['role']) ? (function_exists('mb_strtolower') ? mb_strtolower(trim($msg['role']), 'UTF-8') : strtolower(trim($msg['role']))) : ''; if ($role === 'user' && !empty($msg['content'])) { $content = $msg['content']; return is_string($content) ? $content : wp_json_encode($content); } } return ''; } /** * Store RAG metrics for a chat log post * * @param int $chatlog_id Post ID of the chat log * @param array $rag_metrics RAG metrics from analytics */ private function store_rag_metrics($chatlog_id, $rag_metrics) { if (!$chatlog_id || !$rag_metrics) { return; } update_post_meta($chatlog_id, '_answerrails_rag_score', $rag_metrics['score']); update_post_meta($chatlog_id, '_answerrails_confidence_level', $rag_metrics['confidence_level']); update_post_meta($chatlog_id, '_answerrails_deflection_detected', $rag_metrics['deflection_detected']); update_post_meta($chatlog_id, '_answerrails_knowledge_utilized', !empty($rag_metrics['components']['knowledge_used'])); update_post_meta($chatlog_id, '_answerrails_rag_components', $rag_metrics['components']); // Phase A: reliable, vendor-agnostic per-turn signals for the Overview. // Persisted as post meta (in addition to the chatlog post_content // metadata.rag_metrics that get_overview_metrics() reads) so they are // queryable via meta_query — scaffolding for the multi-provider work // (per-provider/model filtering and comparison in a later phase). update_post_meta($chatlog_id, '_answerrails_grounded', !empty($rag_metrics['grounded']) ? 1 : 0); update_post_meta($chatlog_id, '_answerrails_retrieval_score', isset($rag_metrics['retrieval_top_score']) ? (float) $rag_metrics['retrieval_top_score'] : 0); // Score-scale segmentation key: vector scores are 0–1 relevance, // lexical (fallback) scores are MySQL fulltext — never compare the // retrieval_score meta across sources without this. update_post_meta($chatlog_id, '_answerrails_retrieval_source', isset($rag_metrics['retrieval_source']) ? (string) $rag_metrics['retrieval_source'] : ''); update_post_meta($chatlog_id, '_answerrails_citation_count', isset($rag_metrics['citation_count']) ? (int) $rag_metrics['citation_count'] : 0); update_post_meta($chatlog_id, '_answerrails_outcome', isset($rag_metrics['outcome']) ? (string) $rag_metrics['outcome'] : ''); update_post_meta($chatlog_id, '_answerrails_provider', isset($rag_metrics['provider']) ? (string) $rag_metrics['provider'] : ''); update_post_meta($chatlog_id, '_answerrails_chat_model', isset($rag_metrics['model']) ? (string) $rag_metrics['model'] : ''); } }