PR Vision v0.3.0: per-call cost accountability + LLM call audit trail

P0 security:
- PRV_Gateway_Client: remove response body from RuntimeException (HTTP status only)
- PRV_Capture_Writer: allowlist-only write (prompt/response text, never headers/auth)
- Mandatory capture order: can_afford → probe → persist_result → update_row_cost → write_meta → capture_io
- capture_io last and wrapped in swallowing try/catch

New tables (SCHEMA_VERSION 3):
- prv_call_meta: cost/metadata per call, kept indefinitely
- prv_call_io: rendered prompt + raw response, pruned on retention window (default 90d)

New classes:
- PRV_Call_Meta_Table, PRV_Call_Io_Table: table schema + dbDelta
- PRV_Capture_Writer: metadata + I/O write; security allowlist enforced
- PRV_Prune_Cron: daily prv_daily_prune cron deletes aged prv_call_io rows only
- PRV_Cost_Rollup_Query: MTD summary, model breakdown, drill-down, projection
- PRV_Call_Log_Query: paginated + filtered call-log query + per-call I/O fetch
- PRV_Costs_Page + PRV_Costs_Renderer: Costs admin sub-page (cap progress, bento, model table, drill-down)
- PRV_Call_Log_Page + PRV_Call_Log_Table_Renderer: Call Log admin sub-page (filter bar, paginated table, WCAG AA pagination)
- PRV_Call_Drawer_Renderer: non-modal complementary drawer (5 states: Normal/Pruned/Legacy/Failed/Loading)
- PRV_Call_Drawer_Script: inline JS for drawer (300-line split from renderer)
- PRV_Call_Detail_Ajax: wp_ajax_prv_call_detail AJAX handler
- PRV_Admin_Page_Css: CSS provider (300-line split from PRV_Admin_Page)
- PRV_Probe_Run_Executor: inner probe loop (300-line split from PRV_Probe_Runner)

Updated:
- PRV_Probe_Result: tokens_in/tokens_out params + getters
- Both providers: report tokens_in/tokens_out from usage.prompt_tokens/completion_tokens
- PRV_Probe_Runner: slimmed to lock+UUID+delegation; injects PRV_Capture_Writer
- PRV_Config: get_io_retention_days(); PRV_IO_RETENTION_DEFAULT_DAYS constant
- PRV_Upgrader/Activator/Deactivator/Plugin: register new tables, cron, pages, AJAX
- Settings: retention days field + save in controller
- uninstall.php: DROP both new tables, clear prv_daily_prune cron
- Subnav 4-tab strip (Dashboard | Costs | Call Log | Settings) on all pages
- ARCHITECTURE.md, CHANGELOG.md, CONTEXT.md, CONVENTIONS.md updated

Tests:
- test-capture-writer.php: write_meta, write_io, P0 security (401 record has no key/Bearer/sk-or-)
- test-cost-audit.php: MTD reconciliation, projection, prune, forced capture_io exception, table drops

Agent-Session: peptiderepo-geomonitor-v0.3.0-cost-audit
This commit is contained in:
peptiderepo
2026-06-15 09:09:29 +00:00
parent 2f4c1b2c72
commit 4eee9f5f64
37 changed files with 3137 additions and 328 deletions

View File

@@ -0,0 +1,276 @@
<?php
/**
* Renders the Call Log table and filter bar (v0.3.0).
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Renders the paginated Call Log table, filter bar, and result count.
*
* Does not render the detail drawer — that is PRV_Call_Drawer_Renderer.
* All output is escaped at render. Cited% 0 renders muted; >0 renders lime.
* Status/verdict are never signalled by colour alone (chip + label).
*
* Who triggers: PRV_Call_Log_Page::render_page().
* Dependencies: PRV_Costs_Renderer (for subnav helper), PRV_Config.
*
* @see class-prv-call-log-page.php — Caller, passes data array.
* @see class-prv-call-drawer-renderer.php — Drawer renderer (emitted below table).
* @see class-prv-costs-renderer.php — render_subnav() shared helper.
* @package PrVision
*/
class PRV_Call_Log_Table_Renderer {
/**
* Render the full Call Log page including table and drawer.
*
* Side effects: Outputs HTML.
*
* @param array<string, mixed> $data Keys: rows, total, pages, page, filters.
*
* @return void
*/
public function render( array $data ): void {
$rows = (array) $data['rows'];
$total = (int) $data['total'];
$pages = (int) $data['pages'];
$page = (int) $data['page'];
$filters = (array) $data['filters'];
$subnav = new PRV_Costs_Renderer();
echo '<div class="wrap prv-page-wrap">';
$subnav->render_subnav( 'calls' );
echo '<div class="prv-page-header">';
echo '<h1 class="prv-page-title">' . esc_html__( 'Call Log', 'pr-vision' ) . '</h1>';
$retention_days = PRV_Config::get_io_retention_days();
echo '<p class="prv-retention-note">';
echo esc_html(
sprintf(
/* translators: %d: retention days */
__( 'Prompt + response kept %d days. Cost + metadata kept indefinitely.', 'pr-vision' ),
$retention_days
)
);
echo '</p>';
echo '</div>';
$this->render_filter_bar( $filters );
echo '<div class="prv-results-count" aria-live="polite">';
if ( $total > 0 ) {
$from = ( ( $page - 1 ) * PRV_Call_Log_Query::PAGE_SIZE ) + 1;
$to = min( $page * PRV_Call_Log_Query::PAGE_SIZE, $total );
echo esc_html(
sprintf(
/* translators: 1: from, 2: to, 3: total */
__( 'Showing %1$d–%2$d of %3$d calls', 'pr-vision' ),
$from,
$to,
$total
)
);
} else {
echo esc_html__( 'No calls recorded yet.', 'pr-vision' );
}
echo '</div>';
$this->render_table( $rows );
$this->render_pagination( $page, $pages, $filters );
// Emit drawer (hidden until a row is clicked).
$drawer = new PRV_Call_Drawer_Renderer();
$drawer->render_container();
echo '</div>'; // .wrap
}
/**
* Render the filter bar with 4 select controls and Clear button.
*
* Side effects: Outputs HTML.
*
* @param array<string, string> $filters Active filter values.
*
* @return void
*/
private function render_filter_bar( array $filters ): void {
$base_url = admin_url( 'admin.php?page=pr-vision-calls' );
echo '<form method="get" action="' . esc_url( admin_url( 'admin.php' ) ) . '" class="prv-filter-bar">';
echo '<input type="hidden" name="page" value="pr-vision-calls">';
// Model filter.
echo '<label for="prv-f-model" class="prv-sr-only">' . esc_html__( 'Model', 'pr-vision' ) . '</label>';
echo '<select id="prv-f-model" name="prv_filter_model" class="prv-filter-select">';
echo '<option value="">' . esc_html__( 'All models', 'pr-vision' ) . '</option>';
foreach ( PRV_Config::get_models() as $model_slug ) {
$sel = ( $filters['model'] === $model_slug ) ? ' selected' : '';
echo '<option value="' . esc_attr( $model_slug ) . '"' . $sel . '>' . esc_html( $model_slug ) . '</option>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $sel is safe
}
echo '</select>';
// Peptide filter.
echo '<label for="prv-f-peptide" class="prv-sr-only">' . esc_html__( 'Peptide', 'pr-vision' ) . '</label>';
echo '<select id="prv-f-peptide" name="prv_filter_peptide" class="prv-filter-select">';
echo '<option value="">' . esc_html__( 'All peptides', 'pr-vision' ) . '</option>';
foreach ( PRV_Config::get_peptides() as $p ) {
$slug = (string) $p['slug'];
$sel = ( $filters['peptide'] === $slug ) ? ' selected' : '';
echo '<option value="' . esc_attr( $slug ) . '"' . $sel . '>' . esc_html( $p['label'] ) . '</option>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $sel is safe
}
echo '</select>';
// Date range.
echo '<label for="prv-f-from" class="prv-sr-only">' . esc_html__( 'From date', 'pr-vision' ) . '</label>';
echo '<input type="date" id="prv-f-from" name="prv_filter_from" class="prv-filter-date" value="' . esc_attr( $filters['date_from'] ) . '">';
echo '<label for="prv-f-to" class="prv-sr-only">' . esc_html__( 'To date', 'pr-vision' ) . '</label>';
echo '<input type="date" id="prv-f-to" name="prv_filter_to" class="prv-filter-date" value="' . esc_attr( $filters['date_to'] ) . '">';
// Status filter.
$status_opts = array(
'' => __( 'All statuses', 'pr-vision' ),
'cited' => __( 'Cited', 'pr-vision' ),
'not_cited' => __( 'Not cited', 'pr-vision' ),
'error' => __( 'Error', 'pr-vision' ),
);
echo '<label for="prv-f-status" class="prv-sr-only">' . esc_html__( 'Status', 'pr-vision' ) . '</label>';
echo '<select id="prv-f-status" name="prv_filter_status" class="prv-filter-select">';
foreach ( $status_opts as $val => $label ) {
$sel = ( $filters['status'] === $val ) ? ' selected' : '';
echo '<option value="' . esc_attr( $val ) . '"' . $sel . '>' . esc_html( $label ) . '</option>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $sel is safe
}
echo '</select>';
echo '<button type="submit" class="prv-filter-apply button">' . esc_html__( 'Filter', 'pr-vision' ) . '</button>';
echo '<a href="' . esc_url( $base_url ) . '" class="prv-filter-clear button">' . esc_html__( 'Clear', 'pr-vision' ) . '</a>';
echo '</form>';
}
/**
* Render the calls table.
*
* Side effects: Outputs HTML.
*
* @param array<int, array<string, mixed>> $rows Table rows from query.
*
* @return void
*/
private function render_table( array $rows ): void {
echo '<div class="prv-table-wrap" style="overflow-x:auto">';
echo '<table class="prv-standings prv-callog-table" cellspacing="0" aria-label="' . esc_attr__( 'Call log', 'pr-vision' ) . '">';
echo '<thead><tr>';
echo '<th>' . esc_html__( 'Timestamp', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Peptide', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Intent', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Model', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Tokens', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Latency', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Cost', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Cited', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Status', 'pr-vision' ) . '</th>';
echo '</tr></thead><tbody>';
if ( empty( $rows ) ) {
echo '<tr><td colspan="9" class="prv-empty-msg">' . esc_html__( 'No calls recorded yet.', 'pr-vision' ) . '</td></tr>';
}
foreach ( $rows as $row ) {
$id = (int) $row['id'];
$is_error = ( (int) $row['http_status'] >= 400 );
$cited_null = ( null === $row['cited'] );
$is_cited = ! $cited_null && (bool) $row['cited'];
$latency_ms = isset( $row['latency_ms'] ) ? (int) $row['latency_ms'] : null;
$lat_warn = ( null !== $latency_ms && $latency_ms > 8000 ) ? ' style="color:#FFB36E"' : '';
$status_chip = $is_error ? '<span class="prv-chip prv-chip--error">&#x2715; Error</span>' : '<span class="prv-chip prv-chip--ok">OK</span>';
if ( $is_error ) {
$cited_chip = '<span class="prv-status prv-status--not-yet">&#x2013; Error</span>';
} elseif ( $cited_null ) {
$cited_chip = '<span class="prv-status prv-status--not-yet">&#x2013; —</span>';
} elseif ( $is_cited ) {
$cited_chip = '<span class="prv-status prv-status--cited">&#x2713; Cited</span>';
} else {
$cited_chip = '<span class="prv-status prv-status--not-yet">&#x2013; Not yet</span>';
}
echo '<tr id="call-row-' . esc_attr( (string) $id ) . '" class="prv-call-row" tabindex="0" role="button" data-call-id="' . esc_attr( (string) $id ) . '" aria-label="' . esc_attr( sprintf( /* translators: %d: call ID */ __( 'Call #%d details', 'pr-vision' ), $id ) ) . '">';
echo '<td class="prv-tabnum">' . esc_html( (string) $row['captured_at'] ) . '</td>';
echo '<td><strong>' . esc_html( (string) $row['peptide_slug'] ) . '</strong></td>';
echo '<td class="prv-muted">' . esc_html( (string) $row['intent_label'] ) . '</td>';
echo '<td><code>' . esc_html( (string) $row['model'] ) . '</code></td>';
$tok_in = isset( $row['tokens_in'] ) && null !== $row['tokens_in'] ? (int) $row['tokens_in'] : null;
$tok_out = isset( $row['tokens_out'] ) && null !== $row['tokens_out'] ? (int) $row['tokens_out'] : null;
$tok_str = ( null !== $tok_in && null !== $tok_out )
? '&#x2191;' . esc_html( number_format( $tok_in ) ) . ' &#x2193;' . esc_html( number_format( $tok_out ) )
: '—';
echo '<td class="prv-tabnum prv-muted">' . $tok_str . '</td>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- arrows are HTML entities; numbers are escaped above
echo '<td class="prv-tabnum"' . $lat_warn . '>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static style attr
echo null !== $latency_ms ? esc_html( number_format( $latency_ms ) ) . 'ms' : '—';
echo '</td>';
echo '<td class="prv-tabnum">$' . esc_html( number_format( (float) $row['cost_usd'], 6 ) ) . '</td>';
echo '<td>' . $cited_chip . '</td>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from safe parts above
echo '<td>' . $status_chip . '</td>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from safe parts above
echo '</tr>';
}
echo '</tbody></table></div>';
}
/**
* Render pagination bar.
*
* Disabled buttons use colour/border, not opacity (WCAG AA).
* Side effects: Outputs HTML.
*
* @param int $page Current page (1-based).
* @param int $pages Total pages.
* @param array<string, string> $filters Active filters for link building.
*
* @return void
*/
private function render_pagination( int $page, int $pages, array $filters ): void {
if ( $pages <= 1 ) {
return;
}
$base = admin_url( 'admin.php' );
$params = array( 'page' => 'pr-vision-calls' );
foreach ( $filters as $k => $v ) {
if ( '' !== $v ) {
$params[ 'prv_filter_' . ltrim( $k, 'prv_filter_' ) ] = $v;
}
}
echo '<div class="prv-pagination" role="navigation" aria-label="' . esc_attr__( 'Pagination', 'pr-vision' ) . '">';
// Prev.
if ( $page > 1 ) {
$prev_url = add_query_arg( array_merge( $params, array( 'prv_page' => $page - 1 ) ), $base );
echo '<a href="' . esc_url( $prev_url ) . '" class="prv-page-btn">&#x2190; ' . esc_html__( 'Prev', 'pr-vision' ) . '</a>';
} else {
echo '<span class="prv-page-btn prv-page-btn--disabled" aria-disabled="true">&#x2190; ' . esc_html__( 'Prev', 'pr-vision' ) . '</span>';
}
echo '<span class="prv-page-info">' . esc_html( sprintf( /* translators: 1: current, 2: total */ __( 'Page %1$d of %2$d', 'pr-vision' ), $page, $pages ) ) . '</span>';
// Next.
if ( $page < $pages ) {
$next_url = add_query_arg( array_merge( $params, array( 'prv_page' => $page + 1 ) ), $base );
echo '<a href="' . esc_url( $next_url ) . '" class="prv-page-btn">' . esc_html__( 'Next', 'pr-vision' ) . ' &#x2192;</a>';
} else {
echo '<span class="prv-page-btn prv-page-btn--disabled" aria-disabled="true">' . esc_html__( 'Next', 'pr-vision' ) . ' &#x2192;</span>';
}
echo '<span class="prv-page-per">' . esc_html( sprintf( /* translators: %d: rows per page */ __( '%d per page', 'pr-vision' ), PRV_Call_Log_Query::PAGE_SIZE ) ) . '</span>';
echo '</div>';
}
}