init();
}
/**
* Initialize hooks
*
* @since 3.3.0
*/
public function init() {
// Enqueue assets
add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
// AJAX handlers
add_action('wp_ajax_answerrails_get_conversation', [$this, 'ajax_get_conversation']);
add_action('wp_ajax_answerrails_export_conversation', [$this, 'ajax_export_conversation']);
// #119 D11: legal-hold place/release. Handled on admin_init — the
// action redirects, and by page-render time (where the list table's
// bulk processor runs) the admin headers are already sent
// ("Cannot modify header information", found live 2026-07-28).
add_action('admin_init', [$this, 'handle_hold_action']);
// #130 D3: resolving a deferred erasure is ALWAYS an explicit action,
// never automatic on release. Same admin_init reasoning as the hold
// handlers — the redirect must fire before any page output.
add_action('admin_init', [$this, 'handle_deferred_erasure_action']);
add_action('admin_init', [$this, 'handle_anonymize_action']);
add_action('admin_notices', [$this, 'render_deferred_erasure_notice']);
// Single-row Delete link (same admin_init reasoning). Pre-existing
// bug surfaced by #119 verification (2026-07-28): the row link
// carries a per-row nonce ('delete-chatlog-{id}') but the bulk
// processor verified EVERYTHING against 'bulk-chatlogs', so the row
// link always died "Security check failed" (bulk delete, whose
// nonce comes from the form, was unaffected). Handled here with the
// right nonce + the #119 held-record guard; the bulk path keeps its
// own guard in the list table.
add_action('admin_init', [$this, 'handle_row_delete_action']);
// Bulk delete/export (debt item 4, cleared in the #90/#93 cycle,
// 2026-07-28): the list table's render-time bulk processor fired
// wp_safe_redirect() after the admin headers were sent ("Cannot
// modify header information"; action executed, redirect lost,
// stale list rendered). Same admin_init pattern as the two
// handlers above; the list table processes no action itself.
add_action('admin_init', [$this, 'handle_bulk_action']);
}
/**
* Process the bulk delete/export actions on admin_init, BEFORE any
* admin-page output (debt item 4; pattern established by #119's
* handle_hold_action). The bulk form submits `chatlog[]` as an
* array — a scalar `chatlog` is the single-row Delete link, owned
* by handle_row_delete_action().
*
* @since 0.0.60
*/
public function handle_bulk_action() {
if (!isset($_GET['page']) || 'answerrails_chatlogs' !== $_GET['page']) {
return;
}
// WP list tables carry two bulk dropdowns (top/bottom): action,
// action2. '-1' is the "Bulk actions" placeholder.
$action = '';
foreach (['action', 'action2'] as $key) {
if (isset($_REQUEST[$key]) && '-1' !== $_REQUEST[$key]) {
$action = sanitize_text_field(wp_unslash($_REQUEST[$key]));
break;
}
}
if (!in_array($action, ['delete', 'export'], true)) {
return;
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- routing probe read before the id-scoped nonce check (the id is part of the nonce action name); shape-validated via (int)/is_array and unused until the nonce verifies
$raw = $_REQUEST['chatlog'] ?? null;
if (!is_array($raw)) {
return; // Row-link path (scalar) or nothing selected.
}
if (!isset($_REQUEST['_wpnonce'])
|| !wp_verify_nonce(sanitize_text_field(wp_unslash($_REQUEST['_wpnonce'])), 'bulk-chatlogs')
|| !current_user_can('manage_options')
) {
wp_die(esc_html__('Security check failed', 'answerrails'));
}
$ids = array_filter(array_map('intval', $raw));
if (empty($ids)) {
return;
}
if ('delete' === $action) {
// #119: records under legal hold survive deletion until
// the hold is released — same rule as the eraser and the
// retention purge.
$deleted = 0;
$held = 0;
foreach ($ids as $id) {
if (class_exists('AnswerRails_Privacy_Tools') && AnswerRails_Privacy_Tools::is_on_legal_hold($id)) {
$held++;
continue;
}
wp_delete_post($id, true);
$deleted++;
}
wp_safe_redirect(admin_url(
'admin.php?page=answerrails_chatlogs&deleted=' . $deleted
. ($held > 0 ? '&held_skipped=' . $held : '')
));
exit;
}
// export
$this->export_conversations_csv($ids);
}
/**
* Stream the selected conversations as a CSV download (moved from
* the list table with the bulk processor — debt item 4).
*
* #149: rows are assembled BEFORE any header is sent (headers-last
* rule). The 2026-08-06 prod fatal — a refactor-stranded call to the
* nonexistent AnswerRails_RAG_Analytics::get_conversation_score() —
* fired after Content-Disposition was already out, so the browser
* received a truncated attachment (ERR_INVALID_RESPONSE) instead of
* a readable WP error page. Assembling first bounds that failure
* shape: anything that throws now throws before headers.
*
* @param array $ids Post IDs to export
*/
private function export_conversations_csv($ids) {
$rows = $this->build_export_rows($ids);
header('Content-Type: text/csv');
header('Content-Disposition: attachment; filename="chatlogs-' . gmdate('Y-m-d') . '.csv"');
$output = fopen('php://output', 'w');
foreach ($rows as $row) {
fputcsv($output, $row);
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- php://output CSV stream; WP_Filesystem does not apply to output streams
fclose($output);
exit;
}
/**
* Assemble the bulk-export CSV rows (header row first) with no
* output side effects — the testable half of the exporter (#149 pin:
* ChatlogBulkExportTest).
*
* Quality reads the `_answerrails_rag_score` post meta — the same
* authority the detail modal reads (get_conversation_data()). The
* previous AnswerRails_RAG_Analytics::get_conversation_score() call
* referenced a method that does not exist (stranded when scoring
* moved to meta with the bulk processor) and was the #149 prod
* fatal; a class_exists() guard can never catch a missing METHOD on
* an existing class.
*
* @param array $ids Post IDs to export.
* @return array[] CSV rows, header row included.
*/
public function build_export_rows($ids) {
$rows = array(
array('ID', 'Date', 'Session', 'Turns', 'Quality', 'Conversation'),
);
foreach ($ids as $id) {
$post = get_post($id);
if (!$post || 'answerrails_chatlog' !== $post->post_type) {
continue;
}
$rows[] = array(
$id,
$post->post_date,
get_post_meta($id, '_answerrails_session_id', true),
get_post_meta($id, '_answerrails_turn_count', true),
get_post_meta($id, '_answerrails_rag_score', true),
$post->post_content,
);
}
return $rows;
}
/**
* Process the legal-hold place/release row action (#119 D11).
*
* Runs on admin_init so wp_safe_redirect() fires BEFORE any admin-page
* output. Per-row nonce ('ar-hold-{id}'), manage_options.
*
* @since 0.0.59
*/
/**
* Resolve a deferred erasure (#130 D3) — complete it, or dismiss it.
*
* Both outcomes are audited. A dismissal is a deliberate decision about a
* subject's request, so it is recorded as loudly as a completion; a trail
* that only captures the tidy outcome is not an audit trail.
*
* Same admin_init + per-row nonce + manage_options shape as the hold
* actions above.
*/
/**
* Anonymize a single record from its row (#130).
*
* Reachable from the row as well as the Privacy panel: a controller
* looking at ONE record must be able to take the action the panel
* itself calls recommended, not only export or destroy it.
*
* SCOPE, stated here as well as in the tooltip because it is the honest
* limit of a row-level action: from a row we know the post, not the
* subject, so no email or phone is available to redact from the transcript
* body. This strips the identified metas and flips the record to
* anonymized shape. Subject-level redaction remains the panel's job.
*/
public function handle_anonymize_action() {
if (!isset($_GET['page']) || 'answerrails_chatlogs' !== $_GET['page']) {
return;
}
$action = isset($_REQUEST['action']) ? sanitize_text_field(wp_unslash($_REQUEST['action'])) : '';
if ('ar_anonymize' !== $action) {
return;
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- routing probe read before the id-scoped nonce check (the id is part of the nonce action name); shape-validated via (int)/is_array and unused until the nonce verifies
$raw = $_REQUEST['chatlog'] ?? 0;
$id = (int) (is_array($raw) ? reset($raw) : $raw);
if ($id <= 0
|| !isset($_REQUEST['_wpnonce'])
|| !wp_verify_nonce(sanitize_text_field(wp_unslash($_REQUEST['_wpnonce'])), 'ar-anonymize-' . $id)
|| !current_user_can('manage_options')
|| !class_exists('AnswerRails_Privacy_Tools')
) {
wp_die(esc_html__('Security check failed', 'answerrails'));
}
$result = AnswerRails_Privacy_Tools::anonymize_chatlog($id, '', '');
// Audited like any other DSAR action, including when the hold refuses
// it — a blocked outcome is exactly what the trail exists to show.
AnswerRails_Privacy_Tools::record_dsar_event(
'dsar_anonymize',
array('chatlogs' => array($id)),
array('chatlogs' => !empty($result['removed']) ? 1 : 0),
!empty($result['retained']) ? 1 : 0,
AnswerRails_Privacy_Tools::admin_actor_context('row_action')
);
wp_safe_redirect(admin_url('admin.php?page=answerrails_chatlogs&anonymized=' . (!empty($result['retained']) ? 'retained' : 'done')));
exit;
}
public function handle_deferred_erasure_action() {
if (!isset($_GET['page']) || 'answerrails_chatlogs' !== $_GET['page']) {
return;
}
$action = isset($_REQUEST['action']) ? sanitize_text_field(wp_unslash($_REQUEST['action'])) : '';
if (!in_array($action, ['ar_defer_complete', 'ar_defer_dismiss'], true)) {
return;
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- routing probe read before the id-scoped nonce check (the id is part of the nonce action name); shape-validated via (int)/is_array and unused until the nonce verifies
$raw = $_REQUEST['chatlog'] ?? 0;
$id = (int) (is_array($raw) ? reset($raw) : $raw);
if ($id <= 0
|| !isset($_REQUEST['_wpnonce'])
|| !wp_verify_nonce(sanitize_text_field(wp_unslash($_REQUEST['_wpnonce'])), 'ar-defer-' . $id)
|| !current_user_can('manage_options')
|| !class_exists('AnswerRails_Privacy_Tools')
) {
wp_die(esc_html__('Security check failed', 'answerrails'));
}
// Completion runs the anonymize path. There is no subject email to
// redact with here — the flag deliberately never stored one — so the
// record is anonymized on its metas and transcript scheme, which is
// what the subject asked for and all we can honestly do.
AnswerRails_Privacy_Tools::resolve_deferred_erasure(
$id,
'ar_defer_complete' === $action,
'',
'',
AnswerRails_Privacy_Tools::admin_actor_context()
);
wp_safe_redirect(admin_url('admin.php?page=answerrails_chatlogs&deferred=' . ('ar_defer_complete' === $action ? 'completed' : 'dismissed')));
exit;
}
/**
* Surface records released from hold that still carry a pending erasure
* request (#130) — the gap that made this issue worth filing:
* releasing a hold silently returned the record to normal behaviour with
* nothing tracking that an erasure was still owed.
*/
public function render_deferred_erasure_notice() {
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
if (!$screen || false === strpos((string) $screen->id, 'answerrails_chatlogs')) {
return;
}
if (!current_user_can('manage_options') || !class_exists('AnswerRails_Privacy_Tools')) {
return;
}
$pending = AnswerRails_Privacy_Tools::deferred_erasure_pending();
if (empty($pending)) {
return;
}
echo '
';
echo esc_html(sprintf(
/* translators: %d: number of records */
_n(
'%d record released from hold has a pending erasure request:',
'%d records released from hold have pending erasure requests:',
count($pending),
'answerrails'
),
count($pending)
));
echo '
';
echo esc_html__('Completing honours the request by anonymizing the record: session id, IP address, user agent and referrer are removed. Contact details typed into the conversation itself are not redacted by this action — for that, search for the person in the Privacy panel.', 'answerrails');
echo '
';
// The affected records are NAMED, with their actions attached. The
// count alone was a puzzle: an admin returning to a list of dozens
// across several pages had no way to find the record they had just
// released, and would have had to remember it.
echo '
';
foreach ($pending as $post_id) {
$post = get_post($post_id);
$title = $post ? wp_trim_words((string) $post->post_title, 8, '…') : '';
printf(
'- %s — %s | %s
',
esc_html('' !== $title ? $title : sprintf(
/* translators: %d: chatlog post id */
__('Conversation #%d', 'answerrails'),
$post_id
)),
esc_url(wp_nonce_url(
admin_url('admin.php?page=answerrails_chatlogs&action=ar_defer_complete&chatlog=' . $post_id),
'ar-defer-' . $post_id
)),
esc_js(__('Complete the pending erasure by anonymizing this record? This cannot be undone.', 'answerrails')),
esc_html__('Complete (anonymize)', 'answerrails'),
esc_url(wp_nonce_url(
admin_url('admin.php?page=answerrails_chatlogs&action=ar_defer_dismiss&chatlog=' . $post_id),
'ar-defer-' . $post_id
)),
esc_html__('Dismiss request', 'answerrails')
);
}
echo '
';
}
public function handle_hold_action() {
if (!isset($_GET['page']) || 'answerrails_chatlogs' !== $_GET['page']) {
return;
}
$action = isset($_REQUEST['action']) ? sanitize_text_field(wp_unslash($_REQUEST['action'])) : '';
if (!in_array($action, ['ar_hold', 'ar_release'], true)) {
return;
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- routing probe read before the id-scoped nonce check (the id is part of the nonce action name); shape-validated via (int)/is_array and unused until the nonce verifies
$raw = $_REQUEST['chatlog'] ?? 0;
$id = (int) (is_array($raw) ? reset($raw) : $raw);
if ($id <= 0
|| !isset($_REQUEST['_wpnonce'])
|| !wp_verify_nonce(sanitize_text_field(wp_unslash($_REQUEST['_wpnonce'])), 'ar-hold-' . $id)
|| !current_user_can('manage_options')
|| !class_exists('AnswerRails_Privacy_Tools')
) {
wp_die(esc_html__('Security check failed', 'answerrails'));
}
// #130: the manual paths attribute the action. Context is passed
// EXPLICITLY (the privacy tools never reach for globals), which is what
// distinguishes these from the automatic moderation placements — those
// omit the context and are recorded as source 'auto', actor 0.
$ctx = AnswerRails_Privacy_Tools::admin_actor_context();
if ('ar_hold' === $action) {
AnswerRails_Privacy_Tools::place_legal_hold($id, 'manual', '', $ctx);
} else {
AnswerRails_Privacy_Tools::release_legal_hold($id, $ctx);
}
wp_safe_redirect(admin_url('admin.php?page=answerrails_chatlogs&hold=' . ('ar_hold' === $action ? 'placed' : 'released')));
exit;
}
/**
* Process the single-row Delete link (per-row nonce; #119 held-guard).
*
* Scalar `chatlog` = the row link; the bulk form submits `chatlog[]`
* as an array and stays with the list table's bulk processor.
*
* @since 0.0.59
*/
public function handle_row_delete_action() {
if (!isset($_GET['page']) || 'answerrails_chatlogs' !== $_GET['page']) {
return;
}
$action = isset($_REQUEST['action']) ? sanitize_text_field(wp_unslash($_REQUEST['action'])) : '';
if ('delete' !== $action) {
return;
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- routing probe read before the id-scoped nonce check (the id is part of the nonce action name); shape-validated via (int)/is_array and unused until the nonce verifies
$raw = $_REQUEST['chatlog'] ?? 0;
if (is_array($raw)) {
return; // Bulk form path — the list table owns it.
}
$id = (int) $raw;
if ($id <= 0) {
return;
}
if (!isset($_REQUEST['_wpnonce'])
|| !wp_verify_nonce(sanitize_text_field(wp_unslash($_REQUEST['_wpnonce'])), 'delete-chatlog-' . $id)
|| !current_user_can('manage_options')
) {
wp_die(esc_html__('Security check failed', 'answerrails'));
}
// #119: a record under legal hold survives deletion until the
// hold is released — same rule as the eraser and retention purge.
if (class_exists('AnswerRails_Privacy_Tools') && AnswerRails_Privacy_Tools::is_on_legal_hold($id)) {
wp_safe_redirect(admin_url('admin.php?page=answerrails_chatlogs&held_skipped=1'));
exit;
}
wp_delete_post($id, true);
wp_safe_redirect(admin_url('admin.php?page=answerrails_chatlogs&deleted=1'));
exit;
}
/**
* Enqueue admin assets
*
* @since 3.3.0
* @param string $hook Current admin page hook
*/
public function enqueue_assets($hook) {
// Only load on our chat logs page
if ($hook !== 'answerrails_page_answerrails_chatlogs') {
return;
}
// CSS
wp_enqueue_style(
'answerrails-chatlog-admin',
plugins_url('assets/css/chatlog-admin.css', dirname(__FILE__)),
[],
ANSWERRAILS_VERSION
);
// JavaScript
wp_enqueue_script(
'answerrails-chatlog-admin',
plugins_url('assets/js/chatlog-admin.js', dirname(__FILE__)),
['jquery'],
ANSWERRAILS_VERSION,
true
);
// Privacy subject-search panel (#110)
wp_enqueue_script(
'answerrails-privacy-panel',
plugins_url('assets/js/privacy-panel.js', dirname(__FILE__)),
['jquery'],
ANSWERRAILS_VERSION,
true
);
wp_localize_script('answerrails-privacy-panel', 'answerRailsPrivacy', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('answerrails_privacy_nonce'),
'strings' => [
'searchError' => __('Search failed. Please try again.', 'answerrails'),
'noResults' => __('Nothing found for this search.', 'answerrails'),
'chatSessions' => __('Chat sessions', 'answerrails'),
'ideasLeads' => __('Ideas & leads', 'answerrails'),
'consentReceipts' => __('Consent receipts', 'answerrails'),
// #261 D6: the reader-facing words for a ledger event. The
// panel renders these; the AJAX payload carries the raw
// ledger value so the vocabulary stays on this side.
'consentGranted' => __('Granted', 'answerrails'),
'consentWithdrawn' => __('Withdrawn', 'answerrails'),
'turns' => __('turns', 'answerrails'),
'identified' => __('identified', 'answerrails'),
'anonymized' => __('anonymized', 'answerrails'),
'confirmAnonymize' => __('Anonymize this person\'s records in place? Identifying details (session id, IP, browser, referrer, contact) are removed and their email/phone is redacted from transcripts. Conversations remain, anonymized. This cannot be undone.', 'answerrails'),
'confirmDelete' => __('DELETE EVERYTHING found for this search? This permanently removes the conversations (transcripts are lost) AND the consent receipts that evidence the visitor\'s consent. If you may need consent evidence, use Anonymize instead. This cannot be undone.', 'answerrails'),
/* translators: 1: chats count, 2: ideas count, 3: consent receipts count, 4: retained items count */
'anonymizeDone' => __('Anonymized: %1$s chats, %2$s ideas, %3$s consent receipts updated; %4$s items retained (see messages).', 'answerrails'),
/* translators: 1: chats count, 2: ideas count, 3: consent receipts count */
'deleteDone' => __('Deleted: %1$s chats, %2$s ideas, %3$s consent receipts.', 'answerrails'),
'actionError' => __('Action failed. Please try again.', 'answerrails'),
]
]);
// Localize script
wp_localize_script('answerrails-chatlog-admin', 'answerRailsChatlog', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('answerrails_chatlog_nonce'),
'strings' => [
'confirmDelete' => __('Are you sure you want to delete the selected conversations?', 'answerrails'),
'viewError' => __('Error loading conversation. Please try again.', 'answerrails'),
'exportError' => __('Error exporting conversation. Please try again.', 'answerrails'),
'loading' => __('Loading...', 'answerrails'),
'support' => __('Support', 'answerrails'),
'system' => __('System', 'answerrails'),
'user' => __('User', 'answerrails'),
'assistant' => __('Assistant', 'answerrails'),
'ideasCaptured' => __('Ideas captured:', 'answerrails'),
'unansweredCaptured' => __('Unanswered question logged', 'answerrails'),
'holdHistory' => __('Hold history', 'answerrails'),
'erasureDeferred' => __('An erasure request for this record was blocked by a legal hold and is still pending.', 'answerrails'),
// #128: the deflected-payload disclosure (D4) and the
// failed-turn record (D5). Inert markup only.
'deflectedAnswer' => __('What the model said before this turn was deflected', 'answerrails'),
'deflectedReason' => __('Reason:', 'answerrails'),
'deflectedUrls' => __('Invented links:', 'answerrails'),
// The renderer has always fallen back to a hard-coded
// 'Sources:' because this key was never localized. Added
// alongside its #135 twin so both are translatable.
'sources' => __('Sources:', 'answerrails'),
'sourcesWithheld' => __('Sources (recorded, not shown to the visitor):', 'answerrails'),
// #152: on a grounded turn the visitor sees the USED subset;
// the rest of the recorded set renders dimmed under this
// qualifier — the exact #135 vocabulary, per the design's DD5.
'sourcesNotServed' => __('recorded, not shown to the visitor:', 'answerrails'),
'failedTurn' => __('Failed turn', 'answerrails'),
'failedTurnNote' => __('This send failed and the visitor was shown an error. It is recorded so the exchange is not missing from the conversation, and it is not counted as a turn.', 'answerrails'),
'visitorShown' => __('Visitor was shown:', 'answerrails'),
]
]);
}
/**
* Render list page
*
* @since 3.3.0
*/
public function render_list_page() {
// Load list table class if needed
if (!class_exists('WP_List_Table')) {
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}
// Create list table instance
$this->list_table = new AnswerRails_Chatlog_List_Table();
$this->list_table->prepare_items();
?>
',
'placed' === sanitize_key(wp_unslash($_GET['hold'])) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display parameter; no state change
? esc_html__('Legal hold placed. The record is excluded from erasure and retention until released.', 'answerrails')
: esc_html__('Legal hold released. The record follows normal privacy behaviour again.', 'answerrails')
);
}
if (isset($_GET['held_skipped'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display parameter; no state change
$held_count = intval($_GET['held_skipped']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display parameter; no state change
printf(
'',
sprintf(
/* translators: %d: number of held conversations */
esc_html(_n(
'%d conversation is under a legal hold and was not deleted. Release the hold first if deletion is required.',
'%d conversations are under a legal hold and were not deleted. Release the holds first if deletion is required.',
$held_count,
'answerrails'
)),
esc_html($held_count)
)
);
}
// Show success messages
if (isset($_GET['deleted'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display parameter; no state change
$count = intval($_GET['deleted']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display parameter; no state change
printf(
'',
sprintf(
/* translators: %d: number of deleted conversations */
esc_html(_n(
'%d conversation deleted.',
'%d conversations deleted.',
$count,
'answerrails'
)),
esc_html($count)
)
);
}
?>
__('Insufficient permissions', 'answerrails')
]);
}
// Get post ID
$post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
if (!$post_id) {
wp_send_json_error([
'message' => __('Invalid post ID', 'answerrails')
]);
}
// Get conversation data
$data = $this->get_conversation_data($post_id);
if (!$data) {
wp_send_json_error([
'message' => __('Conversation not found', 'answerrails')
]);
}
wp_send_json_success($data);
}
/**
* AJAX handler to export conversation
*
* @since 3.3.0
*/
public function ajax_export_conversation() {
// Verify nonce
check_ajax_referer('answerrails_chatlog_nonce', 'nonce');
// Check capability
if (!current_user_can('manage_options')) {
wp_send_json_error([
'message' => __('Insufficient permissions', 'answerrails')
]);
}
// Get post ID
$post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
if (!$post_id) {
wp_send_json_error([
'message' => __('Invalid post ID', 'answerrails')
]);
}
// Get conversation data
$post = get_post($post_id);
if (!$post || $post->post_type !== 'answerrails_chatlog') {
wp_send_json_error([
'message' => __('Conversation not found', 'answerrails')
]);
}
// Prepare export data
$session = get_post_meta($post_id, '_answerrails_session_id', true);
$turns = get_post_meta($post_id, '_answerrails_turn_count', true);
$ip = get_post_meta($post_id, '_answerrails_ip_address', true);
$data = [
'id' => $post_id,
'date' => $post->post_date,
'session_id' => $session,
'ip_address' => $ip,
'turns' => $turns,
'conversation' => $post->post_content
];
wp_send_json_success($data);
}
/**
* Get formatted conversation data
*
* @since 3.3.0
* @param int $post_id Post ID
* @return array|false Conversation data or false on failure
*/
private function get_conversation_data($post_id) {
$post = get_post($post_id);
if (!$post || $post->post_type !== 'answerrails_chatlog') {
return false;
}
// Get metadata. Effective mode (#109) for the mode CLAIM; the
// session/IP display below is data-presence-driven — stored PII on
// historical identified logs stays visible to the data controller
// (forward-only enforcement decision, 2026-07-26).
$logging_mode = AnswerRails_Logger::logging_mode();
$session_id = get_post_meta($post_id, '_answerrails_session_id', true);
$ip_address = get_post_meta($post_id, '_answerrails_ip_address', true);
$turns = get_post_meta($post_id, '_answerrails_turn_count', true);
$quality = get_post_meta($post_id, '_answerrails_rag_score', true);
// Parse conversation - this handles BOTH logging systems
$conversation = json_decode($post->post_content, true);
if (!is_array($conversation)) {
$conversation = [];
}
// Format for display
$formatted_conversation = $this->format_conversation_for_display($conversation);
// #130: the record's own hold history, so an admin can see why
// it was held and by whom without scanning the global trail. Formatted
// server-side — the labels are translatable and the JS stays a
// renderer. Hold REASONS are machine strings (moderation_terminate,
// manual): they are origin, never a moderation verdict, so #124's
// provenance_label() authority is not in play here.
$hold_history = array();
if (class_exists('AnswerRails_Privacy_Tools')) {
foreach (AnswerRails_Privacy_Tools::get_hold_history($post_id) as $event) {
$actor_id = (int) ($event['actor_id'] ?? 0);
$actor = $actor_id > 0
? (string) ($event['actor_login'] ?: '#' . $actor_id)
: __('automatic', 'answerrails');
$hold_history[] = array(
'label' => AnswerRails_Privacy_Tools::audit_event_label($event['event'] ?? ''),
'date' => (string) ($event['date'] ?? ''),
'actor' => $actor,
'reason' => (string) ($event['reason'] ?? ''),
);
}
}
return [
'id' => $post_id,
'date' => mysql2date('F j, Y \a\t g:i A', $post->post_date),
'session_id' => $session_id ?: null,
'ip_address' => $ip_address ?: null,
'turns' => $turns ?: 0,
'quality' => $quality,
'logging_mode' => $logging_mode,
'hold_history' => $hold_history,
'erasure_deferred' => class_exists('AnswerRails_Privacy_Tools')
&& AnswerRails_Privacy_Tools::has_deferred_erasure($post_id),
'conversation' => $formatted_conversation
];
}
/**
* #128 D2: resolve which PIPELINE BRANCH produced a turn, in a fixed
* order, from the turn's own metadata — never inferred.
*
* Why an ordered resolution and not simply `rag_metrics.outcome`:
* moderated turns carry NO `rag_metrics` at all (the moderation writer
* builds its own metadata block), so the outcome field is absent on the
* one path with a mature admin surface. The blocks that ARE present are
* therefore consulted first, in the order the pipeline can produce them.
*
* **Scope gotcha this exists to avoid (#128):** `_answerrails_outcome`
* and every meta written by `store_rag_metrics()` are CONVERSATION-scoped
* and LAST-TURN-WINS. Per-turn truth lives only in the transcript JSON.
* Never resolve a turn's path from post meta.
*
* Returns '' when the path is not recorded — an absent label is honest,
* a guessed one is not (D2).
*
* @since 3.20.0
* @param array $meta A turn's `metadata` block from the transcript.
* @return string Taxonomy token, or '' when nothing is recorded.
*/
public static function turn_path_label(array $meta): string {
// 1) Moderation. Identifiable ONLY by the presence of the block.
if (!empty($meta['moderation']) && is_array($meta['moderation'])) {
return 'moderated';
}
// 2) #123 Path B — a fabricated internal URL deflected the turn.
if (!empty($meta['fabrication']) && is_array($meta['fabrication'])) {
return 'fabricated';
}
// 3) #124 S3 scope enforcement. Explicitly NOT moderation (the
// vocabulary rule at rest-chat.php handle_ungrounded_turn()) —
// which is what lets a path vocabulary coexist with #124's
// judgment authority.
if (isset($meta['scope_enforcement']['reason'])
&& '' !== (string) $meta['scope_enforcement']['reason']) {
return 'ungrounded_deflected';
}
// 4) The scored outcome, when the turn was scored at all.
if (isset($meta['rag_metrics']['outcome'])
&& '' !== (string) $meta['rag_metrics']['outcome']) {
return (string) $meta['rag_metrics']['outcome'];
}
// 5) Not recorded. Render nothing.
return '';
}
/**
* #128 D3: the human label for a path token. This states which BRANCH
* fired — never what was decided. The judgment stays the exclusive
* property of `provenance_label()` (#124 S1) and is rendered as a
* separate element.
*
* An unrecognised token renders VERBATIM rather than being dropped or
* mapped to a neighbour: a new outcome added upstream must show up as
* itself, not disappear.
*
* @since 3.20.0
* @param string $token Path token from turn_path_label().
* @return string Display label, or '' for an empty token.
*/
public static function path_label_text(string $token): string {
if ('' === $token) {
return '';
}
$labels = array(
'grounded' => __('path: grounded', 'answerrails'),
'answered_ungrounded' => __('path: ungrounded answer', 'answerrails'),
'handoff' => __('path: handoff', 'answerrails'),
'moderated' => __('path: moderation', 'answerrails'),
'fabricated' => __('path: fabrication deflection', 'answerrails'),
'ungrounded_deflected' => __('path: scope deflection', 'answerrails'),
);
if (isset($labels[$token])) {
return $labels[$token];
}
return sprintf(
/* translators: %s: an outcome token recorded by the pipeline that this version does not have a label for */
__('path: %s', 'answerrails'),
$token
);
}
/**
* #128 D3: the one-line explanation of a branch — the thing that made
* this feature necessary, since an S3 scope deflection and a #123
* fabrication deflection serve the SAME string by design (#124 D6), and
* without this line they can be told apart only from a CSV export.
*
* @since 3.20.0
* @param string $token Path token from turn_path_label().
* @return string Explanation, or '' when there is nothing useful to add.
*/
public static function path_label_description(string $token): string {
$descriptions = array(
'grounded' => __('The answer was grounded in retrieved knowledge.', 'answerrails'),
'answered_ungrounded' => __('The model answered without grounding in retrieved knowledge.', 'answerrails'),
// ⚠ Vocabulary rule: "handed off" claims routing that only
// happens when escalations run. The chip
// (path: handoff) stays the technical PATH token — provenance —
// while this human explanation tells the truth for both cases.
'handoff' => __('No knowledge covered the question; the assistant declined to guess — deflected, or escalated when escalations are enabled.', 'answerrails'),
'moderated' => __('Visitor input was blocked at the moderation boundary before any model call.', 'answerrails'),
'fabricated' => __('The model invented an internal link that does not exist, so the whole turn was deflected.', 'answerrails'),
'ungrounded_deflected' => __('The model produced a substantive answer that was not grounded, so the deflection was served instead.', 'answerrails'),
);
return $descriptions[$token] ?? '';
}
/**
* #128 D4: the deflected payload for the modal's inert `` —
* what the model actually said before the turn was deflected, plus the
* attribution. Read-only markup: no AJAX, no admin action, so the Admin
* Action Timing Rule is not engaged.
*
* The discarded answer is already truncated to 1000 chars at write time
* by both writers; nothing is re-truncated or re-assembled here.
*
* @since 3.20.0
* @param array $meta A turn's `metadata` block from the transcript.
* @return array Payload for the renderer, empty when there is none.
*/
private static function turn_deflection_payload(array $meta): array {
$source = array();
$reason = '';
if (!empty($meta['fabrication']) && is_array($meta['fabrication'])) {
$source = $meta['fabrication'];
$reason = __('invented internal link', 'answerrails');
} elseif (!empty($meta['scope_enforcement']) && is_array($meta['scope_enforcement'])) {
$source = $meta['scope_enforcement'];
$reason = (string) ($source['reason'] ?? '');
}
if (array() === $source) {
return array();
}
$discarded = isset($source['discarded_answer']) ? (string) $source['discarded_answer'] : '';
$urls = array();
if (isset($source['urls']) && is_array($source['urls'])) {
$urls = array_values(array_filter(array_map('strval', $source['urls'])));
}
if ('' === $discarded && array() === $urls) {
return array();
}
return array(
'reason' => $reason,
'provider' => isset($source['provider']) ? (string) $source['provider'] : '',
'model' => isset($source['model']) ? (string) $source['model'] : '',
'discarded_answer' => $discarded,
'urls' => $urls,
);
}
/**
* Build the modal's per-turn display records from the stored transcript.
*
* PUBLIC for testability (#128, the #131 D1 precedent): this is the
* surface the whole issue is about, and it had no test at all. Nothing
* outside the class calls it; it is not part of a supported API.
*
* @param array $conversation Decoded transcript entries.
* @return array Display records.
*/
/**
* The vendor's own error type/code for a failed turn's meta line
* (#328 D4, design §5.1): "rate_limited/1300", or whichever is present.
*/
private static function vendor_meta(array $vendor): string {
return implode('/', array_filter(array(
(string) ($vendor['type'] ?? ''),
(string) ($vendor['code'] ?? ''),
), 'strlen'));
}
/**
* The request limit the vendor stated, when it stated one (#328 D4):
* Mistral's header is per minute ("limit 0/min"); the OpenAI and
* Anthropic request-limit headers are shown without a unit the record
* does not carry.
*/
private static function vendor_limit_meta(array $headers): string {
if (isset($headers['x-ratelimit-limit-req-minute'])) {
/* translators: %s: requests per minute the AI provider allows */
return sprintf(__('limit %s/min', 'answerrails'), (string) $headers['x-ratelimit-limit-req-minute']);
}
foreach (array('x-ratelimit-limit-requests', 'anthropic-ratelimit-requests-limit') as $name) {
if (isset($headers[$name])) {
/* translators: %s: the request limit the AI provider stated */
return sprintf(__('limit %s', 'answerrails'), (string) $headers[$name]);
}
}
return '';
}
public function format_conversation_for_display($conversation) {
if (!is_array($conversation)) {
return [];
}
$formatted = [];
foreach ($conversation as $item) {
// #128 D5: a FAILED send. Checked BEFORE the normal-turn branch
// below, which would otherwise match on the user/assistant keys
// and render a failure as an ordinary exchange — the opposite of
// the point. The visitor's message renders as their message; the
// error copy renders as a system entry, labelled and explained,
// so an admin reading the transcript can tell an answer from an
// apology.
if (is_array($item)
&& AnswerRails_Response_Service::FAILED_TURN_TYPE === ($item['metadata']['type'] ?? '')) {
$timestamp = isset($item['timestamp']) ? $item['timestamp'] : '';
$failed_meta = is_array($item['metadata']) ? $item['metadata'] : [];
if (isset($item['user']) && '' !== (string) $item['user']) {
$formatted[] = [
'role' => 'user',
'content' => (string) $item['user'],
'formatted_time' => $timestamp ? mysql2date('g:i A', $timestamp) : '',
];
}
$formatted[] = [
'role' => 'system',
'failed_turn' => true,
'content' => isset($item['assistant']) ? (string) $item['assistant'] : '',
'formatted_time' => $timestamp ? mysql2date('g:i A', $timestamp) : '',
'meta_line' => implode(' · ', array_filter([
(string) ($failed_meta['failure_class'] ?? ''),
(string) ($failed_meta['support_code'] ?? ''),
(string) ($failed_meta['provider'] ?? ''),
(string) ($failed_meta['model'] ?? ''),
self::vendor_meta(is_array($failed_meta['vendor'] ?? null) ? $failed_meta['vendor'] : []),
self::vendor_limit_meta(is_array($failed_meta['vendor']['headers'] ?? null) ? $failed_meta['vendor']['headers'] : []),
])),
];
continue;
}
// Handle NEW REST format (has 'user' and 'assistant' keys)
if (isset($item['user']) && isset($item['assistant'])) {
$timestamp = isset($item['timestamp']) ? $item['timestamp'] : '';
// Add user turn
$formatted[] = [
'role' => 'user',
'content' => $item['user'],
'formatted_time' => $timestamp ? mysql2date('g:i A', $timestamp) : ''
];
// Per-turn provider / model / API line (#69). Captured in turn
// metadata since 0.0.26; provider since Phase 2a. Old turns
// without metadata render nothing (graceful fallback).
$meta = (isset($item['metadata']) && is_array($item['metadata'])) ? $item['metadata'] : [];
$provider = isset($meta['rag_metrics']['provider']) ? (string) $meta['rag_metrics']['provider'] : '';
// Retrieval-source label (v0.0.51): which engine grounded the
// turn. 'none' is shown too — it is the one-glance explanation
// for a gated handoff (no KB coverage). 'lexical' covers BOTH
// local-primary turns (#92) and vector-outage fallback turns —
// same engine, same score scale — so the label is engine-
// neutral ("local keyword"), not "fallback".
$source_labels = [
'vector' => __('retrieval: vector', 'answerrails'),
'lexical' => __('retrieval: local keyword', 'answerrails'),
'none' => __('retrieval: none', 'answerrails'),
];
$source = isset($meta['rag_metrics']['retrieval_source']) ? (string) $meta['rag_metrics']['retrieval_source'] : '';
// #119 D7 / #124 S1: moderation flag surfaced per turn —
// action plus the PROVENANCE form (the judgment attributed
// to the classifier that made it), never a bare internal
// class asserted as fact. Legacy pre-provenance records get
// the neutral D3 fallback via provenance_label().
$moderation_note = '';
if (isset($meta['moderation']) && is_array($meta['moderation'])) {
$mod = $meta['moderation'];
$moderation_note = trim(sprintf(
/* translators: 1: moderation action, 2: provenance record */
__('moderation: %1$s — %2$s', 'answerrails'),
(string) ($mod['action'] ?? ''),
AnswerRails_Moderation_Service::provenance_label(
(array) ($mod['provenance'] ?? []),
(string) ($mod['moderation_model'] ?? ''),
(string) ($mod['tier'] ?? '')
)
));
if ('mid' === (string) ($mod['band'] ?? '')) {
$moderation_note .= ' ' . __('[mid band — no strike]', 'answerrails');
}
}
$meta_line = implode(' · ', array_unique(array_filter([
$provider,
isset($meta['model']) ? (string) $meta['model'] : '',
isset($meta['api_used']) ? (string) $meta['api_used'] : '',
$source_labels[$source] ?? '',
$moderation_note,
])));
// Citations for this turn (#69 follow-up): slim label+url records
// logged with each turn since 0.0.26 — the widget's "Sources"
// block, now surfaced in the admin view too.
$citations = [];
if (isset($meta['citations']) && is_array($meta['citations'])) {
foreach ($meta['citations'] as $citation) {
if (!is_array($citation)) {
continue;
}
$label = isset($citation['label']) ? trim((string) $citation['label']) : '';
$url = isset($citation['url']) ? trim((string) $citation['url']) : '';
if ($label === '' && $url === '') {
continue;
}
$citations[] = [
'label' => $label !== '' ? $label : $url,
'url' => $url,
// #152: was this source actually SERVED to the
// visitor? Absent ⇒ true: turns written before
// the used-vs-retrieved filter served their full
// list, and relabelling history would be false
// (the same #128 D2 posture as citations_withheld
// below).
'rendered' => !isset($citation['rendered']) || !empty($citation['rendered']),
];
}
}
// Sales leads flagged on this turn (#70).
$turn_leads = (isset($meta['leads']) && is_array($meta['leads']))
? array_values(array_filter(array_map('strval', $meta['leads'])))
: [];
// Ideas captured on this turn (v3.4.0): model-emitted ideas
// that reached the ledger, plus the deterministic
// unanswered-question capture.
//
// #158: this reads `ideas_stored`, NOT `ideas_found`. The
// record keeps all three counts, but only the stored one is
// an owner-facing claim: the modal says "Ideas captured", and
// the owner then goes to the Ideas screen expecting rows.
// `ideas_found` (raw model emission) and `ideas_accepted`
// (post-#151 gate) both overstate that — see
// IDEAS_SURFACE_TRUTHFULNESS_DESIGN Absent ⇒ 0 by
// decision (D4: the pre-change dataset is test data, so
// the usual #128 D2 absent-≠-zero posture is deliberately
// NOT applied here).
$ideas_stored = isset($meta['ideas_stored']) ? (int) $meta['ideas_stored'] : 0;
$unanswered_captured = !empty($meta['unanswered_captured']);
// #128 D1/D3: which pipeline branch fired, per turn. This is
// deliberately a SEPARATE element from $meta_line rather than
// another ' · ' fragment inside it: on a moderated turn the
// meta line already carries the #124 provenance JUDGMENT, and
// the two must never read as one statement. The path says
// which branch ran; provenance_label() says what was decided.
// Detail modal only (D1) — the outcome POST METAS are
// conversation-scoped and last-turn-wins, so no
// list-table column can honestly carry this.
$path_token = self::turn_path_label($meta);
// Add assistant turn
$formatted[] = [
'role' => 'assistant',
'content' => $item['assistant'],
'formatted_time' => $timestamp ? mysql2date('g:i A', $timestamp) : '',
'meta_line' => $meta_line,
'path_token' => $path_token,
'path_label' => self::path_label_text($path_token),
'path_description' => self::path_label_description($path_token),
'deflection' => self::turn_deflection_payload($meta),
'citations' => $citations,
// #135: the transcript keeps citations the visitor was
// never shown. Say so, rather than rendering a "Sources:"
// line indistinguishable from the one they saw. Only
// turns that RECORDED the fact are qualified — see the
// writer's note.
'citations_withheld' => !empty($meta['citations_withheld']),
'leads' => $turn_leads,
'ideas_stored' => $ideas_stored,
'unanswered_captured' => $unanswered_captured
];
}
// Handle OLD Logger format (has 'role', 'question', 'answer')
elseif (isset($item['role']) && $item['role'] === 'qa') {
$timestamp = isset($item['time']) ? $item['time'] : '';
if (!empty($item['question'])) {
$formatted[] = [
'role' => 'user',
'content' => $item['question'],
'formatted_time' => $timestamp ? mysql2date('g:i A', $timestamp) : ''
];
}
if (!empty($item['answer'])) {
$formatted[] = [
'role' => 'assistant',
'content' => $item['answer'],
'formatted_time' => $timestamp ? mysql2date('g:i A', $timestamp) : ''
];
}
}
// Handle standalone user entries (#114: the escalation-trigger
// query is logged as its own user entry, no assistant pair)
elseif (isset($item['role']) && $item['role'] === 'user' && isset($item['content'])) {
$timestamp = isset($item['timestamp']) ? $item['timestamp'] : '';
if ($item['content'] !== '') {
$formatted[] = [
'role' => 'user',
'content' => $item['content'],
'formatted_time' => $timestamp ? mysql2date('g:i A', $timestamp) : ''
];
}
}
// Handle system notices (escalations etc.)
elseif (isset($item['role']) && $item['role'] === 'system') {
$timestamp = isset($item['timestamp']) ? $item['timestamp'] : '';
$content = isset($item['content']) ? $item['content'] : '';
if ($content !== '') {
$formatted[] = [
'role' => 'system',
'content' => $content,
'formatted_time' => $timestamp ? mysql2date('g:i A', $timestamp) : '',
'escalation' => isset($item['metadata']['type']) && $item['metadata']['type'] === 'escalation_notice',
];
}
}
}
return $formatted;
}
}