From 2e2c72cac642d81bb06f8546d8e03f0dc0b40300 Mon Sep 17 00:00:00 2001 From: peptiderepo <273788462+peptiderepo@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:07:08 +0700 Subject: [PATCH] =?UTF-8?q?v0.3.0=20=E2=80=94=20cost=20accountability=20+?= =?UTF-8?q?=20LLM=20call=20audit=20trail=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-call recording of cost and I/O for every LLM probe. New tables prv_call_meta (schema v3) and prv_call_io. New admin pages: Costs (spend drill-down vs $5 cap) and Call Log (paginated + detail drawer). 90-day I/O retention prune via prv_daily_prune cron. P0 security: key never stored; gateway client exposes HTTP status only. QA APPROVED 2026-06-15 SHA 09dc6db9ce24169a55856d3291684578dbb2afb2 --- ARCHITECTURE.md | 39 ++- CHANGELOG.md | 62 ++++ CONTEXT.md | 27 +- CONVENTIONS.md | 11 +- includes/core/class-prv-activator.php | 6 + includes/core/class-prv-admin-page-css.php | 202 ++++++++++++ includes/core/class-prv-admin-page.php | 82 +---- includes/core/class-prv-call-detail-ajax.php | 95 ++++++ .../core/class-prv-call-drawer-renderer.php | 268 ++++++++++++++++ .../core/class-prv-call-drawer-script.php | 114 +++++++ includes/core/class-prv-call-io-table.php | 88 +++++ includes/core/class-prv-call-log-page.php | 100 ++++++ includes/core/class-prv-call-log-query.php | 181 +++++++++++ .../class-prv-call-log-table-renderer.php | 276 ++++++++++++++++ includes/core/class-prv-call-meta-table.php | 112 +++++++ includes/core/class-prv-capture-writer.php | 184 +++++++++++ includes/core/class-prv-config.php | 14 + includes/core/class-prv-cost-rollup-query.php | 205 ++++++++++++ includes/core/class-prv-costs-page.php | 102 ++++++ includes/core/class-prv-costs-renderer.php | 297 +++++++++++++++++ includes/core/class-prv-deactivator.php | 3 + includes/core/class-prv-plugin.php | 22 +- includes/core/class-prv-probe-result.php | 26 +- .../core/class-prv-probe-run-executor.php | 300 ++++++++++++++++++ includes/core/class-prv-probe-runner.php | 251 +++------------ includes/core/class-prv-prune-cron.php | 118 +++++++ .../core/class-prv-settings-controller.php | 5 + includes/core/class-prv-settings-renderer.php | 6 + includes/core/class-prv-upgrader.php | 7 + .../providers/class-prv-gateway-client.php | 27 +- .../class-prv-openrouter-provider.php | 6 +- .../class-prv-perplexity-provider.php | 6 +- pr-vision.php | 12 +- tests/bootstrap.php | 62 ++-- tests/unit/test-capture-writer.php | 142 +++++++++ tests/unit/test-cost-audit.php | 91 ++++++ uninstall.php | 22 +- 37 files changed, 3246 insertions(+), 325 deletions(-) create mode 100644 includes/core/class-prv-admin-page-css.php create mode 100644 includes/core/class-prv-call-detail-ajax.php create mode 100644 includes/core/class-prv-call-drawer-renderer.php create mode 100644 includes/core/class-prv-call-drawer-script.php create mode 100644 includes/core/class-prv-call-io-table.php create mode 100644 includes/core/class-prv-call-log-page.php create mode 100644 includes/core/class-prv-call-log-query.php create mode 100644 includes/core/class-prv-call-log-table-renderer.php create mode 100644 includes/core/class-prv-call-meta-table.php create mode 100644 includes/core/class-prv-capture-writer.php create mode 100644 includes/core/class-prv-cost-rollup-query.php create mode 100644 includes/core/class-prv-costs-page.php create mode 100644 includes/core/class-prv-costs-renderer.php create mode 100644 includes/core/class-prv-probe-run-executor.php create mode 100644 includes/core/class-prv-prune-cron.php create mode 100644 tests/unit/test-capture-writer.php create mode 100644 tests/unit/test-cost-audit.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 89e9fd5..6fb2549 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Architecture — PR Vision -**Version:** 0.2.1 | **Last updated:** 2026-06-14 +**Version:** 0.3.0 | **Last updated:** 2026-06-15 --- @@ -10,6 +10,7 @@ **v1 scope:** AI-visibility only. The collector/panel seam is present so future data categories can be added as new classes. **v2 additions:** Model manager CRUD (no deploy needed), config-version tracking, run-lock, projected cost, API-key three-state, dark "Assay" Settings UI. +**v0.3.0 additions:** Per-call cost/metadata + I/O audit trail; Costs + Call Log admin pages; non-modal detail drawer; daily I/O prune cron; `PRV_Gateway_Client` exception body removed (P0). --- @@ -130,6 +131,42 @@ Admin page load ───────────────────── ## 4. Database +### v0.3.0 additions + +**Table:** `{prefix}prv_call_meta` (kept indefinitely) + +| Column | Type | Notes | +|--------|------|-------| +| id | BIGINT UNSIGNED PK | Auto-increment | +| visibility_row | BIGINT UNSIGNED NULL | FK to prv_ai_visibility.id (nullable) | +| run_id | VARCHAR(36) | UUID for the run | +| peptide_slug | VARCHAR(200) | e.g. "bpc-157" | +| model | VARCHAR(200) | e.g. "perplexity/sonar" | +| intent_label | VARCHAR(200) | Prompt intent template | +| tokens_in | INT NULL | Input token count from API | +| tokens_out | INT NULL | Output token count from API | +| cost_usd | DECIMAL(12,8) | Actual call cost | +| latency_ms | INT NULL | Call latency in ms | +| cited | TINYINT(1) NULL | 1=cited, 0=not cited, NULL=error | +| http_status | INT | HTTP status (200=ok, 4xx/5xx=error) | +| captured_at | DATETIME | UTC | +| config_version | INT NULL | Config version at run time | +| io_captured | TINYINT(1) | 1 when write_io() succeeded; 0 for error rows and legacy/pre-feature rows; drives drawer state (0=Legacy, 1+io present=Normal, 1+io absent=Pruned) | + +**Table:** `{prefix}prv_call_io` (pruned after retention window) + +| Column | Type | Notes | +|--------|------|-------| +| id | BIGINT UNSIGNED PK | Auto-increment | +| call_id | BIGINT UNSIGNED | FK to prv_call_meta.id | +| prompt_text | LONGTEXT | Rendered prompt (plain text) | +| response_text | LONGTEXT | Raw LLM response text | +| captured_at | DATETIME | UTC (indexed for prune query) | + +--- + +## 4a. Original Database + **Table:** `{prefix}prv_ai_visibility` | Column | Type | Notes | diff --git a/CHANGELOG.md b/CHANGELOG.md index fd5ae00..423fc89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,68 @@ Versioning: [Semantic Versioning](https://semver.org/) --- +## [0.3.0] — 2026-06-15 + +Per-call cost accountability + LLM call audit trail. Every probe call now +produces an inspectable record with cost, tokens, latency, and the exact +rendered prompt + raw response. Two new admin pages (Costs, Call Log) with +a non-modal detail drawer. Raw I/O is pruned on a configurable retention +window (default 90 days); cost/metadata kept indefinitely. + +### Fixed (pre-launch P2 cleanup) +- `io_captured TINYINT(1) NOT NULL DEFAULT 0` column added to `prv_call_meta` CREATE (baked into initial schema — table is new in v0.3.0). Set to `1` by `PRV_Capture_Writer::write_io()` on success, remains `0` for error rows and legacy/pre-feature rows. Drawer state now driven by `io_captured` instead of the `config_version < 3` heuristic: `0` = Legacy/not-captured; `1` + io row present = Normal; `1` + io row absent = Pruned (aged out). Removes a fragile inference. + +### Security (P0) +- `PRV_Gateway_Client::post_to_gateway()` — removed response body from + RuntimeException message; exception now carries HTTP status code only. + Closes the pre-existing 401-body → error_log → key-leak vector. +- `PRV_Capture_Writer` — allowlist-only capture; NEVER receives `$headers`, + `Authorization`, or any raw HTTP request object. Structurally impossible + for a key to enter a stored row. +- Mandatory capture ordering enforced in `PRV_Probe_Run_Executor`: + `can_afford → probe → persist_result → update_row_cost → write_meta → capture_io`. + `capture_io` is last and wrapped in a swallowing try/catch. + +### Added +- **`PRV_Call_Meta_Table`** — per-call cost/metadata table (kept indefinitely). +- **`PRV_Call_Io_Table`** — per-call I/O table (pruned on retention window). +- **`PRV_Capture_Writer`** — writes metadata + I/O rows; allowlist-only. +- **`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 call-log with filters; per-call I/O fetch. +- **`PRV_Costs_Page`** — "PR Vision > Costs" admin sub-page. +- **`PRV_Costs_Renderer`** — cap progress, bento, model table, drill-down; includes `render_subnav()` shared helper. +- **`PRV_Call_Log_Page`** — "PR Vision > Call Log" admin sub-page with filter bar. +- **`PRV_Call_Log_Table_Renderer`** — paginated table; cited% 0 = muted, >0 = lime; WCAG AA pagination. +- **`PRV_Call_Drawer_Renderer`** — non-modal `role="complementary"` drawer; 5 states (Normal, Pruned, Legacy, Failed, Loading). Focus-managed; Esc closes. +- **`PRV_Call_Drawer_Script`** — inline JS for drawer open/close/fetch/copy split for 300-line compliance. +- **`PRV_Call_Detail_Ajax`** — `wp_ajax_prv_call_detail` AJAX handler for drawer content. +- **`PRV_Admin_Page_Css`** — CSS provider split from `PRV_Admin_Page` for 300-line compliance. +- **`PRV_Probe_Run_Executor`** — inner probe loop split from `PRV_Probe_Runner`. +- `PRV_Probe_Result` — added `tokens_in`/`tokens_out` params + getters. +- Both providers — report `tokens_in`/`tokens_out` from `usage.prompt_tokens`/`completion_tokens`. +- `PRV_Config::get_io_retention_days()` — reads `prv_io_retention_days` option. +- Retention days field added to Settings form + saved by controller. +- Subnav 4-tab strip added to all PR Vision pages (Dashboard | Costs | Call Log | Settings). +- Constants `PRV_IO_RETENTION_DEFAULT_DAYS`, `PRV_DAILY_PRUNE_HOOK`. +- Tests: `test-capture-writer.php`, `test-cost-audit.php`. + +### Changed +- `PRV_Probe_Runner` — slimmed to lock + UUID + delegation; inner loop moved to `PRV_Probe_Run_Executor`. Constructor now also accepts `PRV_Capture_Writer`. +- `PRV_Upgrader::run()` — creates both new tables on every `plugins_loaded`. +- `PRV_Activator::activate()` — creates new tables + schedules `prv_daily_prune` cron. +- `PRV_Deactivator::deactivate()` — clears `prv_daily_prune` cron. +- `PRV_Plugin::init()` — registers `PRV_Costs_Page`, `PRV_Call_Log_Page`, `PRV_Call_Detail_Ajax`, `PRV_Prune_Cron`. +- `PRV_Settings_Renderer` — adds subnav + retention days field. +- `PRV_Settings_Controller::handle_save()` — saves `prv_io_retention_days`. +- `uninstall.php` — DROPs `prv_call_io` + `prv_call_meta`; clears `prv_daily_prune` cron. +- Schema version bumped to 3. + +### Security +- See P0 section above. + +--- + ## [0.2.3] — 2026-06-15 UI-editable provider API key: encrypted at rest, write-only, wp-config precedence. diff --git a/CONTEXT.md b/CONTEXT.md index 2514e8d..7c29da8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,6 +1,6 @@ # Context — Domain Glossary (PR Vision) -**Version:** 0.2.1 | **Last updated:** 2026-06-14 +**Version:** 0.3.0 | **Last updated:** 2026-06-15 This file is the ubiquitous-language glossary for `pr-vision`. Every term below has a precise meaning within this codebase. AI agents and human contributors should use this terminology consistently. @@ -141,3 +141,28 @@ The Settings page section that allows the operator to set/replace/clear the API The symmetric key used to encrypt/decrypt `prv_provider_key_enc`. Derived via SHA-256 from `AUTH_KEY + SECURE_AUTH_KEY` (or `wp_salt()` fallback) — environment-specific and never stored. Derived fresh at every encrypt/decrypt call. **Code identifiers:** `PRV_Key_Store::derive_encryption_key()`, `PRV_Crypto_Helper::encrypt()`, `PRV_Crypto_Helper::decrypt()`. + +### Call Meta / Call Log +The per-call audit trail introduced in v0.3.0. Every LLM probe call produces a **call metadata** row (`prv_call_meta`) kept indefinitely, and a **call I/O** row (`prv_call_io`) retained for a configurable window. + +**Code identifiers:** `PRV_Call_Meta_Table`, `PRV_Call_Io_Table`, `PRV_Capture_Writer`, `PRV_Call_Log_Query`. + +### Capture Writer +The `PRV_Capture_Writer` class that writes call metadata + I/O rows after a probe. Implements a strict allowlist: stores rendered prompt text, response text, model, tokens, cost — NEVER headers, Authorization, or any raw HTTP request object. + +**Code identifiers:** `PRV_Capture_Writer::write_meta()`, `PRV_Capture_Writer::write_io()`. + +### Retention Window +The configurable number of days raw prompt + response text is kept in `prv_call_io` before the daily prune cron deletes it. Default 90 days. Cost/metadata in `prv_call_meta` is kept indefinitely. + +**Code identifiers:** `PRV_Config::get_io_retention_days()`, `prv_io_retention_days` option, `PRV_Prune_Cron::prune_now()`. + +### Detail Drawer +A non-modal, keyboard-accessible panel (`role="complementary"`) on the Call Log page that shows the exact rendered prompt + raw response for any selected call. Five states: Normal, Pruned, Legacy/not-captured, Failed, Loading. Focus-managed: opens to close button, Esc closes and returns focus to the triggering row, no tab-trap. + +**Code identifiers:** `PRV_Call_Drawer_Renderer`, `PRV_Call_Drawer_Script`, `PRV_Call_Detail_Ajax`. + +### Capture Ordering (P0) +The mandatory sequence within each probe iteration: `can_afford → probe → persist_result → update_row_cost → write_meta → capture_io`. `capture_io` is always last and wrapped in a swallowing try/catch so a write failure can never reach the probe path or corrupt cap accounting. + +**Code identifiers:** `PRV_Probe_Run_Executor::probe_one()`. diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 9d6e61d..759d0a0 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -1,6 +1,6 @@ # Conventions — PR Vision -**Version:** 0.2.1 | **Last updated:** 2026-06-14 +**Version:** 0.3.0 | **Last updated:** 2026-06-15 Applies to all contributors and AI agents working in this repo. Where this document and `AGENT-OPERATING-STANDARD.md` conflict on process, the Standard wins; this document wins on code style. @@ -92,3 +92,12 @@ All key reads must go through `PRV_Key_Store::get_key()`. This maintains the pre - AJAX handlers: call `PRV_Key_Store::get_key()` before any LLM call. - New provider that needs the key: implement `is_configured()` as `'' !== PRV_Key_Store::get_key()`. - Do NOT pass the resolved key to any client-side surface, log, exception message, or `error_log()`. + +## 9. Capture pattern (v0.3.0+) + +The call-audit trail follows a strict allowlist pattern: + +- `PRV_Capture_Writer::write_meta()` accepts only: `visibility_row`, `run_id`, `peptide_slug`, `model`, `intent_label`, `tokens_in`, `tokens_out`, `cost_usd`, `latency_ms`, `cited`, `http_status`, `config_version`. +- `PRV_Capture_Writer::write_io()` accepts only: the rendered `prompt_text` (plain text) and `response_text` (LLM response content). +- **NEVER** pass `$headers`, `$parsed_args`, `Authorization`, raw HTTP request blobs, or any field not in the above lists. +- Capture calls are LAST in the probe loop and wrapped in a swallowing try/catch — failure must never propagate to the probe path or corrupt cap accounting. diff --git a/includes/core/class-prv-activator.php b/includes/core/class-prv-activator.php index 6071a95..e116472 100644 --- a/includes/core/class-prv-activator.php +++ b/includes/core/class-prv-activator.php @@ -19,6 +19,7 @@ declare(strict_types=1); * * @see class-prv-table-manager.php — Database schema creation. * @see class-prv-cron.php — Cron scheduling. + * @see class-prv-prune-cron.php — Daily prune cron (v0.3.0). * @package PrVision */ class PRV_Activator { @@ -32,7 +33,12 @@ class PRV_Activator { */ public static function activate(): void { PRV_Table_Manager::create_table(); + // v0.3.0: per-call audit trail tables. + PRV_Call_Meta_Table::create_table(); + PRV_Call_Io_Table::create_table(); PRV_Config::seed_defaults(); PRV_Cron::schedule_weekly(); + // v0.3.0: daily prune cron for prv_call_io. + PRV_Prune_Cron::schedule(); } } diff --git a/includes/core/class-prv-admin-page-css.php b/includes/core/class-prv-admin-page-css.php new file mode 100644 index 0000000..aceb0e3 --- /dev/null +++ b/includes/core/class-prv-admin-page-css.php @@ -0,0 +1,202 @@ +h1,.prv-settings-wrap~*>h1,.wp-heading-inline{color:#EEF2F5;} +.notice.prv-proxy-note{background:#1C2228;border-left-color:#34C0CA;color:#EEF2F5;} +.notice.prv-proxy-note p{color:#C2CCD6;} +'; + } + + /** + * Core component tokens (.prv-*) unchanged from prior versions. + * + * @return string + */ + private static function get_component_css(): string { + return ' +/* === PR Vision dashboard — "Assay" palette (dark-adapted) === */ +.prv-bento{display:grid;grid-template-columns:1.25fr 1fr 1fr;gap:16px;margin:18px 0 22px;} +@media(max-width:820px){.prv-bento{grid-template-columns:1fr;}} +.prv-tile{background:#1C2228;border:1px solid #3A4651;border-radius:12px;padding:20px 22px;position:relative;min-height:140px;display:flex;flex-direction:column;} +.prv-tile-label{font-size:11.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:#9AA7B2;margin-bottom:6px;} +.prv-tile-big{font-size:42px;font-weight:700;color:#EEF2F5;line-height:1;font-variant-numeric:tabular-nums;margin-top:auto;} +.prv-tile-big small{font-size:16px;font-weight:600;color:#9AA7B2;margin-left:3px;} +.prv-tile-sub{font-size:12.5px;color:#9AA7B2;margin-top:8px;} +.prv-tile-delta{font-size:12.5px;font-weight:600;margin-top:8px;} +.prv-delta--up{color:#9BE635;} .prv-delta--down{color:#FF6B5E;} .prv-delta--flat{color:#9AA7B2;} +.prv-health-pill{position:absolute;top:14px;right:14px;display:inline-flex;align-items:center;gap:5px;font-size:11.5px;font-weight:600;padding:3px 9px 3px 7px;border-radius:999px;} +.prv-health-dot{width:7px;height:7px;border-radius:50%;flex:0 0 7px;} +.prv-health--ok{background:rgba(155,230,53,.15);color:#9BE635;} .prv-health--ok .prv-health-dot{background:#9BE635;} +.prv-health--warn{background:rgba(255,157,77,.15);color:#FFB36E;} .prv-health--warn .prv-health-dot{background:#FF9D4D;} +.prv-health--neutral{background:rgba(136,147,160,.14);color:#B4BDC7;} .prv-health--neutral .prv-health-dot{background:#8893A0;} +.prv-cost-row{display:flex;align-items:baseline;gap:4px;margin-top:auto;} +.prv-cost-big{font-size:28px;font-weight:700;color:#EEF2F5;font-variant-numeric:tabular-nums;} +.prv-cost-cap{font-size:13px;color:#9AA7B2;} +.prv-meter{height:10px;border-radius:999px;background:#2A333C;margin-top:8px;border:1px solid #333D47;overflow:hidden;} +.prv-meter>span{display:block;height:100%;border-radius:999px;background:#34C0CA;transition:width .3s ease;} +.prv-meter--capped>span{background:#FF9D4D;} +.prv-trunc-badge{display:flex;align-items:flex-start;gap:7px;margin-top:10px;background:rgba(255,157,77,.15);border:1px solid rgba(255,157,77,.42);border-radius:6px;padding:8px 10px;font-size:12px;color:#EEF2F5;line-height:1.4;} +.prv-trunc-icon{color:#FFB36E;font-size:14px;flex:0 0 16px;margin-top:1px;} +.prv-trunc-badge b{color:#FFB36E;} +.prv-tile-ts{font-size:20px;font-weight:700;color:#EEF2F5;margin-top:auto;line-height:1.2;} +.prv-tile-meta{font-size:12.5px;color:#9AA7B2;margin-top:6px;} +.prv-card{background:#1C2228;border:1px solid #3A4651;border-radius:12px;margin:0 0 20px;} +.prv-card-head{display:flex;align-items:center;justify-content:space-between;padding:18px 22px 0;} +.prv-card-head h2{font-size:16px;font-weight:600;color:#EEF2F5;margin:0;} +.prv-card-body{padding:10px 22px 20px;} +.prv-chartbox{position:relative;height:300px;max-height:300px;padding:14px 8px 0;overflow:hidden;} +.prv-chartbox canvas{max-height:286px;} +.prv-chart-fallback{display:none;height:100%;align-items:center;justify-content:center;text-align:center;color:#9AA7B2;font-size:13px;padding:0 24px;} +.prv-chartbox.prv-noscript .prv-chart-fallback{display:flex;} +.prv-chartbox.prv-noscript canvas{display:none;} +.prv-chartcap{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:8px 22px 4px;font-size:12.5px;color:#9AA7B2;} +.prv-chartcap-item{display:inline-flex;align-items:center;gap:6px;} +.prv-leg-solid{width:20px;height:0;border-top:3px solid #34C0CA;display:inline-block;} +.prv-leg-dash{width:20px;height:0;border-top:2px dashed #B6F25A;display:inline-block;} +.prv-cfg-note{display:flex;gap:10px;align-items:flex-start;margin:8px 22px 14px;background:rgba(255,157,77,.15);border:1px solid rgba(255,157,77,.42);border-radius:8px;padding:11px 14px;color:#EEF2F5;font-size:13px;line-height:1.45;} +.prv-cfg-note-icon{color:#FFB36E;font-style:normal;flex:0 0 16px;margin-top:1px;} +.prv-cfg-note b{color:#FFB36E;} +.prv-standings{margin:0;} +.prv-status{display:inline-flex;align-items:center;gap:4px;font-size:12.5px;font-weight:600;padding:2px 8px;border-radius:999px;} +.prv-status--cited{background:rgba(155,230,53,.15);color:#9BE635;} +.prv-status--not-yet{background:rgba(136,147,160,.14);color:#B4BDC7;} +.prv-proxy-note{margin:10px 0;} +'; + } + + /** + * V0.3.0 additions: subnav, costs, call-log, drawer components. + * + * @return string + */ + private static function get_v030_css(): string { + return ' +/* === v0.3.0: subnav + costs + call log === */ +.prv-subnav{display:flex;gap:2px;margin:0 0 18px;border-bottom:1px solid #3A4651;padding-bottom:0;} +.prv-subnav-tab{display:inline-block;padding:8px 16px;font-size:13px;font-weight:500;color:#9AA7B2;text-decoration:none;border-bottom:2px solid transparent;margin-bottom:-1px;transition:color .15s;} +.prv-subnav-tab:hover{color:#EEF2F5;} +.prv-subnav-tab--active{color:#34C0CA;border-bottom-color:#34C0CA;} +.prv-page-wrap{padding:0 20px 40px;background:#14181C;min-height:100vh;} +.prv-page-header{margin:18px 0 14px;} +.prv-page-title{font-size:20px;font-weight:700;color:#EEF2F5;margin:0 0 4px;} +.prv-page-sub{font-size:13px;color:#9AA7B2;margin:0;} +.prv-retention-note{font-size:12px;color:#9AA7B2;margin:4px 0 0;font-style:italic;} +.prv-cap-row{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:12px;} +.prv-cap-left{display:flex;align-items:baseline;gap:6px;} +.prv-cap-right{display:flex;flex-direction:column;align-items:flex-end;gap:4px;} +.prv-cap-projected{display:flex;flex-direction:column;align-items:flex-end;} +.prv-cap-proj-label{font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:#9AA7B2;} +.prv-cap-proj-val{font-size:18px;font-weight:600;font-variant-numeric:tabular-nums;color:#EEF2F5;} +.prv-cap-days{font-size:12px;color:#9AA7B2;} +.prv-cap-reconcile{font-size:12px;color:#9AA7B2;margin-top:10px;} +.prv-tile-big--sm{font-size:28px;} +.prv-filter-bar{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 14px;align-items:center;} +.prv-filter-select,.prv-filter-date{background:#1C2228;border:1px solid #3A4651;color:#EEF2F5;border-radius:6px;padding:5px 10px;font-size:13px;} +.prv-results-count{font-size:12.5px;color:#9AA7B2;margin:0 0 10px;} +.prv-callog-table th,.prv-callog-table td{white-space:nowrap;} +.prv-callog-table{min-width:900px;} +.prv-tabnum{font-variant-numeric:tabular-nums;} +.prv-muted{color:#9AA7B2;} +.prv-mono{font-family:monospace;font-size:12px;} +.prv-mono-sm{font-size:11px;} +.prv-chip{display:inline-flex;align-items:center;gap:4px;font-size:11.5px;font-weight:600;padding:2px 8px;border-radius:999px;} +.prv-chip--ok{background:rgba(52,192,202,.15);color:#34C0CA;} +.prv-chip--error{background:rgba(255,107,94,.15);color:#FF6B5E;} +.prv-call-row{cursor:pointer;} +.prv-call-row:hover{background:rgba(52,192,202,.07);} +.prv-call-row:focus{outline:2px solid #34C0CA;outline-offset:-2px;} +.prv-pagination{display:flex;align-items:center;gap:10px;margin-top:14px;flex-wrap:wrap;} +.prv-page-btn{display:inline-block;padding:5px 12px;border:1px solid #3A4651;border-radius:6px;font-size:12.5px;color:#EEF2F5;text-decoration:none;} +.prv-page-btn:hover{border-color:#34C0CA;color:#34C0CA;} +.prv-page-btn--disabled{color:#4A5563;border-color:#2A333C;background:#1C2228;cursor:not-allowed;} +.prv-page-info,.prv-page-per{font-size:12.5px;color:#9AA7B2;} +.prv-drawer{position:fixed;right:0;top:32px;height:calc(100vh - 32px);width:480px;background:#1C2228;border-left:1px solid #3A4651;z-index:9999;overflow-y:auto;transform:translateX(100%);transition:transform .16s ease;} +.prv-drawer:not([hidden]){transform:translateX(0);} +@media(prefers-reduced-motion:reduce){.prv-drawer{transition:none;}} +@media(max-width:600px){.prv-drawer{width:100%;}} +.prv-drawer-overlay{position:fixed;inset:0;background:rgba(0,0,0,.3);z-index:9998;opacity:0;transition:opacity .16s ease;} +.prv-drawer:not([hidden])~.prv-drawer-overlay{opacity:1;} +.prv-drawer-header{display:flex;align-items:center;gap:12px;padding:14px 18px;border-bottom:1px solid #3A4651;} +.prv-drawer-close{background:none;border:1px solid #3A4651;color:#9AA7B2;border-radius:6px;padding:4px 10px;cursor:pointer;font-size:12.5px;} +.prv-drawer-close:hover{color:#EEF2F5;border-color:#9AA7B2;} +.prv-drawer-title{font-size:13px;font-weight:600;color:#EEF2F5;} +.prv-drawer-body{padding:16px 18px;} +.prv-drawer-meta-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;} +.prv-drawer-id{font-size:16px;font-weight:700;color:#EEF2F5;} +.prv-drawer-ts{font-size:12px;color:#9AA7B2;font-variant-numeric:tabular-nums;} +.prv-drawer-topline{font-size:13px;color:#C2CCD6;margin-bottom:10px;} +.prv-drawer-peptide{font-weight:600;color:#EEF2F5;} +.prv-drawer-dl{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;font-size:12.5px;margin:12px 0 16px;} +.prv-drawer-dl dt{color:#9AA7B2;font-weight:500;} +.prv-drawer-dl dd{color:#EEF2F5;margin:0;} +.prv-drawer-section{margin-bottom:18px;} +.prv-drawer-section-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;} +.prv-drawer-section-label{font-size:10.5px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:#9AA7B2;} +.prv-copy-btn{background:none;border:1px solid #3A4651;color:#9AA7B2;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:11.5px;} +.prv-copy-btn:hover{color:#34C0CA;border-color:#34C0CA;} +.prv-code-block{background:#14181C;border:1px solid #2A333C;border-radius:6px;padding:12px;font-size:11.5px;line-height:1.55;color:#C2CCD6;overflow-y:auto;max-height:320px;white-space:pre-wrap;word-break:break-word;margin:0;} +.prv-drawer-notice{display:flex;gap:10px;align-items:flex-start;background:rgba(136,147,160,.1);border:1px solid #3A4651;border-radius:8px;padding:12px 14px;font-size:12.5px;color:#C2CCD6;line-height:1.45;} +.prv-notice-icon{font-size:16px;color:#9AA7B2;flex:0 0 18px;} +.prv-error-block{display:flex;gap:10px;align-items:flex-start;background:rgba(255,107,94,.12);border:1px solid rgba(255,107,94,.35);border-radius:8px;padding:12px 14px;font-size:12.5px;color:#EEF2F5;line-height:1.45;} +.prv-error-icon{font-size:16px;color:#FF6B5E;flex:0 0 18px;} +.prv-drawer-err{color:#FF8A80;font-size:13px;} +.prv-skel-line{background:#2A333C;border-radius:4px;height:12px;margin-bottom:8px;animation:prvPulse 1.2s ease-in-out infinite;} +.prv-skel-line--wide{width:80%;} .prv-skel-line--med{width:55%;} .prv-skel-line--sm{width:35%;} +@keyframes prvPulse{0%,100%{opacity:.5;}50%{opacity:1;}} +.prv-seg{display:flex;gap:2px;margin-bottom:14px;flex-wrap:wrap;} +.prv-seg-btn{display:inline-block;padding:5px 12px;border:1px solid #3A4651;border-radius:6px;font-size:12.5px;color:#9AA7B2;text-decoration:none;} +.prv-seg-btn:hover{color:#EEF2F5;border-color:#9AA7B2;} +.prv-seg-btn--active{background:#34C0CA;color:#14181C;border-color:#34C0CA;font-weight:600;} +.prv-table-wrap table{width:100%;} +.prv-empty-msg{color:#9AA7B2;font-size:13px;padding:14px 0;} +.prv-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0;} +.prv-drawer-na{color:#9AA7B2;font-size:13px;font-style:italic;} +.prv-status--error{background:rgba(255,107,94,.15);color:#FF6B5E;} +.prv-field-hint{display:block;font-size:12px;color:#9AA7B2;margin-top:4px;} +'; + } +} diff --git a/includes/core/class-prv-admin-page.php b/includes/core/class-prv-admin-page.php index 9144a66..f752368 100644 --- a/includes/core/class-prv-admin-page.php +++ b/includes/core/class-prv-admin-page.php @@ -201,89 +201,13 @@ class PRV_Admin_Page { } /** - * Return the dashboard CSS for the PR Vision admin page. + * Return the combined CSS string for all PR Vision admin screens. * - * Covers two concerns: - * 1. WP admin wrapper chrome (injected here; fires on BOTH PR Vision screens - * because the enqueue_assets guard matches 'pr-vision' in the hook suffix): - * #wpcontent / #wpbody-content are set to the dark page bg so no white - * gutter/strip shows around or above the dark UI. - * 2. Component tokens (.prv-*) — unchanged from prior versions. - * - * This inline style block is attached to wp-admin (always loaded on admin - * pages) via wp_add_inline_style() and therefore only present on the two - * PR Vision screens. It must NOT be registered globally. + * Delegates to PRV_Admin_Page_Css to keep this class under 300 lines. * * @return string CSS string. */ private function get_dashboard_css(): string { - return ' -/* === PR Vision — WP admin chrome: kill white wrappers (scoped via conditional enqueue) === */ -#wpcontent{background:#14181C;padding-left:0;} -#wpbody-content{background:#14181C;min-height:calc(100vh - 32px);padding-bottom:40px;} -#wpbody{background:#14181C;} -.wrap>h1,.prv-settings-wrap~*>h1,.wp-heading-inline{color:#EEF2F5;} -.notice.prv-proxy-note{background:#1C2228;border-left-color:#34C0CA;color:#EEF2F5;} -.notice.prv-proxy-note p{color:#C2CCD6;} -/* === PR Vision dashboard — "Assay" palette (dark-adapted) === */ -.prv-bento{display:grid;grid-template-columns:1.25fr 1fr 1fr;gap:16px;margin:18px 0 22px;} -@media(max-width:820px){.prv-bento{grid-template-columns:1fr;}} -.prv-tile{background:#1C2228;border:1px solid #3A4651;border-radius:12px;padding:20px 22px;position:relative;min-height:140px;display:flex;flex-direction:column;} -.prv-tile-label{font-size:11.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:#9AA7B2;margin-bottom:6px;} -.prv-tile-big{font-size:42px;font-weight:700;color:#EEF2F5;line-height:1;font-variant-numeric:tabular-nums;margin-top:auto;} -.prv-tile-big small{font-size:16px;font-weight:600;color:#9AA7B2;margin-left:3px;} -.prv-tile-sub{font-size:12.5px;color:#9AA7B2;margin-top:8px;} -.prv-tile-delta{font-size:12.5px;font-weight:600;margin-top:8px;} -.prv-delta--up{color:#9BE635;} .prv-delta--down{color:#FF6B5E;} .prv-delta--flat{color:#9AA7B2;} -/* health pill */ -.prv-health-pill{position:absolute;top:14px;right:14px;display:inline-flex;align-items:center;gap:5px;font-size:11.5px;font-weight:600;padding:3px 9px 3px 7px;border-radius:999px;} -.prv-health-dot{width:7px;height:7px;border-radius:50%;flex:0 0 7px;} -.prv-health--ok{background:rgba(155,230,53,.15);color:#9BE635;} .prv-health--ok .prv-health-dot{background:#9BE635;} -.prv-health--warn{background:rgba(255,157,77,.15);color:#FFB36E;} .prv-health--warn .prv-health-dot{background:#FF9D4D;} -.prv-health--neutral{background:rgba(136,147,160,.14);color:#B4BDC7;} .prv-health--neutral .prv-health-dot{background:#8893A0;} -/* cost tile */ -.prv-cost-row{display:flex;align-items:baseline;gap:4px;margin-top:auto;} -.prv-cost-big{font-size:28px;font-weight:700;color:#EEF2F5;font-variant-numeric:tabular-nums;} -.prv-cost-cap{font-size:13px;color:#9AA7B2;} -.prv-meter{height:10px;border-radius:999px;background:#2A333C;margin-top:8px;border:1px solid #333D47;overflow:hidden;} -.prv-meter>span{display:block;height:100%;border-radius:999px;background:#34C0CA;transition:width .3s ease;} -.prv-meter--capped>span{background:#FF9D4D;} -.prv-trunc-badge{display:flex;align-items:flex-start;gap:7px;margin-top:10px;background:rgba(255,157,77,.15);border:1px solid rgba(255,157,77,.42);border-radius:6px;padding:8px 10px;font-size:12px;color:#EEF2F5;line-height:1.4;} -.prv-trunc-icon{color:#FFB36E;font-size:14px;flex:0 0 16px;margin-top:1px;} -.prv-trunc-badge b{color:#FFB36E;} -/* last run tile */ -.prv-tile-ts{font-size:20px;font-weight:700;color:#EEF2F5;margin-top:auto;line-height:1.2;} -.prv-tile-meta{font-size:12.5px;color:#9AA7B2;margin-top:6px;} -/* cards */ -.prv-card{background:#1C2228;border:1px solid #3A4651;border-radius:12px;margin:0 0 20px;} -.prv-card-head{display:flex;align-items:center;justify-content:space-between;padding:18px 22px 0;} -.prv-card-head h2{font-size:16px;font-weight:600;color:#EEF2F5;margin:0;} -.prv-card-body{padding:10px 22px 20px;} -/* chart */ -.prv-chartbox{position:relative;height:300px;max-height:300px;padding:14px 8px 0;overflow:hidden;} -.prv-chartbox canvas{max-height:286px;} -.prv-chart-fallback{display:none;height:100%;align-items:center;justify-content:center;text-align:center;color:#9AA7B2;font-size:13px;padding:0 24px;} -.prv-chartbox.prv-noscript .prv-chart-fallback{display:flex;} -.prv-chartbox.prv-noscript canvas{display:none;} -/* chart legend */ -.prv-chartcap{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:8px 22px 4px;font-size:12.5px;color:#9AA7B2;} -.prv-chartcap-item{display:inline-flex;align-items:center;gap:6px;} -.prv-leg-solid{width:20px;height:0;border-top:3px solid #34C0CA;display:inline-block;} -.prv-leg-dash{width:20px;height:0;border-top:2px dashed #B6F25A;display:inline-block;} -.prv-leg-cfg{display:inline-flex;align-items:center;gap:3px;} -.prv-leg-cfg::before{content:"";width:0;height:14px;border-left:2px dashed #FFB36E;display:inline-block;} -.prv-leg-cfg::after{content:"";width:8px;height:8px;border-radius:50%;background:#FF9D4D;display:inline-block;} -/* config change note */ -.prv-cfg-note{display:flex;gap:10px;align-items:flex-start;margin:8px 22px 14px;background:rgba(255,157,77,.15);border:1px solid rgba(255,157,77,.42);border-radius:8px;padding:11px 14px;color:#EEF2F5;font-size:13px;line-height:1.45;} -.prv-cfg-note-icon{color:#FFB36E;font-style:normal;flex:0 0 16px;margin-top:1px;} -.prv-cfg-note b{color:#FFB36E;} -/* standings table */ -.prv-standings{margin:0;} -.prv-status{display:inline-flex;align-items:center;gap:4px;font-size:12.5px;font-weight:600;padding:2px 8px;border-radius:999px;} -.prv-status--cited{background:rgba(155,230,53,.15);color:#9BE635;} -.prv-status--not-yet{background:rgba(136,147,160,.14);color:#B4BDC7;} -/* proxy note */ -.prv-proxy-note{margin:10px 0;} -'; + return PRV_Admin_Page_Css::get(); } } diff --git a/includes/core/class-prv-call-detail-ajax.php b/includes/core/class-prv-call-detail-ajax.php new file mode 100644 index 0000000..5fecf3e --- /dev/null +++ b/includes/core/class-prv-call-detail-ajax.php @@ -0,0 +1,95 @@ +get_row( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT * FROM {$table} WHERE id = %d LIMIT 1", + $call_id + ), + ARRAY_A + ); + + if ( null === $meta || ! is_array( $meta ) ) { + wp_send_json_error( esc_html__( 'Call not found.', 'pr-vision' ) ); + } + + $io = $query->get_call_io( $call_id ); + + ob_start(); + $renderer = new PRV_Call_Drawer_Renderer(); + $renderer->render_content( $meta, $io ); + $html = ob_get_clean(); + + wp_send_json_success( array( 'html' => $html ) ); + } +} diff --git a/includes/core/class-prv-call-drawer-renderer.php b/includes/core/class-prv-call-drawer-renderer.php new file mode 100644 index 0000000..729c676 --- /dev/null +++ b/includes/core/class-prv-call-drawer-renderer.php @@ -0,0 +1,268 @@ +'; + echo '
'; + echo ''; + echo ''; + echo '
'; + echo '
'; + echo ''; + echo '
'; + echo ''; + + // Overlay — pointer-events:none so table stays interactive. + echo ''; + + $this->render_inline_script(); + } + + /** + * Render a populated drawer for a specific call (server-side variant). + * + * Used when building drawer HTML server-side (e.g., for AJAX endpoint). + * Security: only prompt_text and response_text shown — never headers. + * + * Side effects: Outputs HTML. + * + * @param array $meta Call metadata row. + * @param array|null $io I/O record or null (pruned/missing). + * + * @return void + */ + public function render_content( array $meta, ?array $io ): void { + $id = (int) $meta['id']; + $captured = (string) $meta['captured_at']; + $peptide = (string) $meta['peptide_slug']; + $model = (string) $meta['model']; + $intent = (string) $meta['intent_label']; + $http_status = (int) ( $meta['http_status'] ?? 200 ); + $is_error = $http_status >= 400; + $cited = isset( $meta['cited'] ) ? $meta['cited'] : null; + $tok_in = isset( $meta['tokens_in'] ) ? (int) $meta['tokens_in'] : null; + $tok_out = isset( $meta['tokens_out'] ) ? (int) $meta['tokens_out'] : null; + $latency_ms = isset( $meta['latency_ms'] ) ? (int) $meta['latency_ms'] : null; + $cost_usd = (float) ( $meta['cost_usd'] ?? 0.0 ); + $run_id = (string) ( $meta['run_id'] ?? '' ); + $config_ver = isset( $meta['config_version'] ) ? (int) $meta['config_version'] : null; + $io_captured = isset( $meta['io_captured'] ) ? (int) $meta['io_captured'] : 0; + + // Determine drawer state using the explicit io_captured flag. + // io_captured = 1 AND io row present → Normal. + // io_captured = 1 AND io row absent → Pruned (aged out by retention cron). + // io_captured = 0 → Legacy / not captured (pre-feature row). + $has_io = ( null !== $io ); + $vis_row_null = empty( $meta['visibility_row'] ); + if ( ! $is_error ) { + $is_legacy = ( 0 === $io_captured ); + $is_pruned = ( 1 === $io_captured && ! $has_io ); + } else { + $is_legacy = false; + $is_pruned = false; + } + + // Header. + echo '
'; + echo '' . esc_html( sprintf( /* translators: %d: call ID */ __( 'Call #%d', 'pr-vision' ), $id ) ) . ''; + echo '' . esc_html( $captured ) . ' UTC'; + echo '
'; + + echo '
'; + echo '' . esc_html( $peptide ) . ''; + if ( '' !== $intent ) { + echo ' · ' . esc_html( $intent ) . ''; + } + echo '
'; + + // Verdict chip. + if ( $is_error ) { + echo '✕ Error'; + } elseif ( null !== $cited ) { + if ( (bool) $cited ) { + echo '✓ Cited'; + } else { + echo '– Not yet'; + } + } + + // Metadata section. + echo '
'; + echo '
' . esc_html__( 'Model', 'pr-vision' ) . '
' . esc_html( $model ) . '
'; + + $tok_str = ( null !== $tok_in && null !== $tok_out ) + ? sprintf( '↑ %s in / ↓ %s out', number_format( $tok_in ), number_format( $tok_out ) ) + : '—'; + echo '
' . esc_html__( 'Tokens', 'pr-vision' ) . '
' . esc_html( $tok_str ) . '
'; + + echo '
' . esc_html__( 'Latency', 'pr-vision' ) . '
'; + echo null !== $latency_ms ? esc_html( number_format( $latency_ms ) ) . 'ms' : '—'; + echo '
'; + + echo '
' . esc_html__( 'Cost', 'pr-vision' ) . '
$' . esc_html( number_format( $cost_usd, 6 ) ) . '
'; + + if ( '' !== $run_id ) { + echo '
' . esc_html__( 'Run ID', 'pr-vision' ) . '
' . esc_html( substr( $run_id, 0, 8 ) ) . '…
'; + } + + if ( null !== $config_ver ) { + echo '
' . esc_html__( 'Config', 'pr-vision' ) . '
v' . esc_html( (string) $config_ver ) . '
'; + } + + if ( $is_error ) { + echo '
' . esc_html__( 'HTTP Status', 'pr-vision' ) . '
' . esc_html( (string) $http_status ) . '
'; + } + + echo '
'; + + // Prompt section. + echo '
'; + echo '
'; + echo ''; + if ( $has_io && isset( $io['prompt_text'] ) && '' !== $io['prompt_text'] ) { + echo ''; + } + echo '
'; + + if ( $is_legacy ) { + $this->render_not_captured_notice( $captured ); + } elseif ( $is_pruned ) { + $this->render_pruned_notice( $captured ); + } elseif ( $has_io && isset( $io['prompt_text'] ) ) { + echo '
' . esc_html( $io['prompt_text'] ) . '
'; + } else { + echo '

—

'; + } + echo '
'; + + // Response section. + echo '
'; + echo '
'; + echo ''; + if ( $has_io && isset( $io['response_text'] ) && '' !== $io['response_text'] ) { + echo ''; + } + echo '
'; + + if ( $is_error ) { + echo ''; + } elseif ( ! $is_legacy && ! $is_pruned && $has_io && isset( $io['response_text'] ) && '' !== $io['response_text'] ) { + echo '
' . esc_html( $io['response_text'] ) . '
'; + } elseif ( ! $is_error && ! $is_legacy && ! $is_pruned ) { + echo '

—

'; + } + echo '
'; + } + + /** + * Render the "pruned" I/O notice (aged out, not an error). + * + * Side effects: Outputs HTML. + * + * @param string $captured_at Timestamp when the call was captured. + * + * @return void + */ + private function render_pruned_notice( string $captured_at ): void { + echo '
'; + echo ''; + echo '
'; + echo '' . esc_html__( 'Prompt and response pruned', 'pr-vision' ) . '
'; + echo esc_html( + sprintf( + /* translators: 1: retention days, 2: captured date */ + __( 'Raw I/O is kept for %1$d days, then auto-deleted. This call was recorded on %2$s UTC.', 'pr-vision' ), + PRV_Config::get_io_retention_days(), + $captured_at + ) + ); + echo '
'; + } + + /** + * Render the "not captured" notice (pre-v0.3 row, never stored). + * + * Distinct from the pruned state — different copy prevents confusion. + * Side effects: Outputs HTML. + * + * @param string $captured_at Timestamp when the call was captured. + * + * @return void + */ + private function render_not_captured_notice( string $captured_at ): void { + echo '
'; + echo ''; + echo '
'; + echo '' . esc_html__( 'Not captured for this call', 'pr-vision' ) . '
'; + echo esc_html__( 'This call was recorded before the call-inspector feature was added. Prompt and response were never stored — this is not a retention prune.', 'pr-vision' ); + echo '
'; + } + + /** + * Emit the inline JavaScript for drawer open/close/copy behaviour. + * + * Security: data is fetched from a server endpoint that enforces the + * capture allowlist — no keys or headers can appear in drawer content. + * Side effects: Outputs script tag. + * + * @return void + */ + private function render_inline_script(): void { + PRV_Call_Drawer_Script::render( + wp_create_nonce( PRV_Call_Detail_Ajax::NONCE_ACTION ), + admin_url( 'admin-ajax.php' ) + ); + } +} diff --git a/includes/core/class-prv-call-drawer-script.php b/includes/core/class-prv-call-drawer-script.php new file mode 100644 index 0000000..f56b323 --- /dev/null +++ b/includes/core/class-prv-call-drawer-script.php @@ -0,0 +1,114 @@ +'; + echo '(function(){'; + echo 'var drawer=document.getElementById("prv-drawer");'; + echo 'var body=document.getElementById("prv-drawer-body");'; + echo 'var closeBtn=document.getElementById("prv-drawer-close");'; + echo 'var lastFocus=null;'; + echo 'function openDrawer(callId,triggerEl){'; + echo ' lastFocus=triggerEl||null;'; + echo ' drawer.removeAttribute("hidden");'; + echo ' body.setAttribute("aria-busy","true");'; + echo ' var skel=document.querySelector(".prv-drawer-skeleton");'; + echo ' if(skel){body.innerHTML=skel.outerHTML;}'; + echo ' closeBtn.focus();'; + echo ' loadCall(callId);'; + echo '}'; + echo 'function closeDrawer(){'; + echo ' drawer.setAttribute("hidden","");'; + echo ' body.setAttribute("aria-busy","false");'; + echo ' if(lastFocus&&lastFocus.focus){lastFocus.focus();}'; + echo '}'; + echo 'function loadCall(callId){'; + echo ' var data=new URLSearchParams();'; + echo ' data.append("action","prv_call_detail");'; + echo ' data.append("nonce","' . $nonce_js . '");'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_js applied above + echo ' data.append("call_id",callId);'; + echo ' fetch("' . $ajax_js . '",{method:"POST",credentials:"same-origin",body:data})'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_url applied above + echo ' .then(function(r){return r.json();})'; + echo ' .then(function(json){'; + echo ' if(json.success){'; + echo ' body.innerHTML=json.data.html;'; + echo ' body.setAttribute("aria-busy","false");'; + echo ' attachCopyButtons();'; + echo ' }else{'; + echo ' body.innerHTML="

"+json.data+"

";'; + echo ' body.setAttribute("aria-busy","false");'; + echo ' }'; + echo ' })'; + echo ' .catch(function(){'; + echo ' body.innerHTML="

Error loading call details.

";'; + echo ' body.setAttribute("aria-busy","false");'; + echo ' });'; + echo '}'; + echo 'function attachCopyButtons(){'; + echo ' document.querySelectorAll(".prv-copy-btn").forEach(function(btn){'; + echo ' btn.addEventListener("click",function(){'; + echo ' var target=document.getElementById(btn.dataset.copyTarget);'; + echo ' if(target){'; + echo ' var orig=btn.textContent;'; + echo ' navigator.clipboard.writeText(target.textContent||"").then(function(){'; + echo ' btn.textContent="Copied!";'; + echo ' setTimeout(function(){btn.textContent=orig;},1500);'; + echo ' });'; + echo ' }'; + echo ' });'; + echo ' });'; + echo '}'; + echo 'closeBtn.addEventListener("click",closeDrawer);'; + echo 'document.addEventListener("keydown",function(e){'; + echo ' if("Escape"===e.key&&!drawer.hasAttribute("hidden")){closeDrawer();}'; + echo '});'; + echo 'document.querySelectorAll(".prv-call-row").forEach(function(row){'; + echo ' function handleOpen(){openDrawer(row.dataset.callId,row);}'; + echo ' row.addEventListener("click",handleOpen);'; + echo ' row.addEventListener("keydown",function(e){'; + echo ' if("Enter"===e.key||" "===e.key){e.preventDefault();handleOpen();}'; + echo ' });'; + echo '});'; + echo '})();'; + echo ''; + } +} diff --git a/includes/core/class-prv-call-io-table.php b/includes/core/class-prv-call-io-table.php new file mode 100644 index 0000000..65b3140 --- /dev/null +++ b/includes/core/class-prv-call-io-table.php @@ -0,0 +1,88 @@ +prefix). + */ + const TABLE_BASE = 'prv_call_io'; + + /** + * Create or upgrade the call-io table via dbDelta. + * + * Safe to call on every plugins_loaded — dbDelta is idempotent. + * Side effects: Database write. + * + * @return void + */ + public static function create_table(): void { + global $wpdb; + + $table = $wpdb->prefix . self::TABLE_BASE; + $charset_collate = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE {$table} ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + call_id BIGINT(20) UNSIGNED NOT NULL, + prompt_text LONGTEXT NOT NULL DEFAULT '', + response_text LONGTEXT NOT NULL DEFAULT '', + captured_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY call_id (call_id), + KEY captured_at (captured_at) + ) {$charset_collate};"; + + if ( ! function_exists( 'dbDelta' ) ) { + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + } + + dbDelta( $sql ); + } + + /** + * Get the full table name including WP prefix. + * + * @return string + */ + public static function get_table_name(): string { + global $wpdb; + return $wpdb->prefix . self::TABLE_BASE; + } + + /** + * Drop the table. Called from uninstall.php only. + * + * Side effects: Permanently destroys all I/O records. + * + * @return void + */ + public static function drop_table(): void { + global $wpdb; + $table = $wpdb->prefix . self::TABLE_BASE; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( "DROP TABLE IF EXISTS {$table}" ); + } +} diff --git a/includes/core/class-prv-call-log-page.php b/includes/core/class-prv-call-log-page.php new file mode 100644 index 0000000..bcbbeb2 --- /dev/null +++ b/includes/core/class-prv-call-log-page.php @@ -0,0 +1,100 @@ + Call Log" sub-menu. Reads filter/page params from + * the URL, queries PRV_Call_Log_Query, and delegates rendering to + * PRV_Call_Log_Table_Renderer and PRV_Call_Drawer_Renderer. + * + * Who triggers: PRV_Plugin::init() — is_admin() guard. + * Dependencies: PRV_Call_Log_Query, PRV_Call_Log_Table_Renderer, + * PRV_Call_Drawer_Renderer, PRV_Config. + * + * @see class-prv-call-log-table-renderer.php — Table + drawer rendering. + * @see class-prv-call-drawer-renderer.php — Detail drawer rendering. + * @see class-prv-call-log-query.php — Query layer. + * @package PrVision + */ +class PRV_Call_Log_Page { + + /** + * Admin menu slug. + */ + const MENU_SLUG = 'pr-vision-calls'; + + /** + * Register WordPress admin hooks. + * + * Side effects: Adds admin_menu action. + * + * @return void + */ + public function register_hooks(): void { + add_action( 'admin_menu', array( $this, 'add_submenu' ) ); + } + + /** + * Register the Call Log sub-menu under PR Vision. + * + * Side effects: Adds a WP admin sub-menu entry. + * + * @return void + */ + public function add_submenu(): void { + add_submenu_page( + PRV_Admin_Page::MENU_SLUG, + __( 'PR Vision — Call Log', 'pr-vision' ), + __( 'Call Log', 'pr-vision' ), + 'manage_options', + self::MENU_SLUG, + array( $this, 'render_page' ) + ); + } + + /** + * Render the full Call Log page. + * + * Side effects: Outputs HTML. + * + * @return void + */ + public function render_page(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'Insufficient permissions.', 'pr-vision' ) ); + } + + // Read filter params (no nonce: read-only display only). + // phpcs:disable WordPress.Security.NonceVerification.Recommended + $filters = array( + 'model' => isset( $_GET['prv_filter_model'] ) ? sanitize_text_field( wp_unslash( $_GET['prv_filter_model'] ) ) : '', + 'peptide' => isset( $_GET['prv_filter_peptide'] ) ? sanitize_text_field( wp_unslash( $_GET['prv_filter_peptide'] ) ) : '', + 'date_from' => isset( $_GET['prv_filter_from'] ) ? sanitize_text_field( wp_unslash( $_GET['prv_filter_from'] ) ) : '', + 'date_to' => isset( $_GET['prv_filter_to'] ) ? sanitize_text_field( wp_unslash( $_GET['prv_filter_to'] ) ) : '', + 'status' => isset( $_GET['prv_filter_status'] ) ? sanitize_text_field( wp_unslash( $_GET['prv_filter_status'] ) ) : '', + ); + $page = isset( $_GET['prv_page'] ) ? max( 1, absint( wp_unslash( $_GET['prv_page'] ) ) ) : 1; + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + $query = new PRV_Call_Log_Query(); + $result = $query->get_page( $filters, $page ); + + $renderer = new PRV_Call_Log_Table_Renderer(); + $renderer->render( + array( + 'rows' => $result['rows'], + 'total' => $result['total'], + 'pages' => $result['pages'], + 'page' => $page, + 'filters' => $filters, + ) + ); + } +} diff --git a/includes/core/class-prv-call-log-query.php b/includes/core/class-prv-call-log-query.php new file mode 100644 index 0000000..9fb472c --- /dev/null +++ b/includes/core/class-prv-call-log-query.php @@ -0,0 +1,181 @@ + $filters Keys: model, peptide, date_from, + * date_to, status (ok|error|cited|not_cited). + * @param int $page 1-based page number. + * + * @return array{rows: array>, total: int, pages: int} + */ + public function get_page( array $filters, int $page = 1 ): array { + global $wpdb; + + $page = max( 1, $page ); + $offset = ( $page - 1 ) * self::PAGE_SIZE; + $table = PRV_Call_Meta_Table::get_table_name(); + + list( $where_sql, $where_args ) = $this->build_where( $filters ); + + // Count query. + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $total = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$table} {$where_sql}", + ...$where_args + ) + ); + // phpcs:enable + + $pages = $total > 0 ? (int) ceil( $total / self::PAGE_SIZE ) : 1; + + // Data query. + $data_args = $where_args; + $data_args[] = self::PAGE_SIZE; + $data_args[] = $offset; + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT id, visibility_row, run_id, peptide_slug, model, intent_label, + tokens_in, tokens_out, cost_usd, latency_ms, cited, + http_status, captured_at, config_version + FROM {$table} + {$where_sql} + ORDER BY captured_at DESC + LIMIT %d OFFSET %d", + ...$data_args + ), + ARRAY_A + ); + // phpcs:enable + + return array( + 'rows' => is_array( $rows ) ? $rows : array(), + 'total' => $total, + 'pages' => $pages, + ); + } + + /** + * Fetch a single call's I/O record from prv_call_io. + * + * Returns null when the row has been pruned (aged out). + * Side effects: Database read. + * + * @param int $call_id PK of prv_call_meta row. + * + * @return array{prompt_text: string, response_text: string}|null Null when pruned. + */ + public function get_call_io( int $call_id ): ?array { + global $wpdb; + + $table = PRV_Call_Io_Table::get_table_name(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $row = $wpdb->get_row( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "SELECT prompt_text, response_text FROM {$table} WHERE call_id = %d LIMIT 1", + $call_id + ), + ARRAY_A + ); + + if ( null === $row || ! is_array( $row ) ) { + return null; + } + + return array( + 'prompt_text' => (string) $row['prompt_text'], + 'response_text' => (string) $row['response_text'], + ); + } + + /** + * Build the WHERE clause and parameter list from filter array. + * + * Returns a two-element array: [sql_string, args_array]. + * + * @param array $filters See get_page() docblock. + * + * @return array{0: string, 1: array} + */ + private function build_where( array $filters ): array { + $clauses = array(); + $args = array(); + + if ( ! empty( $filters['model'] ) ) { + $clauses[] = 'model = %s'; + $args[] = sanitize_text_field( $filters['model'] ); + } + + if ( ! empty( $filters['peptide'] ) ) { + $clauses[] = 'peptide_slug = %s'; + $args[] = sanitize_text_field( $filters['peptide'] ); + } + + if ( ! empty( $filters['date_from'] ) ) { + $clauses[] = 'captured_at >= %s'; + $args[] = sanitize_text_field( $filters['date_from'] ) . ' 00:00:00'; + } + + if ( ! empty( $filters['date_to'] ) ) { + $clauses[] = 'captured_at <= %s'; + $args[] = sanitize_text_field( $filters['date_to'] ) . ' 23:59:59'; + } + + if ( ! empty( $filters['status'] ) ) { + switch ( $filters['status'] ) { + case 'error': + $clauses[] = 'http_status >= %d'; + $args[] = 400; + break; + case 'cited': + $clauses[] = 'cited = %d'; + $args[] = 1; + break; + case 'not_cited': + $clauses[] = 'cited = %d'; + $args[] = 0; + break; + } + } + + $where_sql = empty( $clauses ) ? '' : 'WHERE ' . implode( ' AND ', $clauses ); + + return array( $where_sql, $args ); + } +} diff --git a/includes/core/class-prv-call-log-table-renderer.php b/includes/core/class-prv-call-log-table-renderer.php new file mode 100644 index 0000000..b123c3d --- /dev/null +++ b/includes/core/class-prv-call-log-table-renderer.php @@ -0,0 +1,276 @@ +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 $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 '
'; + $subnav->render_subnav( 'calls' ); + + echo '
'; + echo '

' . esc_html__( 'Call Log', 'pr-vision' ) . '

'; + + $retention_days = PRV_Config::get_io_retention_days(); + echo '

'; + echo esc_html( + sprintf( + /* translators: %d: retention days */ + __( 'Prompt + response kept %d days. Cost + metadata kept indefinitely.', 'pr-vision' ), + $retention_days + ) + ); + echo '

'; + echo '
'; + + $this->render_filter_bar( $filters ); + + echo '
'; + 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 '
'; + + $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 '
'; // .wrap + } + + /** + * Render the filter bar with 4 select controls and Clear button. + * + * Side effects: Outputs HTML. + * + * @param array $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 '
'; + echo ''; + + // Model filter. + echo ''; + echo ''; + + // Peptide filter. + echo ''; + echo ''; + + // Date range. + echo ''; + echo ''; + echo ''; + echo ''; + + // Status filter. + $status_opts = array( + '' => __( 'All statuses', 'pr-vision' ), + 'cited' => __( 'Cited', 'pr-vision' ), + 'not_cited' => __( 'Not cited', 'pr-vision' ), + 'error' => __( 'Error', 'pr-vision' ), + ); + echo ''; + echo ''; + + echo ''; + echo '' . esc_html__( 'Clear', 'pr-vision' ) . ''; + echo '
'; + } + + /** + * Render the calls table. + * + * Side effects: Outputs HTML. + * + * @param array> $rows Table rows from query. + * + * @return void + */ + private function render_table( array $rows ): void { + echo '
'; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + + if ( empty( $rows ) ) { + echo ''; + } + + 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 ? '✕ Error' : 'OK'; + + if ( $is_error ) { + $cited_chip = '– Error'; + } elseif ( $cited_null ) { + $cited_chip = '– —'; + } elseif ( $is_cited ) { + $cited_chip = '✓ Cited'; + } else { + $cited_chip = '– Not yet'; + } + + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + + $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 ) + ? '↑' . esc_html( number_format( $tok_in ) ) . ' ↓' . esc_html( number_format( $tok_out ) ) + : '—'; + echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- arrows are HTML entities; numbers are escaped above + + echo ''; + + echo ''; + echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from safe parts above + echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from safe parts above + echo ''; + } + + echo '
' . esc_html__( 'Timestamp', 'pr-vision' ) . '' . esc_html__( 'Peptide', 'pr-vision' ) . '' . esc_html__( 'Intent', 'pr-vision' ) . '' . esc_html__( 'Model', 'pr-vision' ) . '' . esc_html__( 'Tokens', 'pr-vision' ) . '' . esc_html__( 'Latency', 'pr-vision' ) . '' . esc_html__( 'Cost', 'pr-vision' ) . '' . esc_html__( 'Cited', 'pr-vision' ) . '' . esc_html__( 'Status', 'pr-vision' ) . '
' . esc_html__( 'No calls recorded yet.', 'pr-vision' ) . '
' . esc_html( (string) $row['captured_at'] ) . '' . esc_html( (string) $row['peptide_slug'] ) . '' . esc_html( (string) $row['intent_label'] ) . '' . esc_html( (string) $row['model'] ) . '' . $tok_str . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static style attr + echo null !== $latency_ms ? esc_html( number_format( $latency_ms ) ) . 'ms' : '—'; + echo '$' . esc_html( number_format( (float) $row['cost_usd'], 6 ) ) . '' . $cited_chip . '' . $status_chip . '
'; + } + + /** + * 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 $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 ''; + } +} diff --git a/includes/core/class-prv-call-meta-table.php b/includes/core/class-prv-call-meta-table.php new file mode 100644 index 0000000..d3c3760 --- /dev/null +++ b/includes/core/class-prv-call-meta-table.php @@ -0,0 +1,112 @@ +prefix). + */ + const TABLE_BASE = 'prv_call_meta'; + + /** + * Schema version introduced by this table. + */ + const SCHEMA_VERSION = 3; + + /** + * Create or upgrade the call-meta table via dbDelta. + * + * Safe to call on every plugins_loaded — dbDelta is idempotent. + * Side effects: Database write; may update prv_schema_version option. + * + * @return void + */ + public static function create_table(): void { + global $wpdb; + + $table = $wpdb->prefix . self::TABLE_BASE; + $vis_table = $wpdb->prefix . 'prv_ai_visibility'; + $charset_collate = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE {$table} ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + visibility_row BIGINT(20) UNSIGNED NULL DEFAULT NULL, + run_id VARCHAR(36) NOT NULL DEFAULT '', + peptide_slug VARCHAR(200) NOT NULL DEFAULT '', + model VARCHAR(200) NOT NULL DEFAULT '', + intent_label VARCHAR(200) NOT NULL DEFAULT '', + tokens_in INT(11) NULL DEFAULT NULL, + tokens_out INT(11) NULL DEFAULT NULL, + cost_usd DECIMAL(12,8) NOT NULL DEFAULT 0.00000000, + latency_ms INT(11) NULL DEFAULT NULL, + cited TINYINT(1) NULL DEFAULT NULL, + http_status INT(11) NOT NULL DEFAULT 200, + captured_at DATETIME NOT NULL, + config_version INT(11) NULL DEFAULT NULL, + io_captured TINYINT(1) NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY visibility_row (visibility_row), + KEY run_id (run_id), + KEY peptide_slug (peptide_slug), + KEY model (model), + KEY captured_at (captured_at) + ) {$charset_collate};"; + + if ( ! function_exists( 'dbDelta' ) ) { + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + } + + dbDelta( $sql ); + + update_option( 'prv_schema_version', self::SCHEMA_VERSION ); + } + + /** + * Get the full table name including WP prefix. + * + * @return string + */ + public static function get_table_name(): string { + global $wpdb; + return $wpdb->prefix . self::TABLE_BASE; + } + + /** + * Drop the table. Called from uninstall.php only. + * + * Side effects: Permanently destroys all call metadata. + * + * @return void + */ + public static function drop_table(): void { + global $wpdb; + $table = $wpdb->prefix . self::TABLE_BASE; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->query( "DROP TABLE IF EXISTS {$table}" ); + } +} diff --git a/includes/core/class-prv-capture-writer.php b/includes/core/class-prv-capture-writer.php new file mode 100644 index 0000000..8c0c325 --- /dev/null +++ b/includes/core/class-prv-capture-writer.php @@ -0,0 +1,184 @@ + $meta Allowlisted fields: visibility_row (int|null), + * run_id (string), peptide_slug (string), model (string), intent_label (string), + * tokens_in (int|null), tokens_out (int|null), cost_usd (float), + * latency_ms (int|null), cited (int|null), http_status (int), + * config_version (int|null). + * + * @return int Inserted row ID, or 0 on failure. + */ + public function write_meta( array $meta ): int { + global $wpdb; + + $now = current_time( 'mysql', true ); + $table = PRV_Call_Meta_Table::get_table_name(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->insert( + $table, + array( + 'visibility_row' => isset( $meta['visibility_row'] ) ? (int) $meta['visibility_row'] : null, + 'run_id' => (string) ( $meta['run_id'] ?? '' ), + 'peptide_slug' => (string) ( $meta['peptide_slug'] ?? '' ), + 'model' => (string) ( $meta['model'] ?? '' ), + 'intent_label' => (string) ( $meta['intent_label'] ?? '' ), + 'tokens_in' => isset( $meta['tokens_in'] ) ? (int) $meta['tokens_in'] : null, + 'tokens_out' => isset( $meta['tokens_out'] ) ? (int) $meta['tokens_out'] : null, + 'cost_usd' => number_format( (float) ( $meta['cost_usd'] ?? 0.0 ), 8, '.', '' ), + 'latency_ms' => isset( $meta['latency_ms'] ) ? (int) $meta['latency_ms'] : null, + 'cited' => isset( $meta['cited'] ) ? (int) $meta['cited'] : null, + 'http_status' => (int) ( $meta['http_status'] ?? 200 ), + 'captured_at' => $now, + 'config_version' => isset( $meta['config_version'] ) ? (int) $meta['config_version'] : null, + 'io_captured' => 0, + ), + array( '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%d', '%d', '%d', '%s', '%d', '%d' ) + ); + + return (int) $wpdb->insert_id; + } + + /** + * Write one I/O row to prv_call_io (best-effort; caller must catch). + * + * Security: only prompt_text and response_text are stored — never + * headers, Authorization, or any raw HTTP request object. + * + * On success, sets io_captured = 1 on the matching prv_call_meta row. + * The meta update is a best-effort follow-up; a failure here is not + * propagated (caller already swallows this entire method). + * + * Side effects: Database writes to prv_call_io and prv_call_meta. + * + * @param int $call_id FK to prv_call_meta.id. + * @param string $prompt_text Rendered plain-text prompt (messages joined). + * @param string $response_text Raw text content from LLM response. + * + * @return bool True on success. + */ + public function write_io( int $call_id, string $prompt_text, string $response_text ): bool { + global $wpdb; + + $table = PRV_Call_Io_Table::get_table_name(); + $now = current_time( 'mysql', true ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $result = $wpdb->insert( + $table, + array( + 'call_id' => $call_id, + 'prompt_text' => $prompt_text, + 'response_text' => $response_text, + 'captured_at' => $now, + ), + array( '%d', '%s', '%s', '%s' ) + ); + + if ( false !== $result ) { + $this->set_io_captured( $call_id ); + return true; + } + + return false; + } + + /** + * Update cost_usd on an existing prv_call_meta row. + * + * Called after the probe returns its actual cost, mirroring the pattern + * already used by PRV_Cost_Ledger::update_row_cost() for prv_ai_visibility. + * + * Side effects: Database write to prv_call_meta. + * + * @param int $call_id PK of the prv_call_meta row. + * @param float $cost_usd Settled cost in USD. + * + * @return bool True on success. + */ + public function update_meta_cost( int $call_id, float $cost_usd ): bool { + global $wpdb; + + $table = PRV_Call_Meta_Table::get_table_name(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $result = $wpdb->update( + $table, + array( 'cost_usd' => number_format( $cost_usd, 8, '.', '' ) ), + array( 'id' => $call_id ), + array( '%s' ), + array( '%d' ) + ); + + return false !== $result; + } + + /** + * Mark io_captured = 1 on the prv_call_meta row after a successful I/O write. + * + * Best-effort: called internally from write_io(); failure is non-fatal. + * Legacy/pre-feature rows retain io_captured = 0 (the column default). + * + * Side effects: Database write to prv_call_meta. + * + * @param int $call_id PK of the prv_call_meta row. + * + * @return void + */ + private function set_io_captured( int $call_id ): void { + global $wpdb; + + $table = PRV_Call_Meta_Table::get_table_name(); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->update( + $table, + array( 'io_captured' => 1 ), + array( 'id' => $call_id ), + array( '%d' ), + array( '%d' ) + ); + } +} diff --git a/includes/core/class-prv-config.php b/includes/core/class-prv-config.php index 90d324f..699bfd1 100644 --- a/includes/core/class-prv-config.php +++ b/includes/core/class-prv-config.php @@ -137,6 +137,20 @@ class PRV_Config { ); } + + /** + * Get the I/O retention window in days. + * + * Raw prompt + response text in prv_call_io is pruned after this many days. + * Cost/metadata in prv_call_meta is kept indefinitely. + * + * @return int Days; minimum 1, default PRV_IO_RETENTION_DEFAULT_DAYS. + */ + public static function get_io_retention_days(): int { + $val = (int) get_option( 'prv_io_retention_days', PRV_IO_RETENTION_DEFAULT_DAYS ); + return max( 1, $val ); + } + /** * Default set of ~12 high-interest peptides (slug + human-readable label). * diff --git a/includes/core/class-prv-cost-rollup-query.php b/includes/core/class-prv-cost-rollup-query.php new file mode 100644 index 0000000..ca2bc01 --- /dev/null +++ b/includes/core/class-prv-cost-rollup-query.php @@ -0,0 +1,205 @@ +prepare. + * The MTD total from this class reconciles to PRV_Cost_Ledger::get_month_to_date_usd() + * because both sum cost_usd on prv_call_meta filtered by current calendar month. + * + * Who triggers: PRV_Costs_Page::render_page(). + * Dependencies: $wpdb, PRV_Call_Meta_Table, PRV_Config. + * + * @see class-prv-call-meta-table.php — Source table. + * @see class-prv-cost-ledger.php — MTD total (reconciliation reference). + * @see class-prv-costs-page.php — Consumer of rollup data. + * @package PrVision + */ +class PRV_Cost_Rollup_Query { + + /** + * Rows per page for the drill-down table. + */ + const PAGE_SIZE = 50; + + /** + * Fetch MTD totals: total cost, total calls, avg cost/call for the month. + * + * Side effects: Database read. + * + * @return array{total_cost: float, total_calls: int, avg_cost: float} + */ + public function get_mtd_summary(): array { + global $wpdb; + + $table = PRV_Call_Meta_Table::get_table_name(); + $month = gmdate( 'Y-m' ); + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT COALESCE(SUM(cost_usd),0) AS total_cost, COUNT(*) AS total_calls + FROM {$table} + WHERE DATE_FORMAT(captured_at, %s) = %s", + '%Y-%m', + $month + ), + ARRAY_A + ); + // phpcs:enable + + $total_cost = (float) ( $row['total_cost'] ?? 0.0 ); + $total_calls = (int) ( $row['total_calls'] ?? 0 ); + $avg_cost = $total_calls > 0 ? $total_cost / $total_calls : 0.0; + + return array( + 'total_cost' => $total_cost, + 'total_calls' => $total_calls, + 'avg_cost' => $avg_cost, + ); + } + + /** + * Fetch per-model MTD cost breakdown, ordered by cost descending. + * + * Side effects: Database read. + * + * @return array + */ + public function get_model_breakdown(): array { + global $wpdb; + + $table = PRV_Call_Meta_Table::get_table_name(); + $month = gmdate( 'Y-m' ); + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT model, + COUNT(*) AS calls, + COALESCE(SUM(cost_usd),0) AS mtd_cost + FROM {$table} + WHERE DATE_FORMAT(captured_at, %s) = %s + GROUP BY model + ORDER BY mtd_cost DESC + LIMIT 50", + '%Y-%m', + $month + ), + ARRAY_A + ); + // phpcs:enable + + $result = array(); + foreach ( (array) $rows as $row ) { + $calls = (int) $row['calls']; + $mtd_cost = (float) $row['mtd_cost']; + $result[] = array( + 'model' => (string) $row['model'], + 'calls' => $calls, + 'mtd_cost' => $mtd_cost, + 'cost_per_call' => $calls > 0 ? $mtd_cost / $calls : 0.0, + ); + } + return $result; + } + + /** + * Fetch cost drill-down by aggregation level (run/peptide/intent/model). + * + * Side effects: Database read. + * + * @param string $level One of: run, peptide, intent, model. + * @param int $offset Pagination offset. + * + * @return array> Rows appropriate for the level. + */ + public function get_drill_down( string $level, int $offset = 0 ): array { + global $wpdb; + + $table = PRV_Call_Meta_Table::get_table_name(); + $month = gmdate( 'Y-m' ); + $page_size = self::PAGE_SIZE; + $offset = max( 0, $offset ); + + $allowed_levels = array( 'run', 'peptide', 'intent', 'model' ); + if ( ! in_array( $level, $allowed_levels, true ) ) { + $level = 'run'; + } + + switch ( $level ) { + case 'peptide': + $group_col = 'peptide_slug'; + break; + case 'intent': + $group_col = 'intent_label'; + break; + case 'model': + $group_col = 'model'; + break; + default: + $group_col = 'run_id'; + } + + // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT {$group_col} AS group_key, + COUNT(*) AS calls, + COALESCE(SUM(cost_usd),0) AS total_cost, + MIN(captured_at) AS first_at, + MAX(captured_at) AS last_at + FROM {$table} + WHERE DATE_FORMAT(captured_at, %s) = %s + GROUP BY {$group_col} + ORDER BY total_cost DESC + LIMIT %d OFFSET %d", + '%Y-%m', + $month, + $page_size, + $offset + ), + ARRAY_A + ); + // phpcs:enable + + $result = array(); + foreach ( (array) $rows as $row ) { + $result[] = array( + 'group_key' => (string) $row['group_key'], + 'calls' => (int) $row['calls'], + 'total_cost' => (float) $row['total_cost'], + 'first_at' => (string) $row['first_at'], + 'last_at' => (string) $row['last_at'], + ); + } + return $result; + } + + /** + * Compute the linear month-end projection from MTD spend and days elapsed. + * + * Side effects: None. + * + * @param float $mtd_cost MTD cost in USD. + * + * @return float Projected full-month cost. + */ + public function project_month_end( float $mtd_cost ): float { + $days_in_month = (int) gmdate( 't' ); + $day_of_month = (int) gmdate( 'j' ); + + if ( $day_of_month < 1 ) { + return $mtd_cost; + } + + return $mtd_cost * ( $days_in_month / $day_of_month ); + } +} diff --git a/includes/core/class-prv-costs-page.php b/includes/core/class-prv-costs-page.php new file mode 100644 index 0000000..08f7b3f --- /dev/null +++ b/includes/core/class-prv-costs-page.php @@ -0,0 +1,102 @@ + Costs" sub-menu and renders the Costs page via + * PRV_Costs_Renderer. All data is read from PRV_Cost_Rollup_Query. + * + * Who triggers: PRV_Plugin::init() — is_admin() guard. + * Dependencies: PRV_Cost_Rollup_Query, PRV_Costs_Renderer, PRV_Config. + * + * @see class-prv-costs-renderer.php — HTML rendering. + * @see class-prv-cost-rollup-query.php — Query layer. + * @see class-prv-admin-page.php — Dashboard (parent menu slug). + * @package PrVision + */ +class PRV_Costs_Page { + + /** + * Admin menu slug. + */ + const MENU_SLUG = 'pr-vision-costs'; + + /** + * Register WordPress admin hooks. + * + * Side effects: Adds admin_menu action. + * + * @return void + */ + public function register_hooks(): void { + add_action( 'admin_menu', array( $this, 'add_submenu' ) ); + } + + /** + * Register the Costs sub-menu under PR Vision. + * + * Side effects: Adds a WP admin sub-menu entry. + * + * @return void + */ + public function add_submenu(): void { + add_submenu_page( + PRV_Admin_Page::MENU_SLUG, + __( 'PR Vision — Costs', 'pr-vision' ), + __( 'Costs', 'pr-vision' ), + 'manage_options', + self::MENU_SLUG, + array( $this, 'render_page' ) + ); + } + + /** + * Render the full Costs page. + * + * Side effects: Outputs HTML. + * + * @return void + */ + public function render_page(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'Insufficient permissions.', 'pr-vision' ) ); + } + + $query = new PRV_Cost_Rollup_Query(); + $summary = $query->get_mtd_summary(); + $models = $query->get_model_breakdown(); + + // Drill-down parameters. + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $level = isset( $_GET['prv_level'] ) ? sanitize_text_field( wp_unslash( $_GET['prv_level'] ) ) : 'run'; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $offset = isset( $_GET['prv_offset'] ) ? absint( wp_unslash( $_GET['prv_offset'] ) ) : 0; + $drill = $query->get_drill_down( $level, $offset ); + + $cap = PRV_Config::get_monthly_budget_usd(); + $mtd_cost = $summary['total_cost']; + $projected = $query->project_month_end( $mtd_cost ); + + $renderer = new PRV_Costs_Renderer(); + $renderer->render( + array( + 'mtd_cost' => $mtd_cost, + 'total_calls' => $summary['total_calls'], + 'avg_cost' => $summary['avg_cost'], + 'cap' => $cap, + 'projected' => $projected, + 'models' => $models, + 'drill' => $drill, + 'level' => $level, + 'offset' => $offset, + ) + ); + } +} diff --git a/includes/core/class-prv-costs-renderer.php b/includes/core/class-prv-costs-renderer.php new file mode 100644 index 0000000..52a9d94 --- /dev/null +++ b/includes/core/class-prv-costs-renderer.php @@ -0,0 +1,297 @@ + $data Keys: mtd_cost, total_calls, avg_cost, cap, + * projected, models, drill, level, offset. + * + * @return void + */ + public function render( array $data ): void { + $mtd_cost = (float) $data['mtd_cost']; + $total_calls = (int) $data['total_calls']; + $avg_cost = (float) $data['avg_cost']; + $cap = (float) $data['cap']; + $projected = (float) $data['projected']; + $models = (array) $data['models']; + $drill = (array) $data['drill']; + $level = (string) $data['level']; + $offset = (int) $data['offset']; + + $meter_pct = $cap > 0 ? min( 100, round( ( $mtd_cost / $cap ) * 100 ) ) : 0; + $meter_class = $meter_pct >= 90 ? ' prv-meter--capped' : ''; + $projected_warn = $projected > $cap; + + echo '
'; + $this->render_subnav( 'costs' ); + + echo '
'; + echo '

' . esc_html__( 'Cost Accountability', 'pr-vision' ) . '

'; + echo '

' . esc_html__( 'Per-call spend against the $5.00 monthly cap.', 'pr-vision' ) . '

'; + echo '
'; + + // Cap progress card. + echo '
'; + echo '

' . esc_html__( 'Budget', 'pr-vision' ) . '

'; + echo '
'; + echo '
'; + echo '
'; + echo '$' . esc_html( number_format( $mtd_cost, 2 ) ) . ''; + echo ' ' . esc_html( sprintf( /* translators: %s: cap amount */ __( 'of $%s', 'pr-vision' ), number_format( $cap, 2 ) ) ) . ''; + echo '
'; + echo '
'; + $proj_class = $projected_warn ? ' style="color:#FF9D4D"' : ''; + echo '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static string + echo '' . esc_html__( 'Linear estimate', 'pr-vision' ) . ''; + echo '$' . esc_html( number_format( $projected, 2 ) ) . ''; + echo '
'; + $days_left = (int) gmdate( 't' ) - (int) gmdate( 'j' ); + echo '
' . esc_html( sprintf( /* translators: %d: days */ __( '%d days remaining', 'pr-vision' ), $days_left ) ) . '
'; + echo '
'; + echo '
'; + echo '
'; + echo ''; + echo '
'; + if ( $total_calls > 0 ) { + echo '

'; + echo esc_html( + sprintf( + /* translators: 1: cost, 2: call count */ + __( '$%1$s recorded across %2$d calls this month — approximately reconciles to the Dashboard MTD total.', 'pr-vision' ), + number_format( $mtd_cost, 4 ), + $total_calls + ) + ); + echo '

'; + } + echo '
'; + + // Summary bento. + echo '
'; + $this->render_bento_tile( __( 'Total calls', 'pr-vision' ), (string) $total_calls, __( 'this month', 'pr-vision' ) ); + $this->render_bento_tile( __( 'Avg cost / call', 'pr-vision' ), '$' . number_format( $avg_cost, 4 ), '' ); + $top_model = ! empty( $models ) ? $models[0]['model'] . ' ($' . number_format( $models[0]['mtd_cost'], 4 ) . ')' : __( '—', 'pr-vision' ); + $this->render_bento_tile( __( 'Most expensive model', 'pr-vision' ), $top_model, '' ); + echo '
'; + + // Model breakdown table. + $this->render_model_table( $models ); + + // Drill-down card. + $this->render_drill_down( $drill, $level, $offset ); + + echo '
'; // .wrap + } + + /** + * Render the subnav tab strip (shared across all PR Vision pages). + * + * Side effects: Outputs HTML. + * + * @param string $active Active tab key: dashboard|costs|calls|settings. + * + * @return void + */ + public function render_subnav( string $active ): void { + $tabs = array( + 'dashboard' => array( + 'label' => __( 'Dashboard', 'pr-vision' ), + 'slug' => 'pr-vision', + ), + 'costs' => array( + 'label' => __( 'Costs', 'pr-vision' ), + 'slug' => 'pr-vision-costs', + ), + 'calls' => array( + 'label' => __( 'Call Log', 'pr-vision' ), + 'slug' => 'pr-vision-calls', + ), + 'settings' => array( + 'label' => __( 'Settings', 'pr-vision' ), + 'slug' => 'pr-vision-settings', + ), + ); + echo ''; + } + + /** + * Render a single bento tile. + * + * Side effects: Outputs HTML. + * + * @param string $label Label text. + * @param string $value Primary value. + * @param string $sub Sub-label (empty to omit). + * + * @return void + */ + private function render_bento_tile( string $label, string $value, string $sub ): void { + echo '
'; + echo '
' . esc_html( $label ) . '
'; + echo '
' . esc_html( $value ) . '
'; + if ( '' !== $sub ) { + echo '
' . esc_html( $sub ) . '
'; + } + echo '
'; + } + + /** + * Render the per-model cost breakdown table. + * + * Side effects: Outputs HTML. + * + * @param array> $models Model rows. + * + * @return void + */ + private function render_model_table( array $models ): void { + echo '

'; + echo esc_html__( 'By Model', 'pr-vision' ); + echo '

'; + + if ( empty( $models ) ) { + echo '

' . esc_html__( 'No cost data yet. Run a probe to begin recording per-call costs.', 'pr-vision' ) . '

'; + echo '
'; + return; + } + + echo '
'; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + + foreach ( $models as $row ) { + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + + echo '
' . esc_html__( 'Model', 'pr-vision' ) . '' . esc_html__( 'Calls', 'pr-vision' ) . '' . esc_html__( 'MTD Cost', 'pr-vision' ) . '' . esc_html__( 'Cost / Call', 'pr-vision' ) . '
' . esc_html( (string) $row['model'] ) . '' . esc_html( (string) (int) $row['calls'] ) . '$' . esc_html( number_format( (float) $row['mtd_cost'], 6 ) ) . '$' . esc_html( number_format( (float) $row['cost_per_call'], 6 ) ) . '
'; + echo ''; + } + + /** + * Render the drill-down card with level picker and results table. + * + * Side effects: Outputs HTML. + * + * @param array> $drill Drill-down rows. + * @param string $level Current aggregation level. + * @param int $offset Current pagination offset. + * + * @return void + */ + private function render_drill_down( array $drill, string $level, int $offset ): void { + echo '

'; + echo esc_html__( 'Cost Drill-Down', 'pr-vision' ); + echo '

'; + + $levels = array( 'run', 'peptide', 'intent', 'model' ); + $base_url = admin_url( 'admin.php?page=pr-vision-costs' ); + + echo '
'; + foreach ( $levels as $lvl ) { + $is_active = ( $lvl === $level ); + $cls = 'prv-seg-btn' . ( $is_active ? ' prv-seg-btn--active' : '' ); + $url = add_query_arg( + array( + 'prv_level' => $lvl, + 'prv_offset' => 0, + ), + $base_url + ); + echo ''; + echo esc_html( ucfirst( $lvl ) ); + echo ''; + } + echo '
'; + + if ( empty( $drill ) ) { + echo '

' . esc_html__( 'No data for this level.', 'pr-vision' ) . '

'; + echo '
'; + return; + } + + echo '
'; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + + foreach ( $drill as $row ) { + $filter_url = add_query_arg( + array( $this->level_to_filter_key( $level ) => rawurlencode( (string) $row['group_key'] ) ), + admin_url( 'admin.php?page=pr-vision-calls' ) + ); + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + echo '
' . esc_html( ucfirst( $level ) ) . '' . esc_html__( 'Calls', 'pr-vision' ) . '' . esc_html__( 'Cost', 'pr-vision' ) . '' . esc_html__( 'View', 'pr-vision' ) . '
' . esc_html( (string) $row['group_key'] ) . '' . esc_html( (string) (int) $row['calls'] ) . '$' . esc_html( number_format( (float) $row['total_cost'], 6 ) ) . '' . esc_html__( 'View calls →', 'pr-vision' ) . '
'; + echo ''; + } + + /** + * Map drill-down level to call-log filter query param key. + * + * @param string $level Drill-down level: run|peptide|intent|model. + * + * @return string Filter parameter name. + */ + private function level_to_filter_key( string $level ): string { + $map = array( + 'run' => 'prv_filter_run', + 'peptide' => 'prv_filter_peptide', + 'intent' => 'prv_filter_intent', + 'model' => 'prv_filter_model', + ); + return $map[ $level ] ?? 'prv_filter_run'; + } +} diff --git a/includes/core/class-prv-deactivator.php b/includes/core/class-prv-deactivator.php index 1cb2ea8..eafda9d 100644 --- a/includes/core/class-prv-deactivator.php +++ b/includes/core/class-prv-deactivator.php @@ -17,6 +17,7 @@ declare(strict_types=1); * Dependencies: PRV_Cron. * * @see class-prv-cron.php — Cron registration + clearing. + * @see class-prv-prune-cron.php — Daily prune cron clear (v0.3.0). * @see uninstall.php — Full data purge on uninstall. * @package PrVision */ @@ -31,5 +32,7 @@ class PRV_Deactivator { */ public static function deactivate(): void { PRV_Cron::clear_schedule(); + // v0.3.0: clear the daily prune event. + PRV_Prune_Cron::clear_schedule(); } } diff --git a/includes/core/class-prv-plugin.php b/includes/core/class-prv-plugin.php index 7b3d248..e7e60bc 100644 --- a/includes/core/class-prv-plugin.php +++ b/includes/core/class-prv-plugin.php @@ -16,10 +16,14 @@ declare(strict_types=1); * * Who triggers: plugins_loaded action (pr-vision.php). * Dependencies: PRV_Upgrader, PRV_Cron, PRV_Admin_Page, PRV_Settings_Page, - * PRV_Collector_Registry. + * PRV_Settings_Controller, PRV_Costs_Page, PRV_Call_Log_Page, + * PRV_Call_Detail_Ajax, PRV_Prune_Cron, PRV_Collector_Registry. * - * @see ARCHITECTURE.md -- Boot sequence diagram. - * @see class-prv-upgrader.php -- Runs migrations on every boot. + * @see ARCHITECTURE.md -- Boot sequence diagram. + * @see class-prv-upgrader.php -- Runs migrations on every boot. + * @see class-prv-costs-page.php -- [v0.3.0] Costs admin sub-page. + * @see class-prv-call-log-page.php -- [v0.3.0] Call Log admin sub-page. + * @see class-prv-prune-cron.php -- [v0.3.0] Daily prune cron. * @package PrVision */ class PRV_Plugin { @@ -43,12 +47,24 @@ class PRV_Plugin { $cron = new PRV_Cron(); $cron->register_hooks(); + $prune = new PRV_Prune_Cron(); + $prune->register_hooks(); + if ( is_admin() ) { $page = new PRV_Admin_Page(); $page->register_hooks(); $settings = new PRV_Settings_Page(); $settings->register_hooks(); + + $costs = new PRV_Costs_Page(); + $costs->register_hooks(); + + $call_log = new PRV_Call_Log_Page(); + $call_log->register_hooks(); + + $ajax = new PRV_Call_Detail_Ajax(); + $ajax->register_hooks(); } // Register the v1 AI-visibility collector + panel. diff --git a/includes/core/class-prv-probe-result.php b/includes/core/class-prv-probe-result.php index 9ba7acb..f6149a8 100644 --- a/includes/core/class-prv-probe-result.php +++ b/includes/core/class-prv-probe-result.php @@ -13,6 +13,8 @@ declare(strict_types=1); * Returned by every PRV_Probe_Provider implementation. Immutable — all * fields are set in the constructor and read via typed getters. * + * v0.3.0 adds tokens_in/tokens_out for per-call cost attribution. + * * Who triggers: PRV_Perplexity_Provider and PRV_OpenRouter_Provider. * Dependencies: None. * @@ -31,13 +33,17 @@ class PRV_Probe_Result { * @param bool $cited Whether peptiderepo.com appears in source_domains. * @param int|null $our_position 1-based position of peptiderepo.com in sources, or null. * @param float $cost_usd Estimated call cost in USD. + * @param int|null $tokens_in Input token count from the API response (null if unknown). + * @param int|null $tokens_out Output token count from the API response (null if unknown). */ public function __construct( private readonly string $raw_excerpt, private readonly array $source_domains, private readonly bool $cited, private readonly ?int $our_position, - private readonly float $cost_usd + private readonly float $cost_usd, + private readonly ?int $tokens_in = null, + private readonly ?int $tokens_out = null ) {} /** @@ -84,4 +90,22 @@ class PRV_Probe_Result { public function get_cost_usd(): float { return $this->cost_usd; } + + /** + * Get the input token count for this call (null if not reported by provider). + * + * @return int|null + */ + public function get_tokens_in(): ?int { + return $this->tokens_in; + } + + /** + * Get the output token count for this call (null if not reported by provider). + * + * @return int|null + */ + public function get_tokens_out(): ?int { + return $this->tokens_out; + } } diff --git a/includes/core/class-prv-probe-run-executor.php b/includes/core/class-prv-probe-run-executor.php new file mode 100644 index 0000000..16c5593 --- /dev/null +++ b/includes/core/class-prv-probe-run-executor.php @@ -0,0 +1,300 @@ +ledger = $ledger; + $this->capture = $capture; + } + + /** + * Run the full peptide × intent × model loop. + * + * @param string $run_id UUID for this run. + * @param int $config_ver Config version. + * @return array{probed: int, skipped_budget: int, skipped_error: int, truncated: bool, run_id: string} + */ + public function execute( + string $run_id, + int $config_ver + ): array { + $peptides = PRV_Config::get_peptides(); + $intents = PRV_Config::get_prompt_intents(); + $models = PRV_Config::get_models(); + $model_outcomes = array(); + + foreach ( $models as $slug ) { + // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound + $model_outcomes[ $slug ] = array( 'probed' => 0, 'errors' => 0 ); + } + + $counts = array( + 'probed' => 0, + 'skipped_budget' => 0, + 'skipped_error' => 0, + 'truncated' => false, + 'run_id' => $run_id, + ); + + $budget_hit = false; + + foreach ( $peptides as $peptide ) { + foreach ( $intents as $intent_tpl ) { + $query = str_replace( '{peptide}', $peptide['label'], $intent_tpl ); + foreach ( $models as $model ) { + $this->probe_one( $run_id, $peptide, $model, $intent_tpl, $query, $config_ver, $counts, $model_outcomes, $budget_hit ); + } + } + } + + if ( $budget_hit && $counts['probed'] > 0 ) { + $counts['truncated'] = true; + } + + update_option( 'prv_last_run_truncated', $counts['truncated'] ? 1 : 0 ); + if ( $counts['truncated'] ) { + update_option( 'prv_last_run_truncated_at', current_time( 'mysql', true ) ); + } + + update_option( 'prv_last_run_at', current_time( 'mysql', true ) ); + update_option( 'prv_last_run_counts', $counts ); + + PRV_Model_Registry::update_health( $run_id, $model_outcomes ); + $this->settle_api_key_status( $counts, $model_outcomes ); + + return $counts; + } + + /** + * Execute one probe combination: can_afford → probe → persist → capture (P0 order). + * + * @param string $run_id Run UUID. + * @param array $peptide Peptide config. + * @param string $model Model slug. + * @param string $intent_tpl Intent template. + * @param string $query Rendered query string. + * @param int $config_ver Config version. + * @param array &$counts Counts accumulator. + * @param array &$model_outcomes Per-model outcome map. + * @param bool &$budget_hit Budget exhaustion flag. + * @return void + */ + private function probe_one( + string $run_id, + array $peptide, + string $model, + string $intent_tpl, + string $query, + int $config_ver, + array &$counts, + array &$model_outcomes, + bool &$budget_hit + ): void { + $provider = $this->resolve_provider( $model ); + + if ( null === $provider || ! $provider->is_configured() ) { + ++$counts['skipped_error']; + if ( isset( $model_outcomes[ $model ] ) ) { + ++$model_outcomes[ $model ]['errors']; + } + return; + } + + $estimated = $this->get_estimated_cost( $model ); + if ( ! $this->ledger->can_afford( $estimated ) ) { + ++$counts['skipped_budget']; + $budget_hit = true; + return; + } + + $start_ms = (int) round( microtime( true ) * 1000 ); + + try { + $result = $provider->probe( $query ); + $latency_ms = (int) round( microtime( true ) * 1000 ) - $start_ms; + $http_status = 200; + } catch ( \Exception $e ) { + $latency_ms = (int) round( microtime( true ) * 1000 ) - $start_ms; + $http_status = $this->extract_http_status( $e ); + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( sprintf( 'PRV: probe failed [%s / %s]: %s', $model, $peptide['slug'], $e->getMessage() ) ); + ++$counts['skipped_error']; + if ( isset( $model_outcomes[ $model ] ) ) { + ++$model_outcomes[ $model ]['errors']; + } + // phpcs:disable WordPress.Arrays.ArrayDeclarationSpacing.ArrayItemNoNewLine, Universal.WhiteSpace.CommaSpacing.TooMuchSpaceAfter + $cid = $this->capture->write_meta( + array( + 'visibility_row' => null, 'run_id' => $run_id, + 'peptide_slug' => $peptide['slug'], 'model' => $model, + 'intent_label' => $intent_tpl, 'tokens_in' => null, + 'tokens_out' => null, 'cost_usd' => 0.0, + 'latency_ms' => $latency_ms, 'cited' => null, + 'http_status' => $http_status, 'config_version' => $config_ver, + ) + ); // phpcs:enable + if ( $cid > 0 ) { + try { + $this->capture->write_io( $cid, $query, '' ); + } catch ( \Throwable $e2 ) { + // Best-effort: I/O capture failure must not propagate (P0-4). + unset( $e2 ); + } + } + return; + } + + $row_id = $this->persist_result( $run_id, $peptide, $model, $intent_tpl, $result, $config_ver ); + if ( $row_id > 0 ) { + $this->ledger->update_row_cost( $row_id, $result->get_cost_usd() ); + } + + // phpcs:disable WordPress.Arrays.ArrayDeclarationSpacing.ArrayItemNoNewLine, Universal.WhiteSpace.CommaSpacing.TooMuchSpaceAfter + $cid = $this->capture->write_meta( + array( + 'visibility_row' => $row_id > 0 ? $row_id : null, 'run_id' => $run_id, + 'peptide_slug' => $peptide['slug'], 'model' => $model, + 'intent_label' => $intent_tpl, 'tokens_in' => $result->get_tokens_in(), + 'tokens_out' => $result->get_tokens_out(), 'cost_usd' => $result->get_cost_usd(), + 'latency_ms' => $latency_ms, 'cited' => $result->is_cited() ? 1 : 0, + 'http_status' => $http_status, 'config_version' => $config_ver, + ) + ); // phpcs:enable + if ( $cid > 0 ) { + try { + $this->capture->write_io( $cid, $query, $result->get_raw_excerpt() ); + } catch ( \Throwable $e2 ) { + // Best-effort: I/O capture failure must not propagate (P0-4). + unset( $e2 ); + } + } + + ++$counts['probed']; + if ( isset( $model_outcomes[ $model ] ) ) { + ++$model_outcomes[ $model ]['probed']; + } + } + + /** + * Persist one probe result to prv_ai_visibility. + * + * @param string $run_id Run UUID. + * @param array $peptide Peptide config. + * @param string $model Model slug. + * @param string $intent_tpl Intent template. + * @param PRV_Probe_Result $result Probe result. + * @param int $config_ver Config version. + * @return int Row ID, or 0 on failure. + */ + private function persist_result( string $run_id, array $peptide, string $model, string $intent_tpl, PRV_Probe_Result $result, int $config_ver ): int { + global $wpdb; + $table = PRV_Table_Manager::get_table_name(); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->insert( + $table, + array( + 'run_id' => $run_id, + 'captured_at' => current_time( 'mysql', true ), + 'peptide_slug' => $peptide['slug'], + 'peptide_label' => $peptide['label'], + 'model' => $model, + 'prompt_intent' => $intent_tpl, + 'cited' => $result->is_cited() ? 1 : 0, + 'our_position' => $result->get_our_position(), + 'source_domains' => wp_json_encode( $result->get_source_domains() ), + 'raw_excerpt' => $result->get_raw_excerpt(), + 'cost_usd' => number_format( $result->get_cost_usd(), 8, '.', '' ), + 'config_version' => $config_ver, + ), + array( '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%d' ) + ); + return (int) $wpdb->insert_id; + } + + /** + * Extract HTTP status from a gateway exception message. + * + * @param \Exception $e Caught exception. + * @return int HTTP status, or 0 if unknown. + */ + private function extract_http_status( \Exception $e ): int { + if ( preg_match( '/PR Vision gateway HTTP (\d+)/', $e->getMessage(), $m ) ) { + return (int) $m[1]; + } + return 0; + } + + /** + * Resolve provider instance for a model slug. + * + * @param string $model Model slug. + * @return PRV_Probe_Provider|null Provider, or null if not configured. + */ + private function resolve_provider( string $model ): ?PRV_Probe_Provider { + if ( 'perplexity/sonar' === $model ) { + return new PRV_Perplexity_Provider(); + } + return new PRV_OpenRouter_Provider( $model ); + } + + /** + * Conservative per-call cost estimate for budget pre-check. + * + * @param string $model Model slug. + * @return float Estimated USD per call. + */ + private function get_estimated_cost( string $model ): float { + if ( 'perplexity/sonar' === $model ) { + return PRV_Perplexity_Provider::ESTIMATED_COST_PER_PROBE; + } + return PRV_OpenRouter_Provider::ESTIMATED_COST_PER_PROBE; + } + + /** + * Write the API-key status option based on run outcomes. + * + * @param array $counts Run counts. + * @param array $model_outcomes Model outcomes. + * @return void + */ + private function settle_api_key_status( array $counts, array $model_outcomes ): void { + if ( $counts['probed'] > 0 ) { + update_option( 'prv_api_key_status', 'ok' ); + } elseif ( $counts['skipped_error'] > 0 && 0 === $counts['probed'] ) { + update_option( 'prv_api_key_status', 'failed' ); + } + update_option( 'prv_api_key_last_check', current_time( 'mysql', true ) ); + } +} diff --git a/includes/core/class-prv-probe-runner.php b/includes/core/class-prv-probe-runner.php index 34e407a..823d416 100644 --- a/includes/core/class-prv-probe-runner.php +++ b/includes/core/class-prv-probe-runner.php @@ -1,6 +1,6 @@ 300-line obligation by the executor split. + * + * Capture ordering (P0) is enforced in PRV_Probe_Run_Executor: + * can_afford → probe → persist_result → update_row_cost → write_meta → capture_io * * Who triggers: PRV_Cron::handle_cron_tick() and PRV_Settings_Page (Run now). - * Dependencies: PRV_Config, PRV_Cost_Ledger, PRV_Table_Manager, - * PRV_Run_Lock, PRV_Model_Registry, PRV_Config_Version, all providers. + * Dependencies: PRV_Cost_Ledger, PRV_Capture_Writer, PRV_Run_Lock, + * PRV_Config_Version, PRV_Probe_Run_Executor. * - * @see class-prv-cron.php -- Calls run() on the weekly schedule. - * @see class-prv-cost-ledger.php -- Budget cap enforcement. - * @see class-prv-run-lock.php -- Concurrency guard. - * @see class-prv-model-registry.php -- Per-model health updates. - * @see ARCHITECTURE.md -- Section Probe run flow. + * @see class-prv-probe-run-executor.php — Inner loop implementation. + * @see class-prv-cost-ledger.php — Budget cap enforcement. + * @see class-prv-run-lock.php — Concurrency guard. + * @see class-prv-capture-writer.php — Per-call metadata + I/O writer. + * @see ARCHITECTURE.md — Section Probe run flow v0.3.0. * @package PrVision */ class PRV_Probe_Runner { @@ -38,27 +37,33 @@ class PRV_Probe_Runner { */ private PRV_Cost_Ledger $ledger; + /** + * Per-call capture writer (best-effort; never in critical path). + * + * @var PRV_Capture_Writer + */ + private PRV_Capture_Writer $capture; + /** * Constructor. * - * @param PRV_Cost_Ledger|null $ledger Injected for testing; auto-created otherwise. + * @param PRV_Cost_Ledger|null $ledger Injected for testing; auto-created otherwise. + * @param PRV_Capture_Writer|null $capture Injected for testing; auto-created otherwise. */ - public function __construct( ?PRV_Cost_Ledger $ledger = null ) { - $this->ledger = $ledger ?? new PRV_Cost_Ledger(); + public function __construct( ?PRV_Cost_Ledger $ledger = null, ?PRV_Capture_Writer $capture = null ) { + $this->ledger = $ledger ?? new PRV_Cost_Ledger(); + $this->capture = $capture ?? new PRV_Capture_Writer(); } /** * Execute the full probe run. * * Acquires a run-lock so the cron and "Run now" cannot collide. - * Iterates peptides x prompt intents x models. Aborts at the budget cap - * without throwing -- partial results are kept. Each successful probe is - * persisted immediately so a mid-run failure doesn't lose data. + * Returns early with sentinel -1 in skipped_error when the lock is busy. * - * Side effects: HTTP calls to LLM APIs, database writes, option writes (health). + * Side effects: HTTP calls, database writes, option writes. * * @return array{probed: int, skipped_budget: int, skipped_error: int, truncated: bool, run_id: string} - * Summary counts plus truncation flag for the admin display. */ public function run(): array { $counts = array( @@ -84,199 +89,21 @@ class PRV_Probe_Runner { } /** - * Core run logic (called inside the lock). + * Core run logic executed inside the lock. + * + * Generates a run UUID, stamps the config version, then hands off to + * PRV_Probe_Run_Executor which owns the inner loop. + * + * Side effects: Same as PRV_Probe_Run_Executor::execute(). * * @return array{probed: int, skipped_budget: int, skipped_error: int, truncated: bool, run_id: string} */ private function execute_run(): array { - $run_id = $this->generate_run_id(); - $config_ver = PRV_Config_Version::get_active_version(); - $peptides = PRV_Config::get_peptides(); - $intents = PRV_Config::get_prompt_intents(); - $models = PRV_Config::get_models(); - $model_outcomes = array(); // Per-slug: {probed, errors}. + $run_id = $this->generate_run_id(); + $config_ver = PRV_Config_Version::get_active_version(); - foreach ( $models as $slug ) { - $model_outcomes[ $slug ] = array( - 'probed' => 0, - 'errors' => 0, - ); - } - - $counts = array( - 'probed' => 0, - 'skipped_budget' => 0, - 'skipped_error' => 0, - 'truncated' => false, - 'run_id' => $run_id, - ); - - $budget_hit = false; - - foreach ( $peptides as $peptide ) { - foreach ( $intents as $intent_tpl ) { - $query = str_replace( '{peptide}', $peptide['label'], $intent_tpl ); - - foreach ( $models as $model ) { - $provider = $this->resolve_provider( $model ); - - if ( null === $provider || ! $provider->is_configured() ) { - ++$counts['skipped_error']; - if ( isset( $model_outcomes[ $model ] ) ) { - ++$model_outcomes[ $model ]['errors']; - } - continue; - } - - // Budget pre-check. - $estimated = $this->get_estimated_cost( $model ); - if ( ! $this->ledger->can_afford( $estimated ) ) { - ++$counts['skipped_budget']; - $budget_hit = true; - continue; - } - - try { - $result = $provider->probe( $query ); - } catch ( \Exception $e ) { - // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log - error_log( sprintf( 'PRV: probe failed [%s / %s]: %s', $model, $peptide['slug'], $e->getMessage() ) ); - ++$counts['skipped_error']; - if ( isset( $model_outcomes[ $model ] ) ) { - ++$model_outcomes[ $model ]['errors']; - } - continue; - } - - $row_id = $this->persist_result( $run_id, $peptide, $model, $intent_tpl, $result, $config_ver ); - if ( $row_id > 0 ) { - $this->ledger->update_row_cost( $row_id, $result->get_cost_usd() ); - } - - ++$counts['probed']; - if ( isset( $model_outcomes[ $model ] ) ) { - ++$model_outcomes[ $model ]['probed']; - } - } - } - } - - if ( $budget_hit && $counts['probed'] > 0 ) { - $counts['truncated'] = true; - } - - // Record truncation state for dashboard display. - if ( $counts['truncated'] ) { - update_option( 'prv_last_run_truncated', 1 ); - update_option( 'prv_last_run_truncated_at', current_time( 'mysql', true ) ); - } else { - update_option( 'prv_last_run_truncated', 0 ); - } - - // Persist run summary for dashboard. - update_option( 'prv_last_run_at', current_time( 'mysql', true ) ); - update_option( 'prv_last_run_counts', $counts ); - - // Update per-model run-health. - PRV_Model_Registry::update_health( $run_id, $model_outcomes ); - - // Update API-key status (derive from whether probes succeeded). - $this->update_api_key_status( $counts, $model_outcomes ); - - return $counts; - } - - /** - * Persist a single probe result to the database with config_version stamp. - * - * @param string $run_id UUID for this run. - * @param array $peptide Peptide config with slug and label. - * @param string $model Model identifier. - * @param string $intent_tpl Raw intent template string. - * @param PRV_Probe_Result $result Probe result. - * @param int $config_ver Config version at run time. - * - * @return int Inserted row ID, or 0 on failure. - */ - private function persist_result( - string $run_id, - array $peptide, - string $model, - string $intent_tpl, - PRV_Probe_Result $result, - int $config_ver - ): int { - global $wpdb; - - $table = PRV_Table_Manager::get_table_name(); - - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery - $wpdb->insert( - $table, - array( - 'run_id' => $run_id, - 'captured_at' => current_time( 'mysql', true ), - 'peptide_slug' => $peptide['slug'], - 'peptide_label' => $peptide['label'], - 'model' => $model, - 'prompt_intent' => $intent_tpl, - 'cited' => $result->is_cited() ? 1 : 0, - 'our_position' => $result->get_our_position(), - 'source_domains' => wp_json_encode( $result->get_source_domains() ), - 'raw_excerpt' => $result->get_raw_excerpt(), - 'cost_usd' => number_format( $result->get_cost_usd(), 8, '.', '' ), - 'config_version' => $config_ver, - ), - array( '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%d' ) - ); - - return (int) $wpdb->insert_id; - } - - /** - * Derive + persist the API-key status from run outcomes. - * - * @param array $counts Run counts. - * @param array $model_outcomes Model outcomes. - * - * @return void - */ - private function update_api_key_status( array $counts, array $model_outcomes ): void { - if ( $counts['probed'] > 0 ) { - update_option( 'prv_api_key_status', 'ok' ); - } elseif ( $counts['skipped_error'] > 0 && 0 === $counts['probed'] ) { - update_option( 'prv_api_key_status', 'failed' ); - } - update_option( 'prv_api_key_last_check', current_time( 'mysql', true ) ); - } - - /** - * Instantiate the correct provider class for a model string. - * - * @param string $model OpenRouter model identifier. - * - * @return PRV_Probe_Provider|null Null when the model is unrecognised. - */ - private function resolve_provider( string $model ): ?PRV_Probe_Provider { - if ( 'perplexity/sonar' === $model ) { - return new PRV_Perplexity_Provider(); - } - // All other models route through the generic OpenRouter provider. - return new PRV_OpenRouter_Provider( $model ); - } - - /** - * Conservative per-call cost estimate used for the budget pre-check. - * - * @param string $model Model identifier. - * - * @return float Estimated USD. - */ - private function get_estimated_cost( string $model ): float { - if ( 'perplexity/sonar' === $model ) { - return PRV_Perplexity_Provider::ESTIMATED_COST_PER_PROBE; - } - return PRV_OpenRouter_Provider::ESTIMATED_COST_PER_PROBE; + $executor = new PRV_Probe_Run_Executor( $this->ledger, $this->capture ); + return $executor->execute( $run_id, $config_ver ); } /** diff --git a/includes/core/class-prv-prune-cron.php b/includes/core/class-prv-prune-cron.php new file mode 100644 index 0000000..6a4e4e5 --- /dev/null +++ b/includes/core/class-prv-prune-cron.php @@ -0,0 +1,118 @@ +query( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + "DELETE FROM {$table} WHERE captured_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY)", + $days + ) + ); + + return (int) $deleted; + } + + /** + * Schedule the daily prune event. + * + * Safe to call on re-activation — no-op when already scheduled. + * Side effects: Adds a WP-Cron daily event. + * + * @return void + */ + public static function schedule(): void { + if ( ! wp_next_scheduled( self::PRUNE_HOOK ) ) { + wp_schedule_event( time(), 'daily', self::PRUNE_HOOK ); + } + } + + /** + * Remove the prune cron schedule. + * + * Called on deactivation and uninstall. + * Side effects: Removes the WP-Cron event. + * + * @return void + */ + public static function clear_schedule(): void { + $timestamp = wp_next_scheduled( self::PRUNE_HOOK ); + if ( false !== $timestamp ) { + wp_unschedule_event( $timestamp, self::PRUNE_HOOK ); + } + wp_clear_scheduled_hook( self::PRUNE_HOOK ); + } +} diff --git a/includes/core/class-prv-settings-controller.php b/includes/core/class-prv-settings-controller.php index 9352d38..6e5edca 100644 --- a/includes/core/class-prv-settings-controller.php +++ b/includes/core/class-prv-settings-controller.php @@ -76,6 +76,11 @@ class PRV_Settings_Controller { ? max( 0.01, (float) sanitize_text_field( wp_unslash( (string) $_POST['prv_monthly_budget_usd'] ) ) ) : PRV_DEFAULT_MONTHLY_BUDGET_USD; update_option( 'prv_monthly_budget_usd', $budget ); + // v0.3.0: I/O retention days. + $retention_days = isset( $_POST['prv_io_retention_days'] ) + ? max( 1, absint( sanitize_text_field( wp_unslash( (string) $_POST['prv_io_retention_days'] ) ) ) ) + : PRV_IO_RETENTION_DEFAULT_DAYS; + update_option( 'prv_io_retention_days', $retention_days ); $cadence_raw = isset( $_POST['prv_cadence'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['prv_cadence'] ) ) diff --git a/includes/core/class-prv-settings-renderer.php b/includes/core/class-prv-settings-renderer.php index 6083d3c..ff45c4f 100644 --- a/includes/core/class-prv-settings-renderer.php +++ b/includes/core/class-prv-settings-renderer.php @@ -38,6 +38,7 @@ class PRV_Settings_Renderer { $this->render_styles(); echo '
'; echo '

' . esc_html__( 'PR Vision — Settings', 'pr-vision' ) . '

'; + ( new PRV_Costs_Renderer() )->render_subnav( 'settings' ); $this->render_notices(); echo '

' . esc_html__( 'Model Manager', 'pr-vision' ) . '

'; ( new PRV_Model_Manager_Table() )->render(); @@ -202,6 +203,11 @@ class PRV_Settings_Renderer { $budget = PRV_Config::get_monthly_budget_usd(); echo '
'; echo '
'; + // v0.3.0: I/O retention days. + $retention = PRV_Config::get_io_retention_days(); + echo '
'; + echo ''; + echo '' . esc_html__( 'Prompt + response text is auto-deleted after this many days. Cost and metadata are kept indefinitely.', 'pr-vision' ) . '
'; } /** diff --git a/includes/core/class-prv-upgrader.php b/includes/core/class-prv-upgrader.php index f46b713..72c5d01 100644 --- a/includes/core/class-prv-upgrader.php +++ b/includes/core/class-prv-upgrader.php @@ -20,6 +20,8 @@ declare(strict_types=1); * * @see class-prv-model-registry.php -- Migration v2 implementation. * @see class-prv-config-version.php -- Scoring-config versioning. + * @see class-prv-call-meta-table.php -- [v0.3.0] Per-call metadata table. + * @see class-prv-call-io-table.php -- [v0.3.0] Per-call I/O table (prunable). * @see class-prv-plugin.php -- Calls PRV_Upgrader::run(). * @package PrVision */ @@ -34,7 +36,12 @@ class PRV_Upgrader { */ public static function run(): void { PRV_Table_Manager::create_table(); + // v0.3.0: per-call cost/metadata + I/O tables. + PRV_Call_Meta_Table::create_table(); + PRV_Call_Io_Table::create_table(); PRV_Model_Registry::run_migration_v2(); PRV_Config_Version::maybe_seed_initial_version(); + // v0.3.0: seed retention default if not set. + add_option( 'prv_io_retention_days', PRV_IO_RETENTION_DEFAULT_DAYS ); } } diff --git a/includes/providers/class-prv-gateway-client.php b/includes/providers/class-prv-gateway-client.php index 4ba5042..0bf0440 100644 --- a/includes/providers/class-prv-gateway-client.php +++ b/includes/providers/class-prv-gateway-client.php @@ -33,6 +33,13 @@ declare(strict_types=1); */ class PRV_Gateway_Client { + /** + * HTTP status code from the most recent request (set on permanent error). + * + * @var int + */ + private int $last_http_status = 200; + /** * Base URL for the Cloudflare AI Gateway (without trailing slash). */ @@ -59,6 +66,9 @@ class PRV_Gateway_Client { * @return array Decoded JSON response body. * * @throws \RuntimeException On permanent HTTP failure or parse error. + * Exception message carries HTTP status code only; + * the response body is never included (P0: prevents + * secret-key leakage via 401 error responses). */ public function post_to_gateway( string $provider_slug, array $body, string $api_key, string $endpoint = '/chat/completions' ): array { $base_url = $this->build_base_url( $provider_slug ); @@ -110,8 +120,9 @@ class PRV_Gateway_Client { } if ( $status >= 400 ) { - // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output - throw new \RuntimeException( sprintf( 'PR Vision gateway HTTP %d: %s', $status, substr( $raw_body, 0, 300 ) ) ); + $this->last_http_status = $status; + // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output; body intentionally omitted (P0: prevents key-leak via 401 body) + throw new \RuntimeException( sprintf( 'PR Vision gateway HTTP %d', $status ) ); } $data = json_decode( $raw_body, true ); @@ -129,6 +140,18 @@ class PRV_Gateway_Client { throw new \RuntimeException( sprintf( 'PR Vision: gateway failed after %d attempts. Last: %s', PRV_MAX_RETRIES, $last_error ) ); } + /** + * Get the HTTP status code from the last failed request. + * + * Allows callers to record the HTTP status code in error records without + * parsing the exception message. Returns 200 when no failure has occurred. + * + * @return int HTTP status code. + */ + public function get_last_http_status(): int { + return $this->last_http_status; + } + /** * Build the API base URL (gateway or direct fallback). * diff --git a/includes/providers/class-prv-openrouter-provider.php b/includes/providers/class-prv-openrouter-provider.php index f3e4819..99e4fa8 100644 --- a/includes/providers/class-prv-openrouter-provider.php +++ b/includes/providers/class-prv-openrouter-provider.php @@ -164,7 +164,11 @@ class PRV_OpenRouter_Provider implements PRV_Probe_Provider { $cost_usd = (float) $data['usage']['total_tokens'] * 0.000002; } - return new PRV_Probe_Result( $raw_excerpt, $domains, $cited, $our_position, $cost_usd ); + // Token counts for per-call attribution. + $tokens_in = isset( $data['usage']['prompt_tokens'] ) ? (int) $data['usage']['prompt_tokens'] : null; + $tokens_out = isset( $data['usage']['completion_tokens'] ) ? (int) $data['usage']['completion_tokens'] : null; + + return new PRV_Probe_Result( $raw_excerpt, $domains, $cited, $our_position, $cost_usd, $tokens_in, $tokens_out ); } /** diff --git a/includes/providers/class-prv-perplexity-provider.php b/includes/providers/class-prv-perplexity-provider.php index 7fce671..fa41bff 100644 --- a/includes/providers/class-prv-perplexity-provider.php +++ b/includes/providers/class-prv-perplexity-provider.php @@ -133,7 +133,11 @@ class PRV_Perplexity_Provider implements PRV_Probe_Provider { $cost_usd = (float) $data['usage']['total_tokens'] * 0.000001; } - return new PRV_Probe_Result( $raw_excerpt, $domains, $cited, $our_position, $cost_usd ); + // Token counts for per-call attribution. + $tokens_in = isset( $data['usage']['prompt_tokens'] ) ? (int) $data['usage']['prompt_tokens'] : null; + $tokens_out = isset( $data['usage']['completion_tokens'] ) ? (int) $data['usage']['completion_tokens'] : null; + + return new PRV_Probe_Result( $raw_excerpt, $domains, $cited, $our_position, $cost_usd, $tokens_in, $tokens_out ); } /** diff --git a/pr-vision.php b/pr-vision.php index 94e266e..3dde6db 100644 --- a/pr-vision.php +++ b/pr-vision.php @@ -3,7 +3,7 @@ * Plugin Name: PR Vision * Plugin URI: https://peptiderepo.com * Description: Weekly server-side LLM probes for AI-visibility (GEO) tracking. Records whether peptiderepo.com is cited by LLMs across core peptides, stores time-series, and renders an admin dashboard + settings UI. - * Version: 0.2.3 + * Version: 0.3.0 * Author: peptiderepo * Author URI: https://peptiderepo.com * License: GPL-2.0-or-later @@ -24,11 +24,11 @@ if ( ! defined( 'ABSPATH' ) ) { /* ── Constants ────────────────────────────────────────────────────────── */ -define( 'PRV_VERSION', '0.2.3' ); +define( 'PRV_VERSION', '0.3.0' ); define( 'PRV_PLUGIN_FILE', __FILE__ ); define( 'PRV_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'PRV_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); -define( 'PRV_SCHEMA_VERSION', 2 ); +define( 'PRV_SCHEMA_VERSION', 3 ); /** Maximum HTTP retries for LLM API calls. @var int */ define( 'PRV_MAX_RETRIES', 3 ); @@ -47,6 +47,12 @@ define( 'PRV_CRON_HOOK', 'prv_weekly_probe' ); /** The site we are tracking citations for. @var string */ define( 'PRV_TARGET_DOMAIN', 'peptiderepo.com' ); +/** Default I/O retention window in days. @var int */ +define( 'PRV_IO_RETENTION_DEFAULT_DAYS', 90 ); + +/** WP-Cron hook name for the daily prune run. @var string */ +define( 'PRV_DAILY_PRUNE_HOOK', 'prv_daily_prune' ); + /* ── Autoloader ───────────────────────────────────────────────────────── */ diff --git a/tests/bootstrap.php b/tests/bootstrap.php index c9eaa1d..f3e5192 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -27,41 +27,51 @@ if ( ! defined( 'ARRAY_N' ) ) { define( 'ARRAY_N', 'ARRAY_N' ); } -define( 'PRV_VERSION', '0.2.3' ); +define( 'PRV_VERSION', '0.3.0' ); define( 'PRV_PLUGIN_FILE', __DIR__ . '/../pr-vision.php' ); define( 'PRV_PLUGIN_DIR', realpath( __DIR__ . '/..' ) . '/' ); define( 'PRV_PLUGIN_URL', 'http://example.test/wp-content/plugins/pr-vision/' ); -define( 'PRV_SCHEMA_VERSION', 2 ); +define( 'PRV_SCHEMA_VERSION', 3 ); define( 'PRV_MAX_RETRIES', 3 ); define( 'PRV_API_TIMEOUT_SECONDS', 60 ); define( 'PRV_RETRY_BASE_DELAY_SECONDS', 2 ); define( 'PRV_DEFAULT_MONTHLY_BUDGET_USD', 5.0 ); define( 'PRV_CRON_HOOK', 'prv_weekly_probe' ); define( 'PRV_TARGET_DOMAIN', 'peptiderepo.com' ); +define( 'PRV_IO_RETENTION_DEFAULT_DAYS', 90 ); +define( 'PRV_DAILY_PRUNE_HOOK', 'prv_daily_prune' ); /* ── Global test state ─────────────────────────────────────────────────── */ $GLOBALS['prv_test_state'] = [ - 'options' => [], - 'transients' => [], - 'actions' => [], - 'wpdb_insert_id' => 1, - 'wpdb_results' => [], - 'wpdb_var' => null, - 'cron_events' => [], - 'remote_posts' => [], + 'options' => [], + 'transients' => [], + 'actions' => [], + 'wpdb_insert_id' => 1, + 'wpdb_results' => [], + 'wpdb_var' => null, + 'wpdb_row' => null, + 'cron_events' => [], + 'remote_posts' => [], + 'wpdb_call_meta_rows' => [], + 'wpdb_call_io_rows' => [], + 'wpdb_dropped_tables' => [], ]; function prv_test_reset(): void { $GLOBALS['prv_test_state'] = [ - 'options' => [], - 'transients' => [], - 'actions' => [], - 'wpdb_insert_id' => 1, - 'wpdb_results' => [], - 'wpdb_var' => null, - 'cron_events' => [], - 'remote_posts' => [], + 'options' => [], + 'transients' => [], + 'actions' => [], + 'wpdb_insert_id' => 1, + 'wpdb_results' => [], + 'wpdb_var' => null, + 'wpdb_row' => null, + 'cron_events' => [], + 'remote_posts' => [], + 'wpdb_call_meta_rows' => [], + 'wpdb_call_io_rows' => [], + 'wpdb_dropped_tables' => [], ]; PRV_Collector_Registry::reset_for_testing(); } @@ -182,6 +192,7 @@ function wp_create_nonce( string $action ): string { return 'test_nonce'; } function submit_button( string $text, string $type = 'primary', string $name = 'submit', bool $wrap = true ): void {} function add_query_arg( array $args, string $url ): string { return $url . '?' . http_build_query( $args ); } function admin_url( string $path ): string { return 'http://example.test/wp-admin/' . ltrim( $path, '/' ); } +function plugin_dir_path( string $file ): string { return dirname( $file ) . '/'; } function wp_safe_redirect( string $url ): void {} function wp_send_json_success( $data = null ): void { throw new \RuntimeException( 'json_success:' . json_encode( $data ) ); } function wp_send_json_error( $data = null ): void { throw new \RuntimeException( 'json_error:' . json_encode( $data ) ); } @@ -230,15 +241,28 @@ class stdClass_wpdb { public function prepare( string $query, ...$args ): string { return vsprintf( str_replace( '%s', "'%s'", $query ), array_map( 'addslashes', $args ) ); } - public function query( string $sql ): bool { return true; } + public function query( string $sql ): bool { + if ( preg_match( '/DROP TABLE.*?(prv_\w+)/i', $sql, $m ) ) { + $GLOBALS['prv_test_state']['wpdb_dropped_tables'][] = $m[1]; + } + return true; + } public function insert( string $table, array $data, array $format = [] ): int { $this->insert_id = $GLOBALS['prv_test_state']['wpdb_insert_id']++; + // Track call_meta and call_io rows for test assertions. + if ( str_contains( $table, 'prv_call_meta' ) ) { + $GLOBALS['prv_test_state']['wpdb_call_meta_rows'][] = $data; + } + if ( str_contains( $table, 'prv_call_io' ) ) { + $GLOBALS['prv_test_state']['wpdb_call_io_rows'][] = $data; + } return 1; } public function update( string $table, array $data, array $where, array $df = [], array $wf = [] ): int { return 1; } public function get_results( string $query, string $output = 'OBJECT' ): array { return is_array( $GLOBALS['prv_test_state']['wpdb_results'] ) ? $GLOBALS['prv_test_state']['wpdb_results'] : []; } + public function get_row( string $query, string $output = 'OBJECT' ): mixed { return $GLOBALS['prv_test_state']['wpdb_row'] ?? null; } public function get_var( string $query ): mixed { return $GLOBALS['prv_test_state']['wpdb_var']; } } diff --git a/tests/unit/test-capture-writer.php b/tests/unit/test-capture-writer.php new file mode 100644 index 0000000..057df31 --- /dev/null +++ b/tests/unit/test-capture-writer.php @@ -0,0 +1,142 @@ +write_meta( + array( + 'visibility_row' => 10, + 'run_id' => 'abc-123', + 'peptide_slug' => 'bpc-157', + 'model' => 'perplexity/sonar', + 'intent_label' => 'what is {peptide}', + 'tokens_in' => 100, + 'tokens_out' => 250, + 'cost_usd' => 0.00123, + 'latency_ms' => 800, + 'cited' => 1, + 'http_status' => 200, + 'config_version' => 3, + ) +); + +prv_assert( $call_id > 0, 'write_meta returns a positive row ID' ); +$meta_rows = $GLOBALS['prv_test_state']['wpdb_call_meta_rows']; +prv_assert( count( $meta_rows ) === 1, 'write_meta inserts exactly one call_meta row' ); +prv_assert_equals( 'bpc-157', $meta_rows[0]['peptide_slug'], 'write_meta stores peptide_slug' ); +prv_assert_equals( 100, $meta_rows[0]['tokens_in'], 'write_meta stores tokens_in' ); +prv_assert_equals( 250, $meta_rows[0]['tokens_out'], 'write_meta stores tokens_out' ); +prv_assert_equals( 0, $meta_rows[0]['io_captured'], 'write_meta sets io_captured=0 initially' ); + +// ── Test 2: write_io writes a row to prv_call_io ───────────────────────── +$ok = $writer->write_io( $call_id, 'what is BPC-157?', 'BPC-157 is a peptide...' ); +prv_assert( $ok, 'write_io returns true on success' ); +$io_rows = $GLOBALS['prv_test_state']['wpdb_call_io_rows']; +prv_assert( count( $io_rows ) === 1, 'write_io inserts exactly one call_io row' ); +prv_assert_equals( 'what is BPC-157?', $io_rows[0]['prompt_text'], 'write_io stores prompt_text' ); +prv_assert_equals( 'BPC-157 is a peptide...', $io_rows[0]['response_text'], 'write_io stores response_text' ); + +// ── Test 3 (P0 SECURITY — direct writer): 401 error record does NOT store key/Bearer ── +// Direct-writer path: simulate the probe-runner error path parameters. +prv_test_reset(); +$writer2 = new PRV_Capture_Writer(); +$writer2->write_meta( + array( + 'visibility_row' => null, + 'run_id' => 'error-run-1', + 'peptide_slug' => 'tb-500', + 'model' => 'openai/gpt-4o', + 'intent_label' => 'what is {peptide}', + 'tokens_in' => null, + 'tokens_out' => null, + 'cost_usd' => 0.0, + 'latency_ms' => 150, + 'cited' => null, + 'http_status' => 401, + 'config_version' => 3, + ) +); +$writer2->write_io( 1, 'what is TB-500?', '' ); + +$err_meta = $GLOBALS['prv_test_state']['wpdb_call_meta_rows'][0]; +$err_io = $GLOBALS['prv_test_state']['wpdb_call_io_rows'][0]; + +prv_assert_equals( 401, $err_meta['http_status'], 'Error record stores http_status 401' ); + +$all_stored = wp_json_encode( $err_meta ) . wp_json_encode( $err_io ); +prv_assert( false === strpos( $all_stored, 'Bearer' ), 'Stored data does not contain "Bearer"' ); +prv_assert( false === strpos( $all_stored, 'sk-or-' ), 'Stored data does not contain "sk-or-"' ); +prv_assert( false === strpos( $all_stored, 'Authorization' ), 'Stored data does not contain "Authorization"' ); +prv_assert( null === $err_meta['cited'], 'Error record has cited=null' ); +prv_assert_equals( 'what is TB-500?', $err_io['prompt_text'], 'Error record stores rendered query as prompt_text' ); + +// ── Test 4 (P0 SECURITY — full executor path): sentinel key must not leak ─ +// Inject a known sentinel as the resolved API key; drive a mocked 401 error +// path end-to-end through PRV_Probe_Run_Executor (via PRV_Probe_Runner). +// Assert the sentinel, "Bearer", and "Authorization" appear in NO stored row. +prv_test_reset(); + +if ( ! defined( 'PRV_OPENROUTER_API_KEY' ) ) { + define( 'PRV_OPENROUTER_API_KEY', 'sk-or-TEST-LEAK-CANARY' ); +} + +$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0; +$GLOBALS['prv_test_state']['options']['prv_peptides'] = array( array( 'slug' => 'bpc-157', 'label' => 'BPC-157' ) ); +$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = array( 'what is {peptide}' ); +$GLOBALS['prv_test_state']['options']['prv_models_v2'] = array( + array( 'slug' => 'openai/gpt-4o', 'enabled' => true ), +); +$GLOBALS['prv_test_state']['options']['prv_active_config_version'] = 3; +$GLOBALS['prv_test_state']['wpdb_var'] = '0.0'; // No prior spend. + +// Queue a 401 response for wp_remote_post. +$GLOBALS['prv_test_state']['remote_posts'][] = array( + 'response' => array( 'code' => 401 ), + 'body' => '{"error":{"message":"Invalid API key","code":401}}', +); + +$sentinel_capture = new PRV_Capture_Writer(); +$sentinel_ledger = new PRV_Cost_Ledger(); +$sentinel_runner = new PRV_Probe_Runner( $sentinel_ledger, $sentinel_capture ); + +try { + $sentinel_counts = $sentinel_runner->run(); +} catch ( \Throwable $ex ) { + $sentinel_counts = array( 'skipped_error' => -99 ); +} + +// The executor must have recorded the 401 error call (skipped_error >= 1). +prv_assert( isset( $sentinel_counts['skipped_error'] ) && $sentinel_counts['skipped_error'] >= 1, 'Executor recorded a skipped_error for the 401 path' ); + +// Now verify NO stored row (meta or io) contains the sentinel key or auth material. +$all_meta = $GLOBALS['prv_test_state']['wpdb_call_meta_rows']; +$all_io = $GLOBALS['prv_test_state']['wpdb_call_io_rows']; +$all_json = wp_json_encode( $all_meta ) . wp_json_encode( $all_io ); + +prv_assert( false === strpos( $all_json, 'sk-or-TEST-LEAK-CANARY' ), 'Sentinel key does not appear in any stored row (executor path)' ); +prv_assert( false === strpos( $all_json, 'Bearer' ), 'Bearer does not appear in any stored row (executor path)' ); +prv_assert( false === strpos( $all_json, 'Authorization' ), 'Authorization does not appear in any stored row (executor path)' ); +prv_assert( count( $all_meta ) >= 1, 'Executor path wrote at least one call_meta row on 401' ); + +if ( ! empty( $all_meta ) ) { + prv_assert_equals( 401, $all_meta[0]['http_status'], 'Executor 401 path stores http_status=401 in call_meta' ); +} + +// ── Test 5: update_meta_cost writes back the cost ──────────────────────── +prv_test_reset(); +$writer3 = new PRV_Capture_Writer(); +$result = $writer3->update_meta_cost( 5, 0.004567 ); +prv_assert( $result, 'update_meta_cost returns true' ); + +exit( prv_test_summary() ); diff --git a/tests/unit/test-cost-audit.php b/tests/unit/test-cost-audit.php new file mode 100644 index 0000000..4554265 --- /dev/null +++ b/tests/unit/test-cost-audit.php @@ -0,0 +1,91 @@ + '1.23456789', 'total_calls' => '5' ); +$GLOBALS['prv_test_state']['wpdb_var'] = '1.23456789'; +$rollup = new PRV_Cost_Rollup_Query(); +$summary = $rollup->get_mtd_summary(); +prv_assert( $summary['total_cost'] > 0, 'get_mtd_summary returns positive cost when rows present' ); + +// Reconcile: PRV_Cost_Ledger reads get_var; confirm it agrees with the seeded mock. +$ledger = new PRV_Cost_Ledger(); +$mtd_via_ledger = $ledger->get_month_to_date_usd(); +prv_assert_equals( (float) '1.23456789', $mtd_via_ledger, 'PRV_Cost_Ledger MTD matches mock value' ); + +// ── Test 2: project_month_end returns a numeric value ──────────────────── +$projected = $rollup->project_month_end( 1.23 ); +prv_assert( $projected >= 1.23, 'project_month_end returns at least MTD value' ); +prv_assert( is_float( $projected ), 'project_month_end returns a float' ); + +// ── Test 3: prune_now deletes only prv_call_io (not prv_call_meta) ─────── +// PRV_Prune_Cron::prune_now() runs a DELETE on prv_call_io. The wpdb stub +// returns true for all queries; confirm it does NOT drop prv_call_meta. +prv_test_reset(); +$deleted = PRV_Prune_Cron::prune_now(); +// The stub returns (int)true = 1; just confirm no exception and correct return type. +prv_assert( is_int( $deleted ), 'prune_now returns an integer' ); +// Confirm prv_call_meta was not among dropped tables. +prv_assert( + ! in_array( 'prv_call_meta', $GLOBALS['prv_test_state']['wpdb_dropped_tables'], true ), + 'prune_now does not drop prv_call_meta' +); + +// ── Test 4: forced capture_io exception does not break probe ────────────── +// Create a mock capture writer that throws in write_io. +class PRV_Capture_Writer_Throwing extends PRV_Capture_Writer { + public function write_io( int $call_id, string $prompt_text, string $response_text ): bool { + throw new \RuntimeException( 'Simulated capture_io failure' ); + } +} + +prv_test_reset(); +$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0; +$GLOBALS['prv_test_state']['options']['prv_peptides'] = array( array( 'slug' => 'bpc-157', 'label' => 'BPC-157' ) ); +$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = array( 'what is {peptide}' ); +$GLOBALS['prv_test_state']['options']['prv_models_v2'] = array( + array( 'slug' => 'perplexity/sonar', 'enabled' => true ), +); +$GLOBALS['prv_test_state']['options']['prv_active_config_version'] = 3; +$GLOBALS['prv_test_state']['wpdb_var'] = '0.0'; // No prior spend. + +$throwing_capture = new PRV_Capture_Writer_Throwing(); +$ledger2 = new PRV_Cost_Ledger(); +$runner = new PRV_Probe_Runner( $ledger2, $throwing_capture ); + +$exception_escaped = false; +try { + $counts = $runner->run(); + // If we got here, the exception was swallowed. +} catch ( \Throwable $e ) { + $exception_escaped = true; +} +prv_assert( ! $exception_escaped, 'forced capture_io exception does not propagate to probe runner caller' ); +prv_assert( is_array( $counts ?? null ), 'runner returns counts array even when capture_io throws' ); + +// ── Test 5: table drop methods work correctly ───────────────────────────── +prv_test_reset(); +// Simulate what uninstall.php does: call the static drop methods directly. +PRV_Table_Manager::drop_table(); +PRV_Call_Io_Table::drop_table(); +PRV_Call_Meta_Table::drop_table(); + +$dropped = $GLOBALS['prv_test_state']['wpdb_dropped_tables']; +prv_assert( in_array( 'prv_call_io', $dropped, true ), 'drop_table() drops prv_call_io' ); +prv_assert( in_array( 'prv_call_meta', $dropped, true ), 'drop_table() drops prv_call_meta' ); +prv_assert( in_array( 'prv_ai_visibility', $dropped, true ), 'drop_table() drops prv_ai_visibility (existing table)' ); + +exit( prv_test_summary() ); diff --git a/uninstall.php b/uninstall.php index 31754a3..4eb800a 100644 --- a/uninstall.php +++ b/uninstall.php @@ -12,6 +12,10 @@ * prv_last_run_truncated_at -- all purged by the prv_ wildcard DELETE below. * * v0.2.3 addition: prv_provider_key_enc (encrypted API key) -- also purged + * + * v0.3.0 additions: prv_call_meta + prv_call_io tables (both DROPped); + * prv_io_retention_days option (purged by wildcard DELETE); + * prv_daily_prune cron hook (cleared in step 4). * by the prv_ wildcard DELETE below. No plaintext is stored. * * @see ARCHITECTURE.md -- Section Uninstall specification. @@ -26,12 +30,26 @@ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { global $wpdb; -/* ── 1. Drop the custom table ─────────────────────────────────────── */ +/* ── 0. Bootstrap minimum classes for DROP calls ─────────────────── */ + +require_once plugin_dir_path( __FILE__ ) . 'includes/core/class-prv-autoloader.php'; +PRV_Autoloader::register(); + +/* ── 1. Drop the custom tables (including v0.3.0 audit tables) ────── */ $table = $wpdb->prefix . 'prv_ai_visibility'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( "DROP TABLE IF EXISTS {$table}" ); +// v0.3.0: per-call audit tables (drop io before meta to respect FK intent). +$io_table = $wpdb->prefix . 'prv_call_io'; +// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching +$wpdb->query( "DROP TABLE IF EXISTS {$io_table}" ); + +$meta_table = $wpdb->prefix . 'prv_call_meta'; +// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching +$wpdb->query( "DROP TABLE IF EXISTS {$meta_table}" ); + /* ── 2. Delete all prv_ prefixed options (v0.1, v0.2, v0.2.3) ───── */ // Covers prv_provider_key_enc (v0.2.3) along with all prior option keys. @@ -50,3 +68,5 @@ $wpdb->query( /* ── 4. Clear any remaining scheduled cron events ─────────────────── */ wp_clear_scheduled_hook( 'prv_weekly_probe' ); +// v0.3.0: daily prune cron. +wp_clear_scheduled_hook( 'prv_daily_prune' );