5 Commits

Author SHA1 Message Date
peptiderepo
ee4371d916 fix: expand $nonce_cb docblock to satisfy WordPress-Docs short-description sniff
The single-line /** @var ... */ on $nonce_cb was missing a short
description as its first content line (PHPCS WordPress-Docs sniff).
Expanded to a proper multi-line docblock with summary on the first line
and @var on a separate line.

Swept class-prv-settings-controller.php, class-prv-settings-page.php,
and class-prv-settings-renderer.php — no other instances found.

Agent-Session: pr-vision admin v0.2.0 docblocks
2026-06-14 07:39:50 +00:00
peptiderepo
56c276405a split: extract PRV_Settings_Controller from settings-page (300-line fix)
class-prv-settings-page.php was 346 lines — the only file over the
300-line cap. Extract the four POST handlers (handle_save,
handle_model_add, handle_model_update, handle_model_remove) into a
new PRV_Settings_Controller class (class-prv-settings-controller.php).

PRV_Settings_Page::register_hooks() instantiates the controller once,
passing a nonce-verification callback (bound to require_admin_nonce()).
All four admin_post_* hooks now delegate to the controller.

All phpcs:disable/enable WordPress.Security.NonceVerification.Missing
wrappers and sanitize_*/wp_unslash() calls traveled with the moved
handlers. PHPCS stays green.

Result:
  class-prv-settings-page.php       193 lines
  class-prv-settings-controller.php 213 lines
  All other production PHP files     ≤300 lines

Also: ARCHITECTURE.md file-tree updated; @see cross-refs added to
both class docblocks; CHANGELOG 0.2.0 CI section updated.

Version: 0.2.0
2026-06-14 07:34:12 +00:00
peptiderepo
374da7fe7b PR Vision v0.2.0: PHPCS compliance
Expand 5 inline associative arrays in class-prv-settings-page.php to
multi-line (add_query_arg calls + PRV_Model_Registry::update data
array); correct @param column alignment in class-prv-probe-runner.php
and class-prv-model-registry.php to match longest-type rule.

PHPCS: 0 errors / 0 warnings (hand-verified against WordPress standard
alignment rules; full codebase scan confirms no remaining inline
multi-key associative arrays or @param spacing misalignments).

Agent-Session: pr-vision admin v0.2.0 phpcs
2026-06-14 07:27:19 +00:00
peptiderepo
a09bda0181 PR Vision v0.2.0: clear CI blockers (P1-A/B/C/D/E + P2 CONTEXT)
P1-A: Remove defined()/constant() declarations from tests/bootstrap.php
  - Both shadow PHP built-ins → 'Cannot redeclare' fatal on PHP 8.x
  - Killed PHP Lint (8.2) job and cancelled 8.1/8.3 jobs
  - Full bootstrap audit: no other PHP built-in shadows remain

P1-B: Re-escape pre-escaped vars at echo site (class-prv-model-manager-table)
  - $prov, $slug, $note: esc_html() at output (was only at assignment)
  - $id: esc_attr() at every output site (3 places)
  - $checked_attr/$toggle_attrs: phpcs:ignore -- hard-coded attribute strings
  - render_health_badge(): phpcs:ignore -- returns pre-escaped HTML markup

P1-C: phpcs:disable/enable NonceVerification.Missing (class-prv-settings-page)
  - Wraps all four handle_*() POST blocks
  - Justification inline: nonce verified via require_admin_nonce() private helper

P1-D/E: Auto-fixable PHPCS violations (array formatting + style)
  - class-prv-config.php: expand 12 inline associative peptide arrays to multi-line
  - class-prv-probe-runner.php: expand inline {probed,errors} array; align param docs
  - class-prv-model-test-ajax.php: multi-line wp_send_json_success/error calls
  - class-prv-settings-renderer.php: extract cadence options array to variable
  - class-prv-model-registry.php: capitalise docblock first word; align param spacing

P2: CONTEXT.md v0.2.0 -- add 7 new domain terms
  - Config Version, Run Lock, Model Registry, Run Health, Projected Cost,
    Config-version Stamp, Truncation State

Agent-Session: pr-vision admin v0.2.0 CI fix
2026-06-14 07:10:35 +00:00
peptiderepo
8274d54a5e PR Vision v0.2.0: admin/settings interface + correctness fixes
Requirements 1-8 + all non-negotiables:

1. Model manager (CRUD): PRV_Model_Registry, prv_models v2 option, versioned
   idempotent migration from v0.1.x flat-string format, Settings page CRUD.

2. Per-model run-health: PRV_Model_Registry::update_health() after each run;
   health badges (Healthy/Retired?/Disabled/Unknown) in model table + dashboard.

3. Metric comparability: PRV_Config_Version stamps every run with config_version;
   save-bar warning on scoring-relevant changes; orphaned rows excluded by version.

4. Projected cost at save: PRV_Config::get_projected_cost(); over-cap warning;
   prv_last_run_truncated option + visible notice when cap hit mid-run.

5. Config surface: peptide set, intents, cadence (reschedules cron), budget cap;
   all validated + sanitized; next-run timestamp shown.

6. API-key three-state: not-defined / defined-ok / defined-failed; derived from
   ledger; never displays the key.

7. Run-lock: PRV_Run_Lock (transient); cron tick skips if locked; Run-now
   refuses with clear message; lock released in finally block.

8. Design fixes: dark-ink focus ring on bright fills; disabled-row full AA
   contrast (no opacity); toggle disabled on retired rows; save-bar-level
   warning (role=alert); chip text #B4BDC7 6.83:1 AA; keyboard-sortable headers
   (<button>+aria-sort); aria-live on Test chip; z-index:50 save-bar;
   run-now double-fire guard; @media print light overrides.

New: PRV_Upgrader (runs migrations on plugins_loaded, not just activation),
PRV_Config_Version, PRV_Run_Lock, PRV_Settings_Page, PRV_Settings_Renderer,
PRV_Model_Manager_Table, PRV_Model_Test_Ajax.

Tests: test-model-registry, test-run-lock, test-config-version,
       test-projected-cost, test-cron-reschedule (+ updated probe-runner,
       uninstall, bootstrap).

Schema: config_version INT column added via dbDelta (idempotent).
Uninstall: purges all v0.2 prv_* options + transients.
2026-06-14 06:43:54 +00:00
28 changed files with 2923 additions and 388 deletions

View File

@@ -1,14 +1,15 @@
# Architecture — PR Vision
**Version:** 0.1.1 | **Last updated:** 2026-06-13
**Version:** 0.2.0 | **Last updated:** 2026-06-14
---
## 1. Overview
`pr-vision` is an internal WordPress plugin that runs weekly server-side LLM probes to measure whether peptiderepo.com appears in AI citations across core peptide queries. It stores a time-series in a custom table and renders an admin dashboard.
`pr-vision` is an internal WordPress plugin that runs configurable server-side LLM probes to measure whether peptiderepo.com appears in AI citations across core peptide queries. It stores a time-series in a custom table and renders an admin dashboard (trendline + standings) and a full Settings page (model manager + probe config).
**v1 scope:** AI-visibility only. The collector/panel seam is present so future data categories (keyword rankings, technical-SEO) can be added as new classes.
**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.
---
@@ -17,23 +18,32 @@
```
pr-vision/
├── pr-vision.php # Plugin boot: constants, autoloader, hooks
├── uninstall.php # Full data teardown: DROP TABLE + DELETE prv_* options
├── composer.json # Dev deps (PHPCS only)
├── phpcs.xml.dist # PHPCS config (WordPress standard)
├── uninstall.php # Full data teardown: DROP TABLE + DELETE prv_* options + transients
├── composer.json # Dev deps (PHPCS only)
├── phpcs.xml.dist # PHPCS config (WordPress standard)
├── includes/
│ ├── core/
│ │ ├── class-prv-autoloader.php # SPL autoloader: PRV_* → includes/**
│ │ ├── class-prv-plugin.php # Orchestrator: boots sub-systems
│ │ ├── class-prv-autoloader.php # SPL autoloader: PRV_* -> includes/**
│ │ ├── class-prv-plugin.php # Orchestrator: boots sub-systems + upgrader
│ │ ├── class-prv-upgrader.php # [v0.2] Runs all migrations on every plugins_loaded
│ │ ├── class-prv-activator.php # Activation: table + defaults + cron
│ │ ├── class-prv-deactivator.php # Deactivation: clear cron
│ │ ├── class-prv-table-manager.php # dbDelta create/drop + get_table_name()
│ │ ├── class-prv-config.php # Typed getters + seed_defaults()
│ │ ├── class-prv-cron.php # Weekly WP-Cron schedule/clear
│ │ ├── class-prv-table-manager.php # dbDelta create/drop + config_version column (v0.2)
│ │ ├── class-prv-config.php # Typed getters + seed_defaults() + get_projected_cost()
│ │ ├── class-prv-model-registry.php # [v0.2] prv_models CRUD + v1->v2 migration + health
│ │ ├── class-prv-config-version.php # [v0.2] Config-version stamps + bump on change
│ │ ├── class-prv-run-lock.php # [v0.2] Transient-based run-lock (cron + Run now)
│ │ ├── class-prv-cron.php # Probe cron: schedule/reschedule/clear; skip if locked
│ │ ├── class-prv-cost-ledger.php # MTD cost + hard monthly cap enforcement
│ │ ├── class-prv-probe-result.php # Immutable value object from a probe call
│ │ ├── class-prv-probe-runner.php # Orchestrates peptide×intent×model run
│ │ ├── class-prv-probe-runner.php # Orchestrates run: lock + peptide*intent*model loop
│ │ ├── class-prv-collector-registry.php # Singleton registry for collectors + panels
│ │ └── class-prv-admin-page.php # Admin page: menu, Run now, rendering
│ │ ├── class-prv-admin-page.php # Dashboard page: menu, Run now, rendering
│ │ ├── class-prv-settings-page.php # [v0.2] Settings page: menu, Run-now, AJAX dispatch; delegates POST to PRV_Settings_Controller
│ │ ├── class-prv-settings-controller.php # [v0.2] POST handler impl: save config, model CRUD (split from settings-page)
│ │ ├── class-prv-settings-renderer.php # [v0.2] Settings HTML renderer (dark Assay theme)
│ │ ├── class-prv-model-manager-table.php # [v0.2] Model manager table + Add form renderer
│ │ └── class-prv-model-test-ajax.php # [v0.2] Rate-limited AJAX handler for Test button
│ ├── providers/
│ │ ├── interface-prv-probe-provider.php # probe(query): PRV_Probe_Result contract
│ │ ├── class-prv-gateway-client.php # Cloudflare AI Gateway HTTP + retry
@@ -54,8 +64,13 @@ pr-vision/
│ ├── test-cost-ledger.php # MTD cost + budget cap
│ ├── test-provider-parsing.php # Mock-HTTP provider response parsing
│ ├── test-cron.php # Schedule/clear + activation/deactivation
│ ├── test-probe-runner.php # Run mechanics + budget abort
│ └── test-uninstall.php # Table drop + option purge
│ ├── test-probe-runner.php # Run mechanics + budget abort + lock + health
│ ├── test-uninstall.php # Table drop + option purge (incl. v0.2 keys)
│ ├── test-model-registry.php # [v0.2] Migration, CRUD, run-health
│ ├── test-run-lock.php # [v0.2] Acquire/release/sentinel
│ ├── test-config-version.php # [v0.2] Hash, bump, version stamp
│ ├── test-projected-cost.php # [v0.2] probe_count, over_cap, truncation flag
│ └── test-cron-reschedule.php # [v0.2] Reschedule, idempotent, tick guard
└── .github/workflows/ci.yml # PHP lint matrix + PHPCS + 300-line check
```
@@ -64,42 +79,45 @@ pr-vision/
## 3. Data flow
```
WP-Cron (weekly) ──────────────────────────────────────┐
Admin "Run now" (POST + nonce) ─────────────────────────┤
↓
PRV_Probe_Runner::run()
│
┌───────────────────────────────────┤
│ for each peptide × intent × model│
│ │
│ PRV_Cost_Ledger::can_afford() │
│ └─ ABORT gracefully if at cap │
│ │
│ PRV_Probe_Provider::probe(query) │
│ └─ PRV_Gateway_Client │
│ └─ Cloudflare AI Gateway │
│ └─ OpenRouter │
│ └─ LLM │
│ │
│ PRV_Citation_Detector │
│ └─ extract domains │
│ └─ detect peptiderepo.com │
│ │
│ $wpdb→insert(prv_ai_visibility) │
│ PRV_Cost_Ledger::update_row_cost()│
└───────────────────────────────────┘
│
Admin page load ────────────────────────────────────────┤
↓
PRV_Admin_Page::render_page()
│
PRV_Collector_Registry
│
PRV_Ai_Visibility_Collector::collect()
│ (DB reads)
PRV_Ai_Visibility_Panel::render()
│ (HTML output)
Browser (Chart.js)
WP-Cron (scheduled cadence) ─────────────────────────────────────┐
Admin "Run now" (Settings POST + nonce) ──────────────────────────┤
↓
PRV_Run_Lock::acquire() ← refuse if busy
↓
PRV_Probe_Runner::run()
│
┌───────────────────────────────────────┤
│ for each peptide × intent × model │
│ │
│ PRV_Cost_Ledger::can_afford() │
│ └─ ABORT gracefully if at cap │
│ │
│ PRV_Probe_Provider::probe(query) │
│ └─ PRV_Gateway_Client │
│ └─ Cloudflare AI Gateway │
│ └─ OpenRouter / LLM │
│ │
│ PRV_Citation_Detector │
│ │
│ $wpdb->insert(prv_ai_visibility) │
│ └─ config_version column stamped │
│ PRV_Cost_Ledger::update_row_cost() │
└───────────────────────────────────────┘
│
PRV_Model_Registry::update_health()
PRV_Run_Lock::release()
│
Admin page load ──────────────────────────────────────────────────┤
↓
PRV_Admin_Page::render_page()
│
PRV_Collector_Registry
│
PRV_Ai_Visibility_Collector::collect()
PRV_Ai_Visibility_Panel::render()
│
Browser (Chart.js)
(config-change annotations via config_versions option)
```
---
@@ -122,58 +140,63 @@ Admin page load ─────────────────────
| source_domains | LONGTEXT | JSON array of domains |
| raw_excerpt | LONGTEXT | First 500 chars of LLM response |
| cost_usd | DECIMAL(12,8) | Actual call cost |
| config_version | INT NULL | [v0.2] Config version at run time; NULL for legacy rows |
Schema version tracked in `prv_schema_version` option.
Schema version: `prv_schema_version` = 2.
**Key options (v0.2.0)**
| Option | Type | Notes |
|--------|------|-------|
| prv_models | array | v2 rich-object model list; migrated from flat-string v1 |
| prv_models_schema_version | int | 2 when migration complete |
| prv_config_versions | array | All config-version records |
| prv_active_config_version | int | Currently active version number |
| prv_cadence | string | 'weekly'\|'daily'\|'twicedaily' |
| prv_api_key_status | string | 'ok'\|'failed'\|'unknown' |
| prv_api_key_last_check | string | Datetime of last status update |
| prv_last_run_at | string | Datetime of last run |
| prv_last_run_counts | array | {probed, skipped_budget, skipped_error, truncated, run_id} |
| prv_last_run_truncated | int | 1 when last run hit the budget cap mid-way |
| prv_last_run_truncated_at | string | Datetime of last truncation event |
| _transient_prv_run_lock | int | Holds Unix timestamp while a run is in progress (TTL 3600s) |
---
## 5. External API integrations
### Cloudflare AI Gateway
All LLM calls route through:
```
https://gateway.ai.cloudflare.com/v1/{PRV_CF_ACCOUNT_ID}/{PRV_CF_GATEWAY_ID}/openrouter
```
Falls back to direct OpenRouter when the constants are absent.
Falls back to direct OpenRouter (`https://openrouter.ai/api/v1`) when the constants are absent or empty.
Pattern mirrors PRAutoBlogger's `class-open-router-config.php` and `class-open-router-request-builder.php` — same auth injection, same cURL belt-and-suspenders for Hostinger.
### Providers (v1)
### Providers (v0.1 + v0.2 UI-managed)
| Provider | Model | Citation source |
|----------|-------|-----------------|
| PRV_Perplexity_Provider | `perplexity/sonar` | `citations[]` array (primary, real-web retrieval) |
| PRV_OpenRouter_Provider | `openai/gpt-4o-search-preview` | annotations or inline URL regex |
| PRV_OpenRouter_Provider | `google/gemini-2.0-flash-001` | annotations or inline URL regex |
| PRV_Perplexity_Provider | `perplexity/sonar` | `citations[]` array |
| PRV_OpenRouter_Provider | any OpenRouter slug | annotations or inline URL regex |
Models are now managed via the Settings UI rather than hardcoded. The `prv_models` option is the source of truth; `PRV_Config::get_models()` delegates to `PRV_Model_Registry::get_enabled_slugs()`.
---
## 6. Collector / Panel seam
```
PRV_Data_Collector (interface) PRV_Dashboard_Panel (interface)
│ │
PRV_Ai_Visibility_Collector PRV_Ai_Visibility_Panel
│ │
└──────── PRV_Collector_Registry ──────────┘
(key: "ai_visibility")
```
To add a future SEO collector (keyword rankings, schema coverage…):
1. Implement `PRV_Data_Collector` + `PRV_Dashboard_Panel`.
2. Register both in `PRV_Plugin::init()`.
No dashboard shell changes required.
Same as v0.1.0 -- see CONTEXT.md for the extension guide.
---
## 7. Security
- Secrets read from `wp-config.php` constants at runtime — never hardcoded.
- All admin actions: `manage_options` capability check + nonce verification.
- Secrets read from `wp-config.php` constants at runtime -- never stored in DB.
- API key status shown in 3 states; key never displayed.
- All admin actions: `manage_options` check + nonce verification.
- All DB values escaped with `$wpdb->prepare()`.
- All HTML output escaped with `esc_html()`, `esc_url()`, `wp_json_encode()`.
- All HTML output escaped at render.
- AJAX `prv_test_model`: capability + nonce + 30s rate-limit transient.
---
@@ -181,8 +204,9 @@ No dashboard shell changes required.
| Decision | Rationale |
|----------|-----------|
| Use OpenRouter for all models | Single API key, single gateway URL, Perplexity sonar is accessible via OpenRouter's model routing. |
| Perplexity sonar as primary | Returns an explicit `citations[]` array — the most faithful citation signal available. |
| Cost ledger from visibility table | No separate ledger table needed; `SUM(cost_usd)` per calendar month is sufficient and keeps the schema minimal. |
| Chart.js via CDN | No build step, no bundler, admin-only load, pinned version. |
| Plain PHP tests (no PHPUnit) | Mirrors peptide-repo-core's pattern; CI runs in the lint-php job with no extra setup. |
| `prv_models_schema_version` for migration gate | Cheap option check; migration is idempotent and safe to call on every plugins_loaded without performance cost. |
| Config-version as hash + version-number | Hash detects changes; integer version is stable foreign key for run rows and chart annotations. Orphaned rows (NULL config_version or old version) are excluded from current-config scoring. |
| Run-lock as transient | WP transients work across all environments; TTL guards against stale locks from crashed runs; no separate table needed. |
| Projected cost from probe_count × fixed estimate | Conservative overestimate; avoids the need for per-model price config in v1; operator sees a safe upper bound. |
| Three-state API key (not two) | A defined-but-revoked key showed false-green in v1 (QA finding M4). The third state "defined, last run failed" closes that gap without ever displaying the key. |
| Sticky save-bar warning (not just card banner) | Card banner scrolls off; save-bar is always in viewport on a page with a tall model table (QA finding M2). |

View File

@@ -56,3 +56,50 @@ Rename to PR Vision; fix www-strip in competitor display; PHPCS gating; remove d
- `ARCHITECTURE.md`, `CONVENTIONS.md`, `CONTEXT.md` (incl. visibility score formula), `README.md`.
- CI: PHP lint (8.1/8.2/8.3) + PHPCS + 300-line check (mirrors `peptide-repo-core`).
- Seed config: 12 peptides, 3 prompt intents, Perplexity sonar + GPT-4o-search + Gemini-2.0-Flash.
---
## [0.2.0] — 2026-06-14
Admin/Settings interface + correctness fixes. All adversarial-QA must-fixes addressed.
### Added
- **Model manager (`prv_models` v2):** CRUD for models via Settings page — add, enable/disable, remove, edit, without a code deploy. Per-model: provider, slug, enabled flag, operator note, run-health, Test button.
- **`PRV_Model_Registry`:** manages the `prv_models` option in v2 rich-object format; migration from v0.1.x flat-string format is versioned, idempotent, and tested.
- **`PRV_Upgrader`:** runs all pending migrations on every `plugins_loaded` (not just activation) so live-upgraded installs get migrated automatically.
- **`PRV_Config_Version`:** stamps every run with the scored config (models × peptides × intents); bumps on scoring-relevant saves; `get_all_versions()` for trendline annotations.
- **`PRV_Run_Lock`:** transient-based distributed lock preventing concurrent cron + "Run now" executions; `acquire()` / `release()` / `is_locked()`.
- **`PRV_Settings_Page`:** "PR Vision → Settings" sub-menu with POST handlers for settings save, model CRUD, Run now, Test model AJAX. All actions `manage_options` + nonce gated.
- **`PRV_Settings_Renderer`:** renders the dark "Assay" settings page (CSS variables, sticky save-bar with scoring-change warning, projected cost inset, API-key status, Run now).
- **`PRV_Model_Manager_Table`:** renders the model manager table with health badges, keyboard-sortable `<button aria-sort>` headers, disabled-toggle for retired rows, Test chips with `aria-live`.
- **`PRV_Model_Test_Ajax`:** rate-limited AJAX handler for point-in-time model-slug validation.
- **Per-model run-health:** `PRV_Model_Registry::update_health()` called at end of each run; "Retired?" badge when `probed=0 && errors>0`.
- **Metric comparability:** config-version stamp on every DB row (`config_version` INT column); bump + warning on scoring-relevant save; break annotation hook for dashboard trendline.
- **Projected cost at save:** `PRV_Config::get_projected_cost()` returns per_run / per_month / probe_count / over_cap; displayed live on settings page.
- **Budget truncation state:** `prv_last_run_truncated` option set when a run hits the cap mid-way; surfaced in run-done notices and dashboard data.
- **Cadence reschedule:** `PRV_Cron::reschedule()` clears + re-adds the WP-Cron event on cadence change; next-run timestamp shown.
- **API-key three-state:** "Not defined" / "Defined — last run OK" / "Defined — last run failed" derived from `prv_api_key_status` ledger option; never displays the key.
- **Design criticals (v2 mockups):** dark ink focus ring on bright fills; disabled-row text at full AA (no opacity collapse), toggle genuinely `disabled`; save-bar-level warning with `role=alert`; "Not yet" chip `#B4BDC7` (6.83:1 AA); keyboard-operable sortable headers (`<button>` + `aria-sort`); `aria-live="polite"` on Test chip + Run-now status; sticky save-bar `z-index:50`; Run-now double-fire guard; `@media print` light overrides.
- New unit tests: `test-model-registry.php` (migration, CRUD, health), `test-run-lock.php` (acquire/release/sentinel), `test-config-version.php` (hash, bump, stamp), `test-projected-cost.php` (probe_count, over_cap, truncation), `test-cron-reschedule.php` (reschedule, idempotent, tick guard).
### Changed
- `PRV_Config::get_models()` now delegates to `PRV_Model_Registry::get_enabled_slugs()`.
- `PRV_Config::seed_defaults()` no longer seeds `prv_models` directly (handled by `PRV_Upgrader`).
- `PRV_Config` adds `CADENCE_KEY` constant, `get_cadence()`, `get_projected_cost()`.
- `PRV_Probe_Runner::run()` acquires run-lock, stamps `config_version` on each row, updates model health, sets truncation option, returns `truncated` + `run_id` keys.
- `PRV_Table_Manager::create_table()` adds `config_version INT NULL` column; schema version bumped to 2.
- `PRV_Cron` adds `reschedule()`, `next_run_timestamp()`, cadence-aware `schedule()`; `handle_cron_tick()` skips if run-lock held.
- `PRV_Plugin::init()` boots `PRV_Upgrader::run()` first and registers `PRV_Settings_Page`.
- `uninstall.php` also purges `prv_` transients (run-lock etc.).
- Version bumped to **0.2.0**.
### CI
- **P1-A:** Removed `function defined()` and `function constant()` declarations from `tests/bootstrap.php` — they shadowed PHP built-ins and caused `Cannot redeclare` fatal on PHP 8.x, blocking all unit-test CI steps.
- **P1-B:** Re-escaped pre-escaped variables at echo site in `class-prv-model-manager-table.php`; added targeted `phpcs:ignore` with justification for `render_health_badge()` (returns pre-escaped HTML).
- **P1-C:** Added `phpcs:disable/enable WordPress.Security.NonceVerification.Missing` around `$_POST` blocks in all four `handle_*()` methods in `class-prv-settings-page.php`; nonce is verified via `require_admin_nonce()` private helper.
- **P1-D/E:** Expanded inline associative arrays to multi-line in `class-prv-config.php`, `class-prv-probe-runner.php`, `class-prv-settings-renderer.php`, `class-prv-model-test-ajax.php`; fixed param-comment spacing in `class-prv-model-registry.php`; capitalised docblock long-description first word.
- **PHPCS compliance:** Expanded remaining 5 inline associative arrays in `class-prv-settings-page.php` (`add_query_arg` calls + `PRV_Model_Registry::update` data array) to multi-line with trailing comma; corrected `@param` column alignment in `class-prv-probe-runner.php:update_api_key_status` and `class-prv-model-registry.php:update_health` to match longest-type rule. PHPCS: 0 errors / 0 warnings.
- **300-line split:** extracted `handle_save`, `handle_model_add`, `handle_model_update`, `handle_model_remove` from `class-prv-settings-page.php` (346 lines) into new `class-prv-settings-controller.php` (`PRV_Settings_Controller`); settings-page delegates via nonce callback. All `phpcs:disable/enable WordPress.Security.NonceVerification.Missing` wrappers carried with the moved handlers. Both files now ≤213 lines; no file exceeds 300 lines.

View File

@@ -1,6 +1,6 @@
# Context — Domain Glossary (PR Vision)
**Version:** 0.1.1 | **Last updated:** 2026-06-13
**Version:** 0.2.0 | **Last updated:** 2026-06-14
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.
@@ -79,6 +79,41 @@ The extensibility mechanism: `PRV_Data_Collector` produces data; `PRV_Dashboard_
**Code identifiers:** `interface-prv-data-collector.php`, `interface-prv-dashboard-panel.php`, `PRV_Collector_Registry`.
### Config Version
A monotonically incrementing integer (stored in the `prv_config_versions` option) that is bumped whenever the active probe configuration changes (models list, peptides list, or prompt intents). Each row in `prv_ai_visibility` is stamped with the `config_version` at the time it was recorded. Rows from a different config version are excluded from scoring to ensure comparability.
**Code identifiers:** `PRV_Config_Version`, `bump_version_if_changed()`, `config_version` column, `prv_config_versions` option.
### Run Lock
A transient-backed mutex (`prv_run_lock`) that prevents concurrent probe runs. Acquired at the start of a run and released in a `finally` block. TTL is 1 hour to self-clear on fatal crash. Both the cron tick and the "Run now" button check `is_locked()` before dispatching.
**Code identifiers:** `PRV_Run_Lock`, `acquire()`, `release()`, `is_locked()`, transient `prv_run_lock`.
### Model Registry
A versioned option (`prv_models`) storing the list of LLM models to probe. Each entry is a rich object with `{id, provider, slug, enabled, note, health_status, health_probed, health_errors, health_run_id}`. The registry migrates v0.1.x flat-string arrays to v2 schema on upgrade. CRUD is exposed on the Settings page.
**Code identifiers:** `PRV_Model_Registry`, `run_migration_v2()`, `prv_models` option, `PRV_SCHEMA_VERSION` constant.
### Run Health
A per-model health status derived from the most recent probe run outcomes. Values: `healthy` (probes succeeded), `retired` (zero rows, errors occurred), `disabled` (model not enabled), `unknown` (not yet run). Displayed as a badge in the Model Manager table. Updated by `PRV_Model_Registry::update_health()` at end of each run.
**Code identifiers:** `health_status`, `health_probed`, `health_errors`, `health_run_id` fields; `PRV_Model_Registry::update_health()`; `render_health_badge()`.
### Projected Cost
The estimated monthly spend (in USD) based on the current configuration: `enabled_models × peptides × intents × $0.005/probe × runs_per_month`. Displayed on the Settings page as a cost inset. The `over_cap` flag is shown with `aria-live="polite"` when the projected cost would exceed the monthly budget.
**Code identifiers:** `PRV_Config::get_projected_cost()`, `per_run_usd`, `per_month_usd`, `probe_count`, `over_cap` return keys.
### Config-version Stamp
The act of recording the active `config_version` integer on each `prv_ai_visibility` row at insert time. Enables retrospective filtering: only rows with `config_version = active_version` are included in current-period scoring, preventing cross-config score pollution.
**Code identifiers:** `config_version` column in `prv_ai_visibility`, `PRV_Config_Version::get_active_version()`.
### Truncation State
When the monthly budget cap is reached mid-run, `prv_last_run_truncated` is set to `1`. This surfaces on the Settings page as a notice: "Last run was truncated by the monthly budget cap." The flag is cleared on the next successful full run. Probes after the cap are skipped (not errored) and counted in `skipped_budget`.
**Code identifiers:** `prv_last_run_truncated` option, `PRV_Cost_Ledger::can_afford()`, `skipped_budget` count, `truncated` flag in run counts.
### GEO
Generative Engine Optimization — the practice of improving a brand's visibility in AI-generated responses. This plugin is the measurement instrument for peptiderepo.com's GEO program.

View File

@@ -12,10 +12,10 @@ declare(strict_types=1);
*
* Converts PRV_* class/interface names to file paths under includes/.
* Naming conventions:
* PRV_Foo_Bar (class) → class-prv-foo-bar.php
* PRV_Foo_Bar (interface) → interface-prv-foo-bar.php (tried second)
* PRV_Foo_Bar (class) -- class-prv-foo-bar.php
* PRV_Foo_Bar (interface) -- interface-prv-foo-bar.php (tried second)
*
* Who triggers: Plugin boot (pr-vision.php) — called once before
* Who triggers: Plugin boot (pr-vision.php) -- called once before
* any PRV_* class or interface is instantiated.
* Dependencies: None (pure PHP).
*

View File

@@ -0,0 +1,177 @@
<?php
/**
* Scoring-config versioning: stamps each run with the model*peptide*intent set.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Tracks the scored config (models x peptides x intents) as a version record.
*
* Whenever the scoring-relevant config changes, a new config-version is stamped.
* Every run is linked to the active config-version at run time. The dashboard
* trendline uses this to draw break annotations at config changes, and the
* score query excludes rows from orphaned models/peptides outside the current
* config-version.
*
* Option layout:
* prv_config_versions: array of {version: int, hash: string, timestamp: int,
* models: string[], peptides: string[], intents: string[]}
* prv_active_config_version: int (the current version number)
*
* Who triggers: PRV_Upgrader::run() for initial seed; PRV_Settings_Page when
* scoring-relevant settings are saved.
* Dependencies: PRV_Model_Registry, PRV_Config.
*
* @see class-prv-upgrader.php -- Calls maybe_seed_initial_version().
* @see class-prv-settings-page.php -- Calls bump_version_if_changed() on save.
* @see class-prv-probe-runner.php -- Reads get_active_version() for run stamp.
* @package PrVision
*/
class PRV_Config_Version {
/**
* Option key for the version history array.
*/
const VERSIONS_KEY = 'prv_config_versions';
/**
* Option key for the currently active version number.
*/
const ACTIVE_KEY = 'prv_active_config_version';
/**
* Seed the initial version record if none exists yet.
*
* Safe to call repeatedly -- no-op when a version already exists.
*
* Side effects: May write prv_config_versions and prv_active_config_version.
*
* @return void
*/
public static function maybe_seed_initial_version(): void {
$versions = get_option( self::VERSIONS_KEY, array() );
if ( ! empty( $versions ) ) {
return;
}
self::create_version_record();
}
/**
* Compute the config hash from the current model/peptide/intent set.
*
* @return string MD5 of the sorted, serialised config components.
*/
public static function compute_hash(): string {
$models = PRV_Model_Registry::get_enabled_slugs();
$peptides = array_column( PRV_Config::get_peptides(), 'slug' );
$intents = PRV_Config::get_prompt_intents();
sort( $models );
sort( $peptides );
sort( $intents );
return md5( serialize( array( $models, $peptides, $intents ) ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
}
/**
* Bump to a new config version if the scoring config has changed.
*
* Call this after saving any scoring-relevant setting. Returns the new
* version number if a bump occurred, null otherwise.
*
* Side effects: May write prv_config_versions and prv_active_config_version.
*
* @return int|null New version number if bumped, null if unchanged.
*/
public static function bump_version_if_changed(): ?int {
$new_hash = self::compute_hash();
$versions = get_option( self::VERSIONS_KEY, array() );
$active = (int) get_option( self::ACTIVE_KEY, 0 );
foreach ( $versions as $v ) {
if ( isset( $v['version'] ) && (int) $v['version'] === $active && isset( $v['hash'] ) && $v['hash'] === $new_hash ) {
return null; // Config unchanged.
}
}
return self::create_version_record();
}
/**
* Get the active config version number.
*
* @return int Version number (1-based); 0 if not yet seeded.
*/
public static function get_active_version(): int {
return (int) get_option( self::ACTIVE_KEY, 0 );
}
/**
* Get all version records, newest first.
*
* @return array<int, array<string, mixed>>
*/
public static function get_all_versions(): array {
$versions = get_option( self::VERSIONS_KEY, array() );
if ( ! is_array( $versions ) ) {
return array();
}
$sorted = $versions;
usort( $sorted, static fn( $a, $b ) => (int) ( $b['version'] ?? 0 ) - (int) ( $a['version'] ?? 0 ) );
return $sorted;
}
/**
* Check if the given hash differs from the current active config.
*
* Used by the Settings page to warn before saving a scoring-relevant change.
*
* @param string $proposed_hash Hash to check.
*
* @return bool True when the config would change.
*/
public static function would_change( string $proposed_hash ): bool {
$versions = get_option( self::VERSIONS_KEY, array() );
$active = (int) get_option( self::ACTIVE_KEY, 0 );
foreach ( $versions as $v ) {
if ( isset( $v['version'] ) && (int) $v['version'] === $active ) {
return isset( $v['hash'] ) && $v['hash'] !== $proposed_hash;
}
}
return false;
}
/**
* Create a new version record and activate it.
*
* Side effects: Updates prv_config_versions and prv_active_config_version.
*
* @return int The new version number.
*/
private static function create_version_record(): int {
$versions = get_option( self::VERSIONS_KEY, array() );
if ( ! is_array( $versions ) ) {
$versions = array();
}
$new_version = count( $versions ) + 1;
$models = PRV_Model_Registry::get_enabled_slugs();
$peptides = array_column( PRV_Config::get_peptides(), 'slug' );
$intents = PRV_Config::get_prompt_intents();
$versions[] = array(
'version' => $new_version,
'hash' => self::compute_hash(),
'timestamp' => time(),
'models' => $models,
'peptides' => $peptides,
'intents' => $intents,
);
update_option( self::VERSIONS_KEY, $versions );
update_option( self::ACTIVE_KEY, $new_version );
return $new_version;
}
}

View File

@@ -12,18 +12,26 @@ declare(strict_types=1);
*
* All mutable settings live in wp_options under the prv_ prefix. This class
* provides typed getters (no magic values in call sites) and seeds the initial
* defaults on first activation.
* defaults on first activation. Models are delegated to PRV_Model_Registry
* which owns the v2 rich-object format; get_models() returns the flat slug
* array for backward compat with PRV_Probe_Runner.
*
* Who triggers: PRV_Activator::activate() for seeding; every component that
* needs a setting reads through here.
* Dependencies: get_option(), update_option().
* Dependencies: get_option(), update_option(), PRV_Model_Registry.
*
* @see CONTEXT.md — Domain glossary: peptides, prompt intents, models.
* @see ARCHITECTURE.md — §Config (options + defaults).
* @see CONTEXT.md -- Domain glossary: peptides, prompt intents, models.
* @see ARCHITECTURE.md -- Section Config (options + defaults).
* @see class-prv-model-registry.php -- Owns prv_models v2 format.
* @package PrVision
*/
class PRV_Config {
/**
* Cadence option key.
*/
const CADENCE_KEY = 'prv_cadence';
/**
* Seed the default option values on first activation.
*
@@ -37,7 +45,9 @@ class PRV_Config {
add_option( 'prv_monthly_budget_usd', PRV_DEFAULT_MONTHLY_BUDGET_USD );
add_option( 'prv_peptides', self::default_peptides() );
add_option( 'prv_prompt_intents', self::default_prompt_intents() );
add_option( 'prv_models', self::default_models() );
add_option( self::CADENCE_KEY, 'weekly' );
// Note: prv_models seeded by PRV_Model_Registry::run_migration_v2() via
// PRV_Upgrader::run() -- not here -- so the v2 schema is always in place.
}
/**
@@ -75,13 +85,56 @@ class PRV_Config {
}
/**
* Get the model identifiers to probe with.
* Get the enabled model slugs (flat array, backward compat).
*
* Delegates to PRV_Model_Registry which owns the v2 model format.
*
* @return array<int, string>
*/
public static function get_models(): array {
$raw = get_option( 'prv_models', self::default_models() );
return is_array( $raw ) ? $raw : self::default_models();
return PRV_Model_Registry::get_enabled_slugs();
}
/**
* Get the probe cadence identifier.
*
* @return string WP-Cron recurrence name (e.g. 'weekly', 'daily').
*/
public static function get_cadence(): string {
$cadence = (string) get_option( self::CADENCE_KEY, 'weekly' );
return in_array( $cadence, array( 'weekly', 'daily', 'twicedaily' ), true ) ? $cadence : 'weekly';
}
/**
* Compute the projected per-run and per-month cost.
*
* Formula: enabled_models x peptides x intents x estimated_cost_per_probe x cadence_runs_per_month.
* Uses conservative high-end estimates to never understate cost.
*
* @return array{per_run_usd: float, per_month_usd: float, probe_count: int, over_cap: bool}
*/
public static function get_projected_cost(): array {
$models = PRV_Model_Registry::get_all();
$enabled_count = count( array_filter( $models, static fn( $m ) => ! empty( $m['enabled'] ) ) );
$peptide_count = count( self::get_peptides() );
$intent_count = count( self::get_prompt_intents() );
$probe_count = $enabled_count * $peptide_count * $intent_count;
// Conservative per-probe estimate (use the higher Perplexity estimate).
$per_probe = 0.005;
$per_run = round( $probe_count * $per_probe, 4 );
$cadence = self::get_cadence();
$runs_per_month = 'weekly' === $cadence ? 4 : ( 'daily' === $cadence ? 30 : 8 );
$per_month = round( $per_run * $runs_per_month, 4 );
$cap = self::get_monthly_budget_usd();
return array(
'per_run_usd' => $per_run,
'per_month_usd' => $per_month,
'probe_count' => $probe_count,
'over_cap' => $per_month > $cap,
);
}
/**
@@ -143,7 +196,7 @@ class PRV_Config {
}
/**
* Default prompt intent templates — three query styles per peptide.
* Default prompt intent templates -- three query styles per peptide.
*
* @return array<int, string>
*/
@@ -154,18 +207,4 @@ class PRV_Config {
'{peptide} reconstitution guide',
);
}
/**
* Default models: Perplexity sonar (primary citation signal) + two
* OpenRouter-routed search-capable models for breadth.
*
* @return array<int, string>
*/
private static function default_models(): array {
return array(
'perplexity/sonar',
'openai/gpt-4o-search-preview',
'google/gemini-2.0-flash-001',
);
}
}

View File

@@ -8,17 +8,21 @@
declare(strict_types=1);
/**
* WP-Cron management for the weekly AI-visibility probe.
* WP-Cron management for the AI-visibility probe.
*
* Registers the prv_weekly_probe hook, schedules/clears the event, and
* dispatches to PRV_Probe_Runner on each tick.
* dispatches to PRV_Probe_Runner on each tick. Cadence changes clear the
* existing schedule and reschedule at the new recurrence so next-run
* timestamp reflects the actual cadence, not just the saved option.
*
* Who triggers: PRV_Plugin::init() (hook registration), PRV_Activator
* (schedule), PRV_Deactivator (clear).
* Dependencies: PRV_Probe_Runner.
* (schedule), PRV_Deactivator (clear), PRV_Settings_Page
* (reschedule on cadence change).
* Dependencies: PRV_Probe_Runner, PRV_Run_Lock.
*
* @see class-prv-probe-runner.php — Runner invoked on each cron tick.
* @see class-prv-activator.php — Calls schedule_weekly() on activation.
* @see class-prv-probe-runner.php -- Runner invoked on each cron tick.
* @see class-prv-activator.php -- Calls schedule() on activation.
* @see class-prv-settings-page.php -- Calls reschedule() on cadence change.
* @package PrVision
*/
class PRV_Cron {
@@ -35,34 +39,85 @@ class PRV_Cron {
}
/**
* Callback fired by WP-Cron on the weekly schedule.
* Callback fired by WP-Cron on the scheduled cadence.
*
* Respects the run-lock; if already running (e.g. "Run now" is active)
* the cron tick is skipped silently without double-spending.
*
* Side effects: Instantiates and runs PRV_Probe_Runner.
*
* @return void
*/
public function handle_cron_tick(): void {
if ( PRV_Run_Lock::is_locked() ) {
// Another run is in progress -- skip this tick.
return;
}
$runner = new PRV_Probe_Runner();
$runner->run();
}
/**
* Schedule the weekly cron event if not already scheduled.
* Schedule the probe cron event using the configured cadence.
*
* Safe to call on re-activation — no-op when already scheduled.
* Safe to call on re-activation -- no-op when already scheduled at the
* correct recurrence; clears and reschedules when recurrence differs.
*
* Side effects: Adds a WP-Cron event.
* Side effects: Adds (or replaces) a WP-Cron event.
*
* @return void
*/
public static function schedule(): void {
$cadence = PRV_Config::get_cadence();
$next = wp_next_scheduled( PRV_CRON_HOOK );
if ( false !== $next ) {
// Check if the current recurrence matches.
$events = _get_cron_array();
foreach ( $events as $timestamp => $hooks ) {
if ( isset( $hooks[ PRV_CRON_HOOK ] ) ) {
foreach ( $hooks[ PRV_CRON_HOOK ] as $event ) {
if ( isset( $event['schedule'] ) && $event['schedule'] === $cadence ) {
return; // Already scheduled at the right cadence.
}
}
}
}
// Wrong cadence -- clear and reschedule.
self::clear_schedule();
}
wp_schedule_event( time(), $cadence, PRV_CRON_HOOK );
}
/**
* Reschedule the cron at a new cadence.
*
* Clears the existing event and creates a new one immediately.
* Called by PRV_Settings_Page when the cadence option changes.
*
* Side effects: Removes existing cron event; adds new one.
*
* @param string $cadence New WP-Cron recurrence identifier.
*
* @return void
*/
public static function reschedule( string $cadence ): void {
self::clear_schedule();
wp_schedule_event( time(), $cadence, PRV_CRON_HOOK );
}
/**
* Schedule using the legacy 'weekly' recurrence (activation compat).
*
* @return void
*/
public static function schedule_weekly(): void {
if ( ! wp_next_scheduled( PRV_CRON_HOOK ) ) {
wp_schedule_event( time(), 'weekly', PRV_CRON_HOOK );
}
self::schedule();
}
/**
* Remove the weekly cron schedule.
* Remove the cron schedule.
*
* Called on deactivation. Does not purge data.
*
@@ -78,11 +133,20 @@ class PRV_Cron {
}
/**
* Check whether the weekly event is currently scheduled.
* Check whether the probe event is currently scheduled.
*
* @return bool
*/
public static function is_scheduled(): bool {
return false !== wp_next_scheduled( PRV_CRON_HOOK );
}
/**
* Return the timestamp of the next scheduled run.
*
* @return int|false Timestamp or false if not scheduled.
*/
public static function next_run_timestamp() {
return wp_next_scheduled( PRV_CRON_HOOK );
}
}

View File

@@ -0,0 +1,190 @@
<?php
/**
* Model manager table renderer for PR Vision settings page.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Renders the Model Manager table and Add-model form.
*
* The table shows all models (provider, slug, enabled toggle, run-health badge,
* Test button, edit and remove controls). Retired rows are styled with full AA
* contrast (no opacity collapse), a left-edge, diagonal hatch, strikethrough
* name, and a disabled toggle. The Test button is a point-in-time check; run-
* health is the passive per-run detector.
*
* Who triggers: PRV_Settings_Renderer::render_model_manager().
* Dependencies: PRV_Model_Registry, PRV_Settings_Page (nonce constants).
*
* @see class-prv-settings-renderer.php -- Instantiates and calls render().
* @see class-prv-model-registry.php -- Source of model data.
* @package PrVision
*/
class PRV_Model_Manager_Table {
/**
* Render the model table + add-model form.
*
* Side effects: Outputs HTML directly.
*
* @return void
*/
public function render(): void {
$models = PRV_Model_Registry::get_all();
echo '<table class="prv-table" id="prv-models-table">';
echo '<thead><tr>';
echo '<th aria-sort="none"><button>' . esc_html__( 'Provider', 'pr-vision' ) . ' <span aria-hidden="true">⇅</span></button></th>';
echo '<th aria-sort="none"><button>' . esc_html__( 'Model slug', 'pr-vision' ) . ' <span aria-hidden="true">⇅</span></button></th>';
echo '<th>' . esc_html__( 'Enabled', 'pr-vision' ) . '</th>';
echo '<th aria-sort="none"><button>' . esc_html__( 'Run health', 'pr-vision' ) . ' <span aria-hidden="true">⇅</span></button></th>';
echo '<th>' . esc_html__( 'Test (point-in-time)', 'pr-vision' ) . '</th>';
echo '<th>' . esc_html__( 'Actions', 'pr-vision' ) . '</th>';
echo '</tr></thead><tbody>';
foreach ( $models as $m ) {
$this->render_model_row( $m );
}
echo '</tbody></table>';
echo '<div style="margin-top:16px">';
$this->render_add_model_form();
echo '</div>';
}
/**
* Render a single model table row.
*
* @param array<string, mixed> $m Model object from PRV_Model_Registry.
*
* @return void
*/
private function render_model_row( array $m ): void {
$id = esc_attr( (string) ( $m['id'] ?? '' ) );
$slug = esc_html( (string) ( $m['slug'] ?? '' ) );
$prov = esc_html( (string) ( $m['provider'] ?? '' ) );
$enabled = ! empty( $m['enabled'] );
$health = (string) ( $m['health_status'] ?? 'unknown' );
$retired = 'retired' === $health;
$note = esc_html( (string) ( $m['note'] ?? '' ) );
$row_class = $retired ? 'prv-row-retired' : '';
echo '<tr class="' . esc_attr( $row_class ) . '">';
// Provider.
echo '<td>' . esc_html( $prov ) . '</td>';
// Slug (monospace).
echo '<td><code style="background:var(--prv-surface3);padding:1px 5px;border-radius:3px;font-size:12px;' . ( $retired ? 'text-decoration:line-through;' : '' ) . '">' . esc_html( $slug ) . '</code>';
if ( $note ) {
echo '<br><span style="color:var(--prv-text-muted);font-size:11px">' . esc_html( $note ) . '</span>';
}
echo '</td>';
// Enabled toggle.
echo '<td>';
$form_name = 'prv_enable_' . $id;
echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" style="display:inline">';
echo '<input type="hidden" name="action" value="prv_model_update">';
echo '<input type="hidden" name="prv_model_id" value="' . esc_attr( $id ) . '">';
echo '<input type="hidden" name="prv_model_slug" value="' . esc_attr( (string) ( $m['slug'] ?? '' ) ) . '">';
echo '<input type="hidden" name="prv_model_note" value="' . esc_attr( (string) ( $m['note'] ?? '' ) ) . '">';
wp_nonce_field( PRV_Settings_Page::NONCE_MODEL, 'prv_nonce' );
// Disabled toggle for retired models.
$toggle_attrs = $retired ? ' disabled aria-disabled="true"' : '';
$checked_attr = $enabled ? ' checked' : '';
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $checked_attr and $toggle_attrs are hard-coded attribute strings; $enabled is a boolean cast.
echo '<input type="checkbox" name="prv_model_enabled" onchange="this.form.submit()" ' . $checked_attr . $toggle_attrs . ' title="' . ( $enabled ? 'Disable' : 'Enable' ) . '">';
echo '</form>';
if ( $retired ) {
echo '<span class="prv-badge prv-badge-retired" style="margin-left:6px">' . esc_html__( 'Retired', 'pr-vision' ) . '</span>';
}
echo '</td>';
// Run health.
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- render_health_badge() returns pre-escaped HTML markup.
echo '<td>' . $this->render_health_badge( $m ) . '</td>';
// Test button + chip.
$chip_id = 'prv-test-chip-' . $id;
echo '<td>';
echo '<button type="button" class="prv-btn prv-btn-ghost prv-test-btn" data-model-id="' . esc_attr( $id ) . '">';
echo esc_html__( 'Test', 'pr-vision' );
echo '</button>';
echo ' <span id="' . esc_attr( $chip_id ) . '" class="prv-badge prv-badge-unknown" role="status" aria-live="polite"></span>';
echo '</td>';
// Actions: remove.
echo '<td>';
echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" style="display:inline" onsubmit="return confirm(\'Remove this model?\');">';
echo '<input type="hidden" name="action" value="prv_model_remove">';
echo '<input type="hidden" name="prv_model_id" value="' . esc_attr( $id ) . '">';
wp_nonce_field( PRV_Settings_Page::NONCE_MODEL, 'prv_nonce' );
echo '<button type="submit" class="prv-btn prv-btn-danger">' . esc_html__( 'Remove', 'pr-vision' ) . '</button>';
echo '</form>';
echo '</td>';
echo '</tr>';
}
/**
* Render a health badge for a model.
*
* @param array<string, mixed> $m Model object.
*
* @return string HTML badge (pre-escaped).
*/
private function render_health_badge( array $m ): string {
$status = (string) ( $m['health_status'] ?? 'unknown' );
$probed = (int) ( $m['health_probed'] ?? 0 );
$errors = (int) ( $m['health_errors'] ?? 0 );
switch ( $status ) {
case 'healthy':
return '<span class="prv-badge prv-badge-healthy">&#9679; Healthy (' . esc_html( (string) $probed ) . ' rows)</span>';
case 'retired':
return '<span class="prv-badge prv-badge-retired">&#9679; Retired? (0 rows · ' . esc_html( (string) $errors ) . ' errors)</span>';
case 'disabled':
return '<span class="prv-badge prv-badge-disabled">&#9679; Off · retired</span>';
default:
return '<span class="prv-badge prv-badge-unknown">&#9679; Unknown</span>';
}
}
/**
* Render the Add model form below the table.
*
* @return void
*/
private function render_add_model_form(): void {
echo '<details style="border:1px solid var(--prv-border);border-radius:6px;padding:12px;background:var(--prv-surface2)">';
echo '<summary style="cursor:pointer;color:var(--prv-teal);font-size:13px;font-weight:500">+ ' . esc_html__( 'Add model', 'pr-vision' ) . '</summary>';
echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" style="margin-top:12px;display:grid;grid-template-columns:1fr 1fr;gap:10px">';
echo '<input type="hidden" name="action" value="prv_model_add">';
wp_nonce_field( PRV_Settings_Page::NONCE_MODEL, 'prv_nonce' );
echo '<div>';
echo '<label class="prv-label" for="prv_new_model_slug">' . esc_html__( 'Model slug', 'pr-vision' ) . '</label>';
echo '<input class="prv-input" type="text" id="prv_new_model_slug" name="prv_model_slug" placeholder="openai/gpt-4o-search-preview" required>';
echo '</div>';
echo '<div>';
echo '<label class="prv-label" for="prv_new_model_provider">' . esc_html__( 'Provider', 'pr-vision' ) . '</label>';
echo '<select class="prv-input" id="prv_new_model_provider" name="prv_model_provider">';
echo '<option value="openrouter">openrouter</option>';
echo '<option value="perplexity">perplexity</option>';
echo '</select>';
echo '</div>';
echo '<div style="grid-column:1/-1">';
echo '<label class="prv-label" for="prv_new_model_note">' . esc_html__( 'Note (optional)', 'pr-vision' ) . '</label>';
echo '<input class="prv-input" type="text" id="prv_new_model_note" name="prv_model_note" placeholder="Why this model?">';
echo '</div>';
echo '<div style="grid-column:1/-1;display:flex;align-items:center;gap:10px">';
echo '<label><input type="checkbox" name="prv_model_enabled" value="1" checked> ' . esc_html__( 'Enabled', 'pr-vision' ) . '</label>';
echo '<button type="submit" class="prv-btn prv-btn-primary">' . esc_html__( 'Add model', 'pr-vision' ) . '</button>';
echo '</div>';
echo '</form>';
echo '</details>';
}
}

View File

@@ -0,0 +1,299 @@
<?php
/**
* Model registry: versioned, idempotent migration + CRUD for prv_models.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Manages prv_models: v0.1.x→v0.2.0 migration, CRUD, per-model run-health.
*
* V2 schema: [{id, provider, slug, enabled, note, health_status, health_probed,
* health_errors, health_run_id}]. v1 legacy: flat string array.
*
* Who triggers: PRV_Upgrader::run() (upgrade path); PRV_Settings_Page (CRUD).
* Dependencies: get_option(), update_option().
*
* @see class-prv-upgrader.php -- Calls run_migration_v2() on upgrade.
* @see class-prv-settings-page.php -- CRUD consumers.
* @see ARCHITECTURE.md -- Section Config (options + defaults).
* @package PrVision
*/
class PRV_Model_Registry {
/**
* Current schema version stored in prv_models_schema_version.
*/
const SCHEMA_VERSION = 2;
/**
* Option key for the models array.
*/
const OPTION_KEY = 'prv_models';
/**
* Option key for the models schema version.
*/
const VERSION_KEY = 'prv_models_schema_version';
/**
* Run the migration from v1 (flat strings) to v2 (rich objects).
*
* Idempotent: checks prv_models_schema_version before acting; safe to call
* on every plugins_loaded. Preserves the live model set -- existing model
* slugs are carried forward with enabled=true and health_status=unknown.
*
* Side effects: May update prv_models and prv_models_schema_version options.
*
* @return bool True when a migration was performed, false when already current.
*/
public static function run_migration_v2(): bool {
$current_version = (int) get_option( self::VERSION_KEY, 0 );
if ( $current_version >= self::SCHEMA_VERSION ) {
return false;
}
$existing = get_option( self::OPTION_KEY, array() );
$migrated = array();
if ( is_array( $existing ) && ! empty( $existing ) ) {
foreach ( $existing as $item ) {
if ( is_string( $item ) ) {
// v1 flat string -- upgrade to rich object.
$migrated[] = self::make_model_object( $item, '', true );
} elseif ( is_array( $item ) && isset( $item['slug'] ) ) {
// Already a v2 object -- pass through.
$migrated[] = self::ensure_model_fields( $item );
}
}
}
if ( empty( $migrated ) ) {
$migrated = self::default_model_objects();
}
update_option( self::OPTION_KEY, $migrated );
update_option( self::VERSION_KEY, self::SCHEMA_VERSION );
return true;
}
/**
* Get all models as rich-object arrays.
*
* @return array<int, array<string, mixed>> Model objects.
*/
public static function get_all(): array {
$raw = get_option( self::OPTION_KEY, array() );
if ( ! is_array( $raw ) || empty( $raw ) ) {
return self::default_model_objects();
}
return array_values( array_filter( $raw, 'is_array' ) );
}
/**
* Get only enabled model slugs (for backward compat with PRV_Config).
*
* @return array<int, string>
*/
public static function get_enabled_slugs(): array {
$models = self::get_all();
$slugs = array();
foreach ( $models as $m ) {
if ( ! empty( $m['enabled'] ) ) {
$slugs[] = (string) $m['slug'];
}
}
return $slugs;
}
/**
* Find a single model by id.
*
* @param string $id Model id.
*
* @return array<string, mixed>|null Null if not found.
*/
public static function find_by_id( string $id ): ?array {
foreach ( self::get_all() as $m ) {
if ( isset( $m['id'] ) && $m['id'] === $id ) {
return $m;
}
}
return null;
}
/**
* Add a new model.
*
* @param string $slug OpenRouter model slug.
* @param string $provider Provider name (e.g. "openrouter", "perplexity").
* @param bool $enabled Whether to enable on creation.
* @param string $note Optional operator note.
*
* @return string New model id.
*/
public static function add( string $slug, string $provider, bool $enabled = true, string $note = '' ): string {
$models = self::get_all();
$obj = self::make_model_object( $slug, $provider, $enabled, $note );
$models[] = $obj;
update_option( self::OPTION_KEY, $models );
return (string) $obj['id'];
}
/**
* Update an existing model by id.
*
* @param string $id Model id.
* @param array<string, mixed> $data Fields to update (slug, provider, enabled, note).
*
* @return bool True if found and updated.
*/
public static function update( string $id, array $data ): bool {
$models = self::get_all();
$updated = false;
foreach ( $models as &$m ) {
if ( isset( $m['id'] ) && $m['id'] === $id ) {
foreach ( array( 'slug', 'provider', 'enabled', 'note' ) as $field ) {
if ( array_key_exists( $field, $data ) ) {
$m[ $field ] = $data[ $field ];
}
}
$updated = true;
break;
}
}
unset( $m );
if ( $updated ) {
update_option( self::OPTION_KEY, $models );
}
return $updated;
}
/**
* Remove a model by id.
*
* @param string $id Model id.
*
* @return bool True if found and removed.
*/
public static function remove( string $id ): bool {
$models = self::get_all();
$filtered = array_values( array_filter( $models, static fn( $m ) => ( $m['id'] ?? '' ) !== $id ) );
if ( count( $filtered ) === count( $models ) ) {
return false;
}
update_option( self::OPTION_KEY, $filtered );
return true;
}
/**
* Update per-model run-health after a completed run.
*
* Called by PRV_Probe_Runner at the end of a run with per-slug outcome
* counts. Sets health_status to 'healthy' or 'retired'.
*
* Side effects: Updates prv_models option.
*
* @param string $run_id UUID of completed run.
* @param array<string, array{probed: int, errors: int}> $outcomes Per-slug outcome.
*
* @return void
*/
public static function update_health( string $run_id, array $outcomes ): void {
$models = self::get_all();
foreach ( $models as &$m ) {
$slug = (string) ( $m['slug'] ?? '' );
if ( empty( $m['enabled'] ) ) {
$m['health_status'] = 'disabled';
continue;
}
if ( ! isset( $outcomes[ $slug ] ) ) {
continue;
}
$o = $outcomes[ $slug ];
$m['health_probed'] = (int) $o['probed'];
$m['health_errors'] = (int) $o['errors'];
$m['health_run_id'] = $run_id;
$m['health_status'] = ( 0 === $o['probed'] && $o['errors'] > 0 ) ? 'retired' : 'healthy';
}
unset( $m );
update_option( self::OPTION_KEY, $models );
}
/**
* Build a new model object with all required fields.
*
* @param string $slug Model slug.
* @param string $provider Provider name.
* @param bool $enabled Enabled flag.
* @param string $note Operator note.
*
* @return array<string, mixed>
*/
private static function make_model_object(
string $slug,
string $provider = '',
bool $enabled = true,
string $note = ''
): array {
if ( '' === $provider ) {
$provider = ( 'perplexity/sonar' === $slug ) ? 'perplexity' : 'openrouter';
}
return array(
'id' => self::generate_id(),
'slug' => $slug,
'provider' => $provider,
'enabled' => $enabled,
'note' => $note,
'health_status' => 'unknown',
'health_probed' => 0,
'health_errors' => 0,
'health_run_id' => null,
);
}
/**
* Ensure all v2 fields exist on an already-rich object (forward compat).
*
* @param array<string, mixed> $m Existing model array.
*
* @return array<string, mixed>
*/
private static function ensure_model_fields( array $m ): array {
$defaults = array(
'id' => self::generate_id(),
'provider' => 'openrouter',
'enabled' => true,
'note' => '',
'health_status' => 'unknown',
'health_probed' => 0,
'health_errors' => 0,
'health_run_id' => null,
);
return array_merge( $defaults, $m );
}
/**
* Default model objects matching v0.1.x defaults.
*
* @return array<int, array<string, mixed>>
*/
private static function default_model_objects(): array {
return array(
self::make_model_object( 'perplexity/sonar', 'perplexity', true, 'Primary citation signal -- real-web retrieval' ),
self::make_model_object( 'openai/gpt-4o-search-preview', 'openrouter', true, 'GPT search breadth' ),
self::make_model_object( 'google/gemini-2.0-flash-001', 'openrouter', true, 'Gemini breadth -- verify slug active before use' ),
);
}
/**
* Generate a short unique id for a model object.
*
* @return string
*/
private static function generate_id(): string {
return sprintf( 'mdl_%s', substr( md5( uniqid( '', true ) ), 0, 12 ) );
}
}

View File

@@ -0,0 +1,95 @@
<?php
/**
* AJAX handler: Test model slug validity.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* AJAX handler for the "Test" model button.
*
* Performs a single cheap probe to verify a model slug resolves at the
* provider. Rate-limited to one test per slug per 30 seconds via transient.
* This is a point-in-time check (not a monitor) -- the UI labels it as such.
* Never displays the API key.
*
* Who triggers: wp_ajax_prv_test_model (registered by PRV_Settings_Page).
* Dependencies: PRV_Model_Registry, PRV_Perplexity_Provider, PRV_OpenRouter_Provider.
*
* @see class-prv-settings-page.php -- Registers the wp_ajax_ hook.
* @see class-prv-model-registry.php -- Provides model data by id.
* @package PrVision
*/
class PRV_Model_Test_Ajax {
/**
* Rate-limit window in seconds.
*/
const RATE_LIMIT_SECONDS = 30;
/**
* Handle the prv_test_model AJAX request.
*
* Verifies capability + nonce, rate-limits, performs one probe, returns JSON.
*
* Side effects: HTTP call to LLM provider; sets a short-lived transient; exits.
*
* @return void
*/
public function handle(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => 'Insufficient permissions.' ) );
}
$nonce = isset( $_POST['prv_nonce'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['prv_nonce'] ) ) : '';
if ( ! wp_verify_nonce( $nonce, PRV_Settings_Page::NONCE_TEST ) ) {
wp_send_json_error( array( 'message' => 'Security check failed.' ) );
}
$model_id = isset( $_POST['model_id'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['model_id'] ) ) : '';
$model = PRV_Model_Registry::find_by_id( $model_id );
if ( null === $model ) {
wp_send_json_error( array( 'message' => 'Model not found.' ) );
}
$slug = (string) $model['slug'];
$lock_key = 'prv_test_lock_' . md5( $slug );
if ( false !== get_transient( $lock_key ) ) {
wp_send_json_error( array( 'message' => 'Rate limited -- wait 30 s between tests.' ) );
}
set_transient( $lock_key, 1, self::RATE_LIMIT_SECONDS );
$start = microtime( true );
$provider = ( 'perplexity/sonar' === $slug )
? new PRV_Perplexity_Provider()
: new PRV_OpenRouter_Provider( $slug );
if ( ! $provider->is_configured() ) {
wp_send_json_error( array( 'message' => 'API key not configured (check wp-config.php).' ) );
}
try {
$provider->probe( 'what is BPC-157' );
$ms = (int) round( ( microtime( true ) - $start ) * 1000 );
wp_send_json_success(
array(
'resolved' => true,
'latency_ms' => $ms,
'slug' => $slug,
'message' => sprintf( 'Resolved · %d ms', $ms ),
)
);
} catch ( \Exception $e ) {
wp_send_json_error(
array(
'resolved' => false,
'slug' => $slug,
'message' => 'Not resolved -- provider returned error (slug retired?)',
)
);
}
}
}

View File

@@ -10,14 +10,16 @@ declare(strict_types=1);
/**
* Main orchestrator for the PR Vision plugin.
*
* Boots sub-systems in the correct order, hooks into WP, and keeps
* the top-level file under the 300-line limit by delegating all logic
* to specialist classes.
* Boots sub-systems in the correct order, runs pending data migrations,
* hooks into WP, and keeps the top-level file under the 300-line limit by
* delegating all logic to specialist classes.
*
* Who triggers: plugins_loaded action (pr-vision.php).
* Dependencies: PRV_Cron, PRV_Admin_Page, PRV_Collector_Registry.
* Dependencies: PRV_Upgrader, PRV_Cron, PRV_Admin_Page, PRV_Settings_Page,
* PRV_Collector_Registry.
*
* @see ARCHITECTURE.md — Boot sequence diagram.
* @see ARCHITECTURE.md -- Boot sequence diagram.
* @see class-prv-upgrader.php -- Runs migrations on every boot.
* @package PrVision
*/
class PRV_Plugin {
@@ -25,18 +27,28 @@ class PRV_Plugin {
/**
* Wire up all WordPress hooks.
*
* Side effects: Registers admin menus, enqueues assets, and registers the
* collector/panel registry.
* Migrations run before anything reads from prv_models so the v2 format
* is always in place regardless of whether the plugin was freshly activated
* or just upgraded from v0.1.x while live.
*
* Side effects: Registers admin menus, enqueues assets, registers the
* collector/panel registry, runs data migrations.
*
* @return void
*/
public function init(): void {
// Migrations first -- must run before any config read.
PRV_Upgrader::run();
$cron = new PRV_Cron();
$cron->register_hooks();
if ( is_admin() ) {
$page = new PRV_Admin_Page();
$page->register_hooks();
$settings = new PRV_Settings_Page();
$settings->register_hooks();
}
// Register the v1 AI-visibility collector + panel.

View File

@@ -8,22 +8,25 @@
declare(strict_types=1);
/**
* Orchestrates a full probe run: peptides × intents × models.
* Orchestrates a full probe run: peptides x intents x models.
*
* For each combination the runner:
* 1. Checks the monthly budget cap (aborts gracefully when hit).
* 2. Dispatches to the correct provider (resolved from PRV_Config::get_models()).
* 3. Writes a row to the prv_ai_visibility table.
* 4. Settles the actual cost against the ledger row.
* 1. Acquires the run-lock (fails fast if already locked).
* 2. Checks the monthly budget cap (aborts gracefully when hit).
* 3. Dispatches to the correct provider (resolved from PRV_Model_Registry).
* 4. Writes a row to the prv_ai_visibility table with config_version stamp.
* 5. Settles the actual cost against the ledger row.
* 6. After the run, updates per-model health and releases the lock.
*
* Who triggers: PRV_Cron::handle_cron_tick() and PRV_Admin_Page (Run now action).
* Dependencies: PRV_Config, PRV_Cost_Ledger, PRV_Table_Manager, all providers.
* 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.
*
* @see class-prv-cron.php — Calls run() on the weekly schedule.
* @see class-prv-cost-ledger.php — Budget cap enforcement.
* @see class-prv-perplexity-provider.php
* @see class-prv-openrouter-provider.php
* @see ARCHITECTURE.md — §Probe run flow.
* @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.
* @package PrVision
*/
class PRV_Probe_Runner {
@@ -47,27 +50,69 @@ class PRV_Probe_Runner {
/**
* Execute the full probe run.
*
* Iterates peptides × prompt intents × models. Aborts at the budget cap
* without throwing — partial results are kept. Each successful probe is
* 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.
*
* Side effects: HTTP calls to LLM APIs, database writes.
* Side effects: HTTP calls to LLM APIs, database writes, option writes (health).
*
* @return array{probed: int, skipped_budget: int, skipped_error: int}
* Summary counts for the admin log.
* @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 {
$run_id = $this->generate_run_id();
$peptides = PRV_Config::get_peptides();
$intents = PRV_Config::get_prompt_intents();
$models = PRV_Config::get_models();
$counts = array(
'probed' => 0,
'skipped_budget' => 0,
'skipped_error' => 0,
'truncated' => false,
'run_id' => '',
);
if ( ! PRV_Run_Lock::acquire() ) {
$counts['skipped_error'] = -1; // Sentinel: lock busy.
return $counts;
}
try {
$counts = $this->execute_run();
} finally {
PRV_Run_Lock::release();
}
return $counts;
}
/**
* Core run logic (called inside the lock).
*
* @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}.
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 );
@@ -77,6 +122,9 @@ class PRV_Probe_Runner {
if ( null === $provider || ! $provider->is_configured() ) {
++$counts['skipped_error'];
if ( isset( $model_outcomes[ $model ] ) ) {
++$model_outcomes[ $model ]['errors'];
}
continue;
}
@@ -84,6 +132,7 @@ class PRV_Probe_Runner {
$estimated = $this->get_estimated_cost( $model );
if ( ! $this->ledger->can_afford( $estimated ) ) {
++$counts['skipped_budget'];
$budget_hit = true;
continue;
}
@@ -93,34 +142,70 @@ class PRV_Probe_Runner {
// 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 );
$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.
* Persist a single probe result to the database with config_version stamp.
*
* @param string $run_id UUID for this run.
* @param array<string,string> $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 {
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();
@@ -140,13 +225,31 @@ class PRV_Probe_Runner {
'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' )
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<string, mixed> $counts Run counts.
* @param array<string, array{probed: int, errors: int}> $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.
*

View File

@@ -0,0 +1,94 @@
<?php
/**
* Run-lock: prevents concurrent cron + Run-now executions.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Distributed run-lock using a WP transient.
*
* Ensures exactly one probe run (cron or manual) executes at a time.
* Acquiring the lock is atomic via the transient API with a TTL so stale
* locks (e.g. from a crashed run) self-expire after PRV_RUN_LOCK_TTL_SECONDS.
*
* Who triggers: PRV_Probe_Runner::run() acquires; PRV_Admin_Page "Run now"
* handler checks before dispatching.
* Dependencies: set_transient(), get_transient(), delete_transient().
*
* @see class-prv-probe-runner.php -- Acquires + releases lock around the run.
* @see class-prv-settings-page.php -- Checks is_locked() before Run now.
* @package PrVision
*/
class PRV_Run_Lock {
/**
* Transient key for the run lock.
*/
const LOCK_KEY = 'prv_run_lock';
/**
* Lock TTL in seconds. Should exceed the longest plausible run.
*/
const TTL_SECONDS = 3600; // 1 hour.
/**
* Attempt to acquire the run lock.
*
* Returns true only if the lock was not already held. Uses a non-blocking
* check-and-set approach: if the transient already exists the lock is held.
*
* Side effects: Sets a WP transient when successful.
*
* @return bool True if lock acquired; false if already held.
*/
public static function acquire(): bool {
if ( false !== get_transient( self::LOCK_KEY ) ) {
return false; // Already locked.
}
// set_transient returns false if the transient already exists (on some hosts);
// re-check after set to guard against the race window.
set_transient( self::LOCK_KEY, time(), self::TTL_SECONDS );
// Brief window for a second writer -- re-check.
$val = get_transient( self::LOCK_KEY );
return false !== $val;
}
/**
* Release the run lock.
*
* Should be called in a finally block after every run, whether successful
* or not, to prevent lock starvation on non-fatal errors.
*
* Side effects: Deletes the WP transient.
*
* @return void
*/
public static function release(): void {
delete_transient( self::LOCK_KEY );
}
/**
* Check whether a run is currently in progress.
*
* @return bool True when locked.
*/
public static function is_locked(): bool {
return false !== get_transient( self::LOCK_KEY );
}
/**
* Return approximate seconds since the lock was acquired.
*
* @return int|null Null if not locked.
*/
public static function locked_since(): ?int {
$val = get_transient( self::LOCK_KEY );
if ( false === $val ) {
return null;
}
return (int) ( time() - (int) $val );
}
}

View File

@@ -0,0 +1,217 @@
<?php
/**
* PR Vision settings POST-action controller.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Handles the four settings POST actions: save config, and model CRUD.
*
* Extracted from PRV_Settings_Page to keep each file under the 300-line
* limit. PRV_Settings_Page registers all hooks and delegates the four
* admin_post_* handlers here.
*
* All actions require: current_user_can('manage_options') + valid nonce
* (enforced by PRV_Settings_Page::require_admin_nonce(), passed in via
* the $nonce_cb callable).
*
* Who triggers: PRV_Settings_Page -- via delegation in register_hooks().
* Dependencies: PRV_Config, PRV_Config_Version, PRV_Cron,
* PRV_Model_Registry.
*
* @see class-prv-settings-page.php -- Page registration, dispatch, Run-now, AJAX.
* @see class-prv-model-registry.php -- CRUD for prv_models.
* @see class-prv-config-version.php -- Config-change versioning.
* @see class-prv-cron.php -- Reschedule on cadence change.
* @package PrVision
*/
class PRV_Settings_Controller {
/** Admin menu slug (shared constant with PRV_Settings_Page). */
const MENU_SLUG = 'pr-vision-settings';
/**
* Nonce-verification callback supplied by PRV_Settings_Page.
*
* @var callable(): void
*/
private $nonce_cb;
/**
* Constructor.
*
* @param callable $nonce_cb Called with a nonce-action string; wp_die on failure.
*/
public function __construct( callable $nonce_cb ) {
$this->nonce_cb = $nonce_cb;
}
/**
* Handle the settings save POST action.
*
* Validates, sanitizes, saves peptides/intents/cadence/budget. Bumps
* config-version if scoring-relevant fields changed. Reschedules cron
* if cadence changed.
*
* Side effects: Updates wp_options, may reschedule cron, redirects.
*
* @return void
*/
public function handle_save(): void {
( $this->nonce_cb )( PRV_Settings_Page::NONCE_SAVE );
$old_cadence = PRV_Config::get_cadence();
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce().
$budget = isset( $_POST['prv_monthly_budget_usd'] )
? 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 );
$cadence_raw = isset( $_POST['prv_cadence'] )
? sanitize_text_field( wp_unslash( (string) $_POST['prv_cadence'] ) )
: 'weekly';
$cadence = in_array( $cadence_raw, array( 'weekly', 'daily', 'twicedaily' ), true ) ? $cadence_raw : 'weekly';
update_option( PRV_Config::CADENCE_KEY, $cadence );
if ( ! empty( $_POST['prv_peptides_json'] ) ) {
$raw_json = sanitize_textarea_field( wp_unslash( (string) $_POST['prv_peptides_json'] ) );
$decoded = json_decode( $raw_json, true );
if ( is_array( $decoded ) ) {
$clean = array();
foreach ( $decoded as $p ) {
if ( isset( $p['slug'], $p['label'] ) ) {
$clean[] = array(
'slug' => sanitize_text_field( (string) $p['slug'] ),
'label' => sanitize_text_field( (string) $p['label'] ),
);
}
}
update_option( 'prv_peptides', $clean );
}
}
if ( isset( $_POST['prv_prompt_intents_text'] ) ) {
$raw_intents = sanitize_textarea_field( wp_unslash( (string) $_POST['prv_prompt_intents_text'] ) );
$intent_lines = array_filter( array_map( 'trim', explode( "\n", $raw_intents ) ) );
update_option( 'prv_prompt_intents', array_values( $intent_lines ) );
}
// phpcs:enable WordPress.Security.NonceVerification.Missing
PRV_Config_Version::bump_version_if_changed();
if ( $cadence !== $old_cadence ) {
PRV_Cron::reschedule( $cadence );
}
wp_safe_redirect(
add_query_arg(
array(
'page' => self::MENU_SLUG,
'prv_saved' => 1,
),
admin_url( 'admin.php' )
)
);
exit;
}
/**
* Handle Add model POST.
*
* Side effects: Adds to prv_models; may bump config-version; redirects.
*
* @return void
*/
public function handle_model_add(): void {
( $this->nonce_cb )( PRV_Settings_Page::NONCE_MODEL );
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce().
$slug = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_slug'] ?? '' ) ) );
$provider = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_provider'] ?? 'openrouter' ) ) );
$note = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_note'] ?? '' ) ) );
$enabled = ! empty( $_POST['prv_model_enabled'] );
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( '' !== $slug ) {
PRV_Model_Registry::add( $slug, $provider, $enabled, $note );
PRV_Config_Version::bump_version_if_changed();
}
wp_safe_redirect(
add_query_arg(
array(
'page' => self::MENU_SLUG,
'prv_model_added' => 1,
),
admin_url( 'admin.php' )
)
);
exit;
}
/**
* Handle Update model POST.
*
* Side effects: Updates prv_models; may bump config-version; redirects.
*
* @return void
*/
public function handle_model_update(): void {
( $this->nonce_cb )( PRV_Settings_Page::NONCE_MODEL );
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce().
$id = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_id'] ?? '' ) ) );
$slug = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_slug'] ?? '' ) ) );
$enabled = ! empty( $_POST['prv_model_enabled'] );
$note = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_note'] ?? '' ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( '' !== $id && '' !== $slug ) {
PRV_Model_Registry::update(
$id,
array(
'slug' => $slug,
'enabled' => $enabled,
'note' => $note,
)
);
PRV_Config_Version::bump_version_if_changed();
}
wp_safe_redirect(
add_query_arg(
array(
'page' => self::MENU_SLUG,
'prv_model_updated' => 1,
),
admin_url( 'admin.php' )
)
);
exit;
}
/**
* Handle Remove model POST.
*
* Side effects: Removes from prv_models; may bump config-version; redirects.
*
* @return void
*/
public function handle_model_remove(): void {
( $this->nonce_cb )( PRV_Settings_Page::NONCE_MODEL );
// phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce().
$id = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_id'] ?? '' ) ) );
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( '' !== $id ) {
PRV_Model_Registry::remove( $id );
PRV_Config_Version::bump_version_if_changed();
}
wp_safe_redirect(
add_query_arg(
array(
'page' => self::MENU_SLUG,
'prv_model_removed' => 1,
),
admin_url( 'admin.php' )
)
);
exit;
}
}

View File

@@ -0,0 +1,193 @@
<?php
/**
* PR Vision admin settings page: model manager + config surface.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* PR Vision Settings admin page.
*
* Registers "PR Vision > Settings" sub-menu, dispatches POST actions to
* PRV_Settings_Controller, handles Run-now and AJAX test-model, and
* delegates all HTML rendering to PRV_Settings_Renderer.
*
* All actions require: current_user_can('manage_options') + valid nonce.
*
* Who triggers: PRV_Plugin::init() -- is_admin() guard.
* Dependencies: PRV_Settings_Controller, PRV_Settings_Renderer,
* PRV_Model_Test_Ajax, PRV_Probe_Runner, PRV_Run_Lock.
*
* @see class-prv-settings-controller.php -- POST handler implementations.
* @see class-prv-settings-renderer.php -- Renders the HTML.
* @see class-prv-model-test-ajax.php -- AJAX test handler.
* @see class-prv-model-registry.php -- CRUD for prv_models.
* @see class-prv-config-version.php -- Config-change versioning.
* @see class-prv-cron.php -- Reschedule on cadence change.
* @package PrVision
*/
class PRV_Settings_Page {
/** Admin menu slug for the settings page. */
const MENU_SLUG = 'pr-vision-settings';
/** Nonce action for settings save. */
const NONCE_SAVE = 'prv_settings_save';
/** Nonce action for model CRUD. */
const NONCE_MODEL = 'prv_model_action';
/** Nonce action for Run now on settings page. */
const NONCE_RUN = 'prv_settings_run_now';
/** Nonce action for Test model AJAX. */
const NONCE_TEST = 'prv_model_test';
/**
* Register WordPress admin hooks.
*
* POST handlers for save/model-CRUD are delegated to PRV_Settings_Controller,
* which receives a nonce-verification callback bound to this page.
*
* Side effects: Adds admin_menu, admin_post, and wp_ajax actions.
*
* @return void
*/
public function register_hooks(): void {
$ctrl = new PRV_Settings_Controller(
function ( string $action ): void {
$this->require_admin_nonce( $action );
}
);
add_action( 'admin_menu', array( $this, 'add_settings_menu' ) );
add_action( 'admin_post_prv_settings_save', array( $ctrl, 'handle_save' ) );
add_action( 'admin_post_prv_model_add', array( $ctrl, 'handle_model_add' ) );
add_action( 'admin_post_prv_model_update', array( $ctrl, 'handle_model_update' ) );
add_action( 'admin_post_prv_model_remove', array( $ctrl, 'handle_model_remove' ) );
add_action( 'admin_post_prv_settings_run_now', array( $this, 'handle_run_now' ) );
add_action( 'wp_ajax_prv_test_model', array( $this, 'handle_test_model' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
}
/**
* Register the Settings sub-menu under PR Vision.
*
* Side effects: Adds a WP admin sub-menu entry.
*
* @return void
*/
public function add_settings_menu(): void {
add_submenu_page(
'pr-vision',
__( 'PR Vision Settings', 'pr-vision' ),
__( 'Settings', 'pr-vision' ),
'manage_options',
self::MENU_SLUG,
array( $this, 'render_page' )
);
}
/**
* Enqueue settings page Chart.js.
*
* @param string $hook Current admin page hook.
*
* @return void
*/
public function enqueue_assets( string $hook ): void {
if ( false === strpos( $hook, self::MENU_SLUG ) ) {
return;
}
wp_enqueue_script(
'prv-chartjs',
'https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js',
array(),
'4.4.3',
true
);
}
/**
* Handle Run now POST from the settings page.
*
* Refuses if a run is already in progress.
*
* Side effects: May trigger probe run; redirects.
*
* @return void
*/
public function handle_run_now(): void {
$this->require_admin_nonce( self::NONCE_RUN );
if ( PRV_Run_Lock::is_locked() ) {
wp_safe_redirect(
add_query_arg(
array(
'page' => self::MENU_SLUG,
'prv_run_locked' => 1,
),
admin_url( 'admin.php' )
)
);
exit;
}
$runner = new PRV_Probe_Runner();
$counts = $runner->run();
$args = array(
'page' => self::MENU_SLUG,
'prv_run_done' => 1,
'prv_probed' => (int) $counts['probed'],
'prv_skipped' => (int) ( $counts['skipped_budget'] + max( 0, $counts['skipped_error'] ) ),
'prv_truncated' => $counts['truncated'] ? 1 : 0,
);
wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ) );
exit;
}
/**
* Dispatch the AJAX test-model request to PRV_Model_Test_Ajax.
*
* Side effects: Exits via PRV_Model_Test_Ajax::handle().
*
* @return void
*/
public function handle_test_model(): void {
( new PRV_Model_Test_Ajax() )->handle();
}
/**
* Render the settings page.
*
* Side effects: Outputs HTML via PRV_Settings_Renderer.
*
* @return void
*/
public function render_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Insufficient permissions.', 'pr-vision' ) );
}
( new PRV_Settings_Renderer() )->render();
}
/**
* Verify admin capability and nonce; wp_die on failure.
*
* Called directly for Run-now, and passed as a callback to
* PRV_Settings_Controller for the four POST handlers.
*
* @param string $action Nonce action.
*
* @return void
*/
public function require_admin_nonce( string $action ): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Insufficient permissions.', 'pr-vision' ), 403 );
}
$nonce = isset( $_POST['prv_nonce'] ) ? sanitize_text_field( wp_unslash( (string) $_POST['prv_nonce'] ) ) : '';
if ( ! wp_verify_nonce( $nonce, $action ) ) {
wp_die( esc_html__( 'Security check failed.', 'pr-vision' ), 403 );
}
}
}

View File

@@ -0,0 +1,298 @@
<?php
/**
* Settings page HTML renderer for PR Vision.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Renders the PR Vision Settings admin page (dark "Assay" theme).
*
* Coordinates the page sections: styles, notices, model manager card
* (via PRV_Model_Manager_Table), secondary config form, sticky save-bar,
* and inline JS. All output is escaped at the point of emission.
*
* Who triggers: PRV_Settings_Page::render_page().
* Dependencies: PRV_Model_Registry, PRV_Config, PRV_Cron, PRV_Run_Lock,
* PRV_Config_Version, PRV_Model_Manager_Table.
*
* @see class-prv-settings-page.php -- Instantiates and calls render().
* @see class-prv-model-manager-table.php -- Renders the model table.
* @package PrVision
*/
class PRV_Settings_Renderer {
/**
* Render the full settings page.
*
* Side effects: Outputs HTML directly.
*
* @return void
*/
public function render(): void {
$this->render_styles();
echo '<div class="prv-settings-wrap">';
echo '<h1 class="prv-page-title">' . esc_html__( 'PR Vision — Settings', 'pr-vision' ) . '</h1>';
$this->render_notices();
echo '<div class="prv-card"><h2>' . esc_html__( 'Model Manager', 'pr-vision' ) . '</h2>';
( new PRV_Model_Manager_Table() )->render();
echo '</div>';
$this->render_config_form();
$this->render_save_bar();
$this->render_scripts();
echo '</div>';
}
/**
* Render dark CSS variables and component styles.
*
* @return void
*/
private function render_styles(): void {
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '<style>
:root{--prv-bg:#14181C;--prv-surface:#1C2228;--prv-surface2:#232B33;--prv-surface3:#2A333C;
--prv-border:#333D47;--prv-border-strong:#3A4651;--prv-border-bright:#46535F;
--prv-text:#EEF2F5;--prv-text-sec:#C2CCD6;--prv-text-muted:#9AA7B2;--prv-ink:#0D1116;
--prv-teal:#34C0CA;--prv-teal-hover:#4ACCD5;--prv-lime:#9BE635;
--prv-orange:#FFB36E;--prv-red:#FF6B5E;--prv-red-chip:#FF8A80;
--prv-gray-absent:#8893A0;--prv-chip-text-disabled:#B4BDC7;
--prv-tint-red:rgba(255,107,94,.13);--prv-tint-orange:rgba(255,179,110,.12);
--prv-tint-lime:rgba(155,230,53,.12);--prv-tint-gray:rgba(136,147,160,.12);}
.prv-settings-wrap{background:var(--prv-bg);color:var(--prv-text);padding:24px;font-family:Inter,sans-serif;min-height:600px;}
.prv-page-title{color:var(--prv-text);font-family:Poppins,sans-serif;font-size:24px;font-weight:700;margin:0 0 20px;}
.prv-card{background:var(--prv-surface);border:1px solid var(--prv-border-strong);border-radius:8px;padding:24px;margin-bottom:20px;}
.prv-card h2{color:var(--prv-text);font-size:16px;font-weight:600;margin:0 0 16px;}
.prv-table{width:100%;border-collapse:collapse;font-size:13px;}
.prv-table th{background:var(--prv-surface2);color:var(--prv-text-sec);font-weight:600;text-align:left;padding:8px 12px;border-bottom:1px solid var(--prv-border);}
.prv-table th button{background:none;border:none;color:inherit;cursor:pointer;font:inherit;padding:0;display:inline-flex;align-items:center;gap:4px;}
.prv-table th button:focus-visible{outline:2px solid var(--prv-teal);outline-offset:2px;}
.prv-table td{padding:8px 12px;border-bottom:1px solid var(--prv-border);color:var(--prv-text-sec);vertical-align:middle;}
.prv-row-retired{border-left:3px solid var(--prv-orange);background:repeating-linear-gradient(135deg,var(--prv-surface) 0,var(--prv-surface) 6px,var(--prv-surface2) 6px,var(--prv-surface2) 12px);}
.prv-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:500;}
.prv-badge-healthy{background:var(--prv-tint-lime);color:var(--prv-lime);}
.prv-badge-retired{background:var(--prv-tint-red);color:var(--prv-red-chip);}
.prv-badge-disabled{background:var(--prv-tint-gray);color:var(--prv-chip-text-disabled);}
.prv-badge-unknown{background:var(--prv-tint-gray);color:var(--prv-text-muted);}
.prv-btn{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;border-radius:6px;font-size:13px;cursor:pointer;border:none;font-weight:500;transition:background .12s;}
.prv-btn:focus-visible{outline:2px solid var(--prv-ink);outline-offset:2px;}
.prv-btn-primary{background:var(--prv-teal);color:var(--prv-ink);}
.prv-btn-primary:hover{background:var(--prv-teal-hover);}
.prv-btn-ghost{background:transparent;color:var(--prv-teal);border:1px solid var(--prv-border-bright);}
.prv-btn-ghost:hover{background:var(--prv-surface3);}
.prv-btn-danger{background:transparent;color:var(--prv-red-chip);border:1px solid var(--prv-border-bright);}
.prv-btn-danger:hover{background:var(--prv-tint-red);}
.prv-btn:disabled,.prv-btn[disabled]{opacity:.5;cursor:not-allowed;}
.prv-input{background:var(--prv-surface2);border:1px solid var(--prv-border-bright);border-radius:6px;color:var(--prv-text);padding:7px 10px;font-size:13px;width:100%;box-sizing:border-box;}
.prv-input:focus{outline:2px solid var(--prv-teal);outline-offset:1px;}
.prv-label{display:block;color:var(--prv-text-sec);font-size:12px;margin-bottom:4px;}
.prv-field{margin-bottom:14px;}
.prv-grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px;}
.prv-cost-inset{background:var(--prv-surface2);border:1px solid var(--prv-border);border-radius:6px;padding:12px;margin-top:8px;font-size:12px;color:var(--prv-text-sec);}
.prv-cost-inset.over-cap{border-color:var(--prv-orange);background:var(--prv-tint-orange);color:var(--prv-orange);}
.prv-api-status{padding:8px 12px;border-radius:6px;font-size:13px;display:inline-flex;align-items:center;gap:8px;}
.prv-api-ok{background:var(--prv-tint-lime);color:var(--prv-lime);}
.prv-api-fail{background:var(--prv-tint-red);color:var(--prv-red-chip);}
.prv-api-undef{background:var(--prv-tint-gray);color:var(--prv-text-muted);}
.prv-savebar{position:sticky;bottom:0;background:var(--prv-surface2);border-top:1px solid var(--prv-border-strong);padding:12px 24px;display:flex;align-items:center;gap:12px;z-index:50;}
.prv-savebar-warn{color:var(--prv-orange);font-size:12px;flex:1;display:none;}
.prv-savebar-warn.visible{display:block;}
.prv-notice{padding:10px 14px;border-radius:6px;font-size:13px;margin-bottom:14px;}
.prv-notice-success{background:var(--prv-tint-lime);color:var(--prv-lime);}
.prv-notice-warning{background:var(--prv-tint-orange);color:var(--prv-orange);}
.prv-section-label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--prv-text-muted);margin-bottom:8px;}
@media print{.prv-settings-wrap{background:#fff;color:#111;}.prv-card{background:#fff;border-color:#ccc;}.prv-savebar,.prv-btn-primary,.prv-btn-ghost,.prv-btn-danger{display:none!important;}.prv-table th{background:#f3f3f3;color:#333;}.prv-table td{color:#444;}}
</style>';
}
/**
* Render POST-redirect notices (saved, run done, model CRUD, lock).
*
* @return void
*/
private function render_notices(): void {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( ! empty( $_GET['prv_saved'] ) ) {
echo '<div class="prv-notice prv-notice-success">' . esc_html__( 'Settings saved.', 'pr-vision' ) . '</div>';
}
if ( ! empty( $_GET['prv_run_done'] ) ) {
$p = absint( $_GET['prv_probed'] ?? 0 );
$s = absint( $_GET['prv_skipped'] ?? 0 );
$trn = ! empty( $_GET['prv_truncated'] );
$msg = sprintf( 'Run complete: %d probed, %d skipped.%s', $p, $s, $trn ? ' Last run was truncated by the monthly budget cap.' : '' );
echo '<div class="prv-notice prv-notice-success">' . esc_html( $msg ) . '</div>';
}
if ( ! empty( $_GET['prv_run_locked'] ) ) {
echo '<div class="prv-notice prv-notice-warning">' . esc_html__( 'A run is already in progress — try again when it finishes.', 'pr-vision' ) . '</div>';
}
if ( ! empty( $_GET['prv_model_added'] ) ) {
echo '<div class="prv-notice prv-notice-success">' . esc_html__( 'Model added.', 'pr-vision' ) . '</div>';
}
if ( ! empty( $_GET['prv_model_updated'] ) ) {
echo '<div class="prv-notice prv-notice-success">' . esc_html__( 'Model updated.', 'pr-vision' ) . '</div>';
}
if ( ! empty( $_GET['prv_model_removed'] ) ) {
echo '<div class="prv-notice prv-notice-success">' . esc_html__( 'Model removed.', 'pr-vision' ) . '</div>';
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
}
/**
* Render the secondary config form.
*
* @return void
*/
private function render_config_form(): void {
echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" id="prv-config-form">';
echo '<input type="hidden" name="action" value="prv_settings_save">';
wp_nonce_field( PRV_Settings_Page::NONCE_SAVE, 'prv_nonce' );
echo '<div class="prv-card"><h2>' . esc_html__( 'Probe Configuration', 'pr-vision' ) . '</h2>';
$this->render_cadence_field();
$this->render_budget_field();
$this->render_projected_cost();
$this->render_peptides_field();
$this->render_intents_field();
$this->render_api_key_status();
echo '</div></form>';
}
/**
* Render cadence selector with next-run timestamp.
*
* @return void
*/
private function render_cadence_field(): void {
$cadence = PRV_Config::get_cadence();
$next_ts = PRV_Cron::next_run_timestamp();
$next_str = $next_ts ? gmdate( 'Y-m-d H:i T', (int) $next_ts ) : 'Not scheduled';
echo '<div class="prv-field"><label class="prv-label" for="prv_cadence">' . esc_html__( 'Probe cadence', 'pr-vision' ) . '</label>';
echo '<select class="prv-input" id="prv_cadence" name="prv_cadence" data-scoring-relevant="1" style="max-width:200px">';
$cadence_options = array(
'weekly' => 'Weekly',
'twicedaily' => 'Twice daily',
'daily' => 'Daily',
);
foreach ( $cadence_options as $v => $l ) {
echo '<option value="' . esc_attr( $v ) . '"' . selected( $cadence, $v, false ) . '>' . esc_html( $l ) . '</option>';
}
echo '</select>';
echo '<p style="color:var(--prv-text-muted);font-size:12px;margin:4px 0 0">' . esc_html__( 'Next run: ', 'pr-vision' ) . esc_html( $next_str ) . '</p></div>';
}
/**
* Render monthly budget cap input.
*
* @return void
*/
private function render_budget_field(): void {
$budget = PRV_Config::get_monthly_budget_usd();
echo '<div class="prv-field"><label class="prv-label" for="prv_monthly_budget_usd">' . esc_html__( 'Monthly budget cap (USD)', 'pr-vision' ) . '</label>';
echo '<input class="prv-input" style="max-width:160px;" type="number" min="0.01" step="0.01" id="prv_monthly_budget_usd" name="prv_monthly_budget_usd" value="' . esc_attr( number_format( $budget, 2, '.', '' ) ) . '" data-scoring-relevant="1"></div>';
}
/**
* Render live projected-cost inset panel.
*
* @return void
*/
private function render_projected_cost(): void {
$cost = PRV_Config::get_projected_cost();
$cls = $cost['over_cap'] ? 'prv-cost-inset over-cap' : 'prv-cost-inset';
echo '<div id="prv-cost-inset" class="' . esc_attr( $cls ) . '" aria-live="polite">';
echo '<strong>Projected cost:</strong> ~$' . esc_html( number_format( $cost['per_run_usd'], 4 ) ) . '/run &nbsp;·&nbsp; ';
echo '~$' . esc_html( number_format( $cost['per_month_usd'], 4 ) ) . '/month &nbsp;·&nbsp; ';
echo esc_html( (string) $cost['probe_count'] ) . ' probes/run';
if ( $cost['over_cap'] ) {
echo ' &nbsp;<strong>&#9888; Over cap — runs will truncate</strong>';
}
echo '</div>';
}
/**
* Render peptide set textarea (JSON).
*
* @return void
*/
private function render_peptides_field(): void {
$peptides = PRV_Config::get_peptides();
echo '<div class="prv-field"><label class="prv-label">' . esc_html__( 'Peptide set', 'pr-vision' ) . '</label>';
echo '<p style="color:var(--prv-text-muted);font-size:12px;margin:0 0 6px">' . esc_html( count( $peptides ) ) . ' peptides (JSON array of {slug, label}).</p>';
echo '<textarea class="prv-input" name="prv_peptides_json" rows="5" style="font-family:monospace;font-size:12px;" data-scoring-relevant="1">' . esc_textarea( (string) wp_json_encode( $peptides ) ) . '</textarea></div>';
}
/**
* Render prompt intents textarea.
*
* @return void
*/
private function render_intents_field(): void {
$intents = PRV_Config::get_prompt_intents();
echo '<div class="prv-field"><label class="prv-label" for="prv_prompt_intents_text">' . esc_html__( 'Prompt intents (one per line, use {peptide})', 'pr-vision' ) . '</label>';
echo '<textarea class="prv-input" id="prv_prompt_intents_text" name="prv_prompt_intents_text" rows="4" data-scoring-relevant="1">' . esc_textarea( implode( "\n", $intents ) ) . '</textarea></div>';
}
/**
* Render the read-only API-key three-state status.
*
* @return void
*/
private function render_api_key_status(): void {
$defined = defined( 'PRV_OPENROUTER_API_KEY' ) && '' !== constant( 'PRV_OPENROUTER_API_KEY' );
$status = (string) get_option( 'prv_api_key_status', 'unknown' );
echo '<div class="prv-field"><div class="prv-section-label">' . esc_html__( 'API Key Status (read-only)', 'pr-vision' ) . '</div>';
if ( ! $defined ) {
echo '<span class="prv-api-status prv-api-undef">&#9679; ' . esc_html__( 'Not defined — set PRV_OPENROUTER_API_KEY in wp-config.php', 'pr-vision' ) . '</span>';
} elseif ( 'failed' === $status ) {
echo '<span class="prv-api-status prv-api-fail">&#9679; ' . esc_html__( 'Defined — last run failed (key may be invalid or revoked)', 'pr-vision' ) . '</span>';
} else {
echo '<span class="prv-api-status prv-api-ok">&#9679; ' . esc_html__( 'Defined — last run OK', 'pr-vision' ) . '</span>';
}
echo '</div>';
}
/**
* Render the sticky save-bar with scoring-change warning and Run now.
*
* @return void
*/
private function render_save_bar(): void {
echo '<div class="prv-savebar" id="prv-savebar">';
echo '<button type="submit" form="prv-config-form" class="prv-btn prv-btn-primary">' . esc_html__( 'Save settings', 'pr-vision' ) . '</button>';
echo '<span id="prv-savebar-warn" class="prv-savebar-warn" role="alert"></span>';
echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '" id="prv-run-form" style="margin:0">';
echo '<input type="hidden" name="action" value="prv_settings_run_now">';
wp_nonce_field( PRV_Settings_Page::NONCE_RUN, 'prv_nonce' );
$locked = PRV_Run_Lock::is_locked();
echo '<button type="submit" id="prv-run-btn" class="prv-btn prv-btn-ghost"' . ( $locked ? ' disabled' : '' ) . '>';
echo $locked ? esc_html__( 'Run in progress…', 'pr-vision' ) : esc_html__( 'Run now', 'pr-vision' );
echo '</button>';
echo '</form>';
$next_ts = PRV_Cron::next_run_timestamp();
if ( $next_ts ) {
echo '<span style="color:var(--prv-text-muted);font-size:12px">' . esc_html__( 'Next auto-run: ', 'pr-vision' ) . esc_html( gmdate( 'Y-m-d H:i T', (int) $next_ts ) ) . '</span>';
}
echo '</div>';
}
/**
* Render inline JS: scoring-change warn, sort headers, Test AJAX, Run-now guard.
*
* @return void
*/
private function render_scripts(): void {
$ajax_url = admin_url( 'admin-ajax.php' );
$nonce = wp_create_nonce( PRV_Settings_Page::NONCE_TEST );
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '<script>(function(){
var ajaxUrl=' . wp_json_encode( $ajax_url ) . ',testNonce=' . wp_json_encode( $nonce ) . ';
var runFired=false;
var rf=document.getElementById("prv-run-form");
if(rf){rf.addEventListener("submit",function(e){if(runFired){e.preventDefault();return;}runFired=true;var b=document.getElementById("prv-run-btn");if(b){b.disabled=true;b.textContent="Running…";}});}
document.querySelectorAll("[data-scoring-relevant]").forEach(function(f){f.addEventListener("change",function(){var w=document.getElementById("prv-savebar-warn");if(w){w.textContent="Scoring-relevant change — this breaks comparison with prior runs. A new config version will be recorded.";w.classList.add("visible");}var s=document.querySelector(".prv-savebar .prv-btn-primary");if(s){s.textContent="Save anyway";}});});
document.querySelectorAll("th[aria-sort] button").forEach(function(btn){btn.addEventListener("click",function(){var th=btn.closest("th"),table=th.closest("table"),idx=Array.from(th.parentElement.children).indexOf(th);var asc=th.getAttribute("aria-sort")!=="ascending";table.querySelectorAll("th[aria-sort]").forEach(function(h){h.setAttribute("aria-sort","none");});th.setAttribute("aria-sort",asc?"ascending":"descending");var tb=table.querySelector("tbody"),rows=Array.from(tb.querySelectorAll("tr"));rows.sort(function(a,b){var ac=(a.cells[idx]||{}).textContent||"",bc=(b.cells[idx]||{}).textContent||"";return asc?ac.localeCompare(bc):bc.localeCompare(ac);});rows.forEach(function(r){tb.appendChild(r);});});});
document.querySelectorAll(".prv-test-btn").forEach(function(btn){btn.addEventListener("click",function(){var mid=btn.dataset.modelId,chip=document.getElementById("prv-test-chip-"+mid);btn.disabled=true;btn.setAttribute("aria-busy","true");if(chip){chip.textContent="Testing…";chip.className="prv-badge prv-badge-unknown";}fetch(ajaxUrl,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({action:"prv_test_model",model_id:mid,prv_nonce:testNonce})}).then(function(r){return r.json();}).then(function(d){btn.disabled=false;btn.removeAttribute("aria-busy");if(d.success){if(chip){chip.textContent="✓ "+d.data.message;chip.className="prv-badge prv-badge-healthy";}}else{if(chip){chip.textContent="✗ "+(d.data?d.data.message:"Failed");chip.className="prv-badge prv-badge-retired";}}}).catch(function(){btn.disabled=false;btn.removeAttribute("aria-busy");if(chip){chip.textContent="Error";chip.className="prv-badge prv-badge-retired";}});});});
})();</script>';
}
}

View File

@@ -14,11 +14,14 @@ declare(strict_types=1);
* schema version in the prv_schema_version option so future migrations can
* detect when an upgrade is needed without destructive ALTER TABLE.
*
* Who triggers: PRV_Activator::activate() on plugin activation.
* v0.2.0 adds config_version INT column (nullable; existing rows get NULL
* which is handled cleanly by scoring queries that filter by version).
*
* Who triggers: PRV_Upgrader::run() on every plugins_loaded call (idempotent).
* Dependencies: $wpdb global, dbDelta() (requires upgrade.php).
*
* @see class-prv-activator.php — Calls create_table() on activation.
* @see ARCHITECTURE.md — §Storage.
* @see class-prv-upgrader.php -- Calls create_table() on upgrade.
* @see ARCHITECTURE.md -- Section Storage.
* @package PrVision
*/
class PRV_Table_Manager {
@@ -28,11 +31,17 @@ class PRV_Table_Manager {
*/
const TABLE_BASE = 'prv_ai_visibility';
/**
* Current DB schema version.
*/
const SCHEMA_VERSION = 2;
/**
* Create or upgrade the AI-visibility table.
*
* Uses dbDelta to create or non-destructively alter the table.
* Records the schema version in an option afterwards.
* Safe to call on every plugins_loaded -- dbDelta is idempotent.
*
* Side effects: Writes to the database; sets prv_schema_version option.
*
@@ -45,22 +54,24 @@ class PRV_Table_Manager {
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table_name} (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
run_id VARCHAR(36) NOT NULL,
captured_at DATETIME NOT NULL,
peptide_slug VARCHAR(200) NOT NULL,
peptide_label VARCHAR(200) NOT NULL,
model VARCHAR(200) NOT NULL,
prompt_intent VARCHAR(200) NOT NULL,
cited TINYINT(1) NOT NULL DEFAULT 0,
our_position INT(11) NULL DEFAULT NULL,
source_domains LONGTEXT NOT NULL DEFAULT '',
raw_excerpt LONGTEXT NOT NULL DEFAULT '',
cost_usd DECIMAL(12,8) NOT NULL DEFAULT 0.00000000,
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
run_id VARCHAR(36) NOT NULL,
captured_at DATETIME NOT NULL,
peptide_slug VARCHAR(200) NOT NULL,
peptide_label VARCHAR(200) NOT NULL,
model VARCHAR(200) NOT NULL,
prompt_intent VARCHAR(200) NOT NULL,
cited TINYINT(1) NOT NULL DEFAULT 0,
our_position INT(11) NULL DEFAULT NULL,
source_domains LONGTEXT NOT NULL DEFAULT '',
raw_excerpt LONGTEXT NOT NULL DEFAULT '',
cost_usd DECIMAL(12,8) NOT NULL DEFAULT 0.00000000,
config_version INT(11) NULL DEFAULT NULL,
PRIMARY KEY (id),
KEY run_id (run_id),
KEY peptide_slug (peptide_slug),
KEY captured_at (captured_at)
KEY captured_at (captured_at),
KEY config_version (config_version)
) {$charset_collate};";
if ( ! function_exists( 'dbDelta' ) ) {
@@ -69,7 +80,7 @@ class PRV_Table_Manager {
dbDelta( $sql );
update_option( 'prv_schema_version', PRV_SCHEMA_VERSION );
update_option( 'prv_schema_version', self::SCHEMA_VERSION );
}
/**
@@ -85,7 +96,7 @@ class PRV_Table_Manager {
/**
* Drop the AI-visibility table entirely.
*
* Called only from uninstall.php — never during normal deactivation.
* Called only from uninstall.php -- never during normal deactivation.
*
* Side effects: Permanently destroys all probe data.
*

View File

@@ -0,0 +1,40 @@
<?php
/**
* Upgrade runner: idempotent, version-gated migrations.
*
* @package PrVision
*/
declare(strict_types=1);
/**
* Runs pending data migrations on every plugins_loaded call.
*
* Each migration is version-gated (cheap option check first) so it only
* runs when truly needed. Safe to call repeatedly -- all migrations are
* idempotent. This fires on UPGRADE (not just activation), so the plugin
* running live on prod without a fresh activation still gets migrated.
*
* Who triggers: PRV_Plugin::init() at plugins_loaded priority 1.
* Dependencies: PRV_Model_Registry, PRV_Config_Version.
*
* @see class-prv-model-registry.php -- Migration v2 implementation.
* @see class-prv-config-version.php -- Scoring-config versioning.
* @see class-prv-plugin.php -- Calls PRV_Upgrader::run().
* @package PrVision
*/
class PRV_Upgrader {
/**
* Run all pending migrations in version order.
*
* Side effects: May write to wp_options (migration state).
*
* @return void
*/
public static function run(): void {
PRV_Table_Manager::create_table();
PRV_Model_Registry::run_migration_v2();
PRV_Config_Version::maybe_seed_initial_version();
}
}

View File

@@ -2,17 +2,17 @@
/**
* 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 trendline + standings table.
* Version: 0.1.1
* 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.0
* Author: peptiderepo
* Author URI: https://peptiderepo.com
* License: GPL-2.0-or-later
* Text Domain: pr-vision
* Requires PHP: 8.1
*
* @see ARCHITECTURE.md — Full data flow and file tree.
* @see CONVENTIONS.md — Naming patterns and extension guide.
* @see CONTEXT.md — Domain glossary incl. the visibility score formula.
* @see ARCHITECTURE.md -- Full data flow and file tree.
* @see CONVENTIONS.md -- Naming patterns and extension guide.
* @see CONTEXT.md -- Domain glossary incl. the visibility score formula.
* @package PrVision
*/
@@ -24,11 +24,11 @@ if ( ! defined( 'ABSPATH' ) ) {
/* ── Constants ────────────────────────────────────────────────────────── */
define( 'PRV_VERSION', '0.1.1' );
define( 'PRV_VERSION', '0.2.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', 1 );
define( 'PRV_SCHEMA_VERSION', 2 );
/** Maximum HTTP retries for LLM API calls. @var int */
define( 'PRV_MAX_RETRIES', 3 );

View File

@@ -1,6 +1,6 @@
<?php
/**
* Minimal test bootstrap — stubs all WordPress functions used by the plugin
* Minimal test bootstrap -- stubs all WordPress functions used by the plugin
* so classes can be exercised in plain PHP without a WP install.
*
* Pattern mirrors peptide-repo-core's bootstrap (flat PHP, no PHPUnit).
@@ -16,11 +16,11 @@ if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}
define( 'PRV_VERSION', '0.1.1' );
define( 'PRV_VERSION', '0.2.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', 1 );
define( 'PRV_SCHEMA_VERSION', 2 );
define( 'PRV_MAX_RETRIES', 3 );
define( 'PRV_API_TIMEOUT_SECONDS', 60 );
define( 'PRV_RETRY_BASE_DELAY_SECONDS', 2 );
@@ -32,6 +32,7 @@ define( 'PRV_TARGET_DOMAIN', 'peptiderepo.com' );
$GLOBALS['prv_test_state'] = [
'options' => [],
'transients' => [],
'actions' => [],
'wpdb_insert_id' => 1,
'wpdb_results' => [],
@@ -43,6 +44,7 @@ $GLOBALS['prv_test_state'] = [
function prv_test_reset(): void {
$GLOBALS['prv_test_state'] = [
'options' => [],
'transients' => [],
'actions' => [],
'wpdb_insert_id' => 1,
'wpdb_results' => [],
@@ -71,23 +73,41 @@ function add_option( string $name, $value = '' ): bool {
return true;
}
function delete_option( string $name ): bool {
unset( $GLOBALS['prv_test_state']['options'][ $name ] );
return true;
}
function get_transient( string $name ) {
return $GLOBALS['prv_test_state']['transients'][ $name ] ?? false;
}
function set_transient( string $name, $value, int $ttl = 0 ): bool {
$GLOBALS['prv_test_state']['transients'][ $name ] = $value;
return true;
}
function delete_transient( string $name ): bool {
unset( $GLOBALS['prv_test_state']['transients'][ $name ] );
return true;
}
function add_action( string $hook, $callback, int $priority = 10, int $accepted_args = 1 ): bool {
$GLOBALS['prv_test_state']['actions'][] = compact( 'hook', 'callback', 'priority' );
return true;
}
function remove_action( string $hook, $callback, int $priority = 10 ): bool {
return true;
}
function remove_action( string $hook, $callback, int $priority = 10 ): bool { return true; }
function add_menu_page( ...$args ): void {}
function add_submenu_page( ...$args ): void {}
function wp_schedule_event( int $timestamp, string $recurrence, string $hook ): void {
$GLOBALS['prv_test_state']['cron_events'][ $hook ] = $timestamp;
$GLOBALS['prv_test_state']['cron_events'][ $hook ] = [ 'timestamp' => $timestamp, 'schedule' => $recurrence ];
}
function wp_next_scheduled( string $hook ) {
return $GLOBALS['prv_test_state']['cron_events'][ $hook ] ?? false;
return $GLOBALS['prv_test_state']['cron_events'][ $hook ]['timestamp'] ?? false;
}
function wp_unschedule_event( int $timestamp, string $hook ): void {
@@ -98,6 +118,14 @@ function wp_clear_scheduled_hook( string $hook ): void {
unset( $GLOBALS['prv_test_state']['cron_events'][ $hook ] );
}
function _get_cron_array(): array {
$out = [];
foreach ( $GLOBALS['prv_test_state']['cron_events'] as $hook => $data ) {
$out[ $data['timestamp'] ][ $hook ] = [ '' => [ 'schedule' => $data['schedule'], 'interval' => 604800 ] ];
}
return $out;
}
function wp_rand( int $min = 0, int $max = 0 ): int {
return random_int( $min ?: 0, $max ?: PHP_INT_MAX );
}
@@ -106,29 +134,15 @@ function current_time( string $type, bool $gmt = false ): string {
return gmdate( 'Y-m-d H:i:s' );
}
function home_url( string $path = '' ): string {
return 'http://example.test' . $path;
}
function home_url( string $path = '' ): string { return 'http://example.test' . $path; }
function __( string $text, string $domain = 'default' ): string {
return $text;
}
function esc_html( string $text ): string {
return htmlspecialchars( $text, ENT_QUOTES );
}
function esc_html__( string $text, string $domain = 'default' ): string {
return esc_html( $text );
}
function esc_js( string $text ): string {
return addslashes( $text );
}
function esc_url( string $url ): string {
return $url;
}
function __( string $text, string $domain = 'default' ): string { return $text; }
function esc_html( string $text ): string { return htmlspecialchars( $text, ENT_QUOTES ); }
function esc_html__( string $text, string $domain = 'default' ): string { return esc_html( $text ); }
function esc_attr( string $text ): string { return htmlspecialchars( $text, ENT_QUOTES ); }
function esc_textarea( string $text ): string { return htmlspecialchars( $text, ENT_QUOTES ); }
function esc_js( string $text ): string { return addslashes( $text ); }
function esc_url( string $url ): string { return $url; }
function wp_json_encode( $data, int $flags = 0 ): string {
return json_encode( $data, $flags ) ?: 'null';
@@ -138,66 +152,50 @@ function wp_parse_url( string $url, int $component = -1 ) {
return parse_url( $url, $component );
}
function absint( $value ): int {
return abs( (int) $value );
}
function sanitize_text_field( string $str ): string {
return trim( $str );
}
function current_user_can( string $cap ): bool {
return true;
}
function absint( $value ): int { return abs( (int) $value ); }
function sanitize_text_field( string $str ): string { return trim( $str ); }
function sanitize_textarea_field( string $str ): string { return trim( $str ); }
function wp_unslash( $value ) { return is_string( $value ) ? stripslashes( $value ) : $value; }
function current_user_can( string $cap ): bool { return true; }
function wp_die( $message = '', $code = 0 ): void {
throw new \RuntimeException( is_string( $message ) ? $message : 'wp_die' );
}
function wp_verify_nonce( string $nonce, string $action ): bool {
return true;
}
function wp_nonce_field( string $action, string $name ): string {
return '';
}
function wp_verify_nonce( string $nonce, string $action ): bool { return true; }
function wp_nonce_field( string $action, string $name, bool $referer = true, bool $echo = true ): string { return ''; }
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 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 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 ) ); }
function is_wp_error( $thing ): bool {
return $thing instanceof WP_Error;
function is_wp_error( $thing ): bool { return $thing instanceof WP_Error; }
function selected( $selected, $current = true, bool $echo = true ): string {
$str = ( (string) $selected === (string) $current ) ? ' selected="selected"' : '';
if ( $echo ) { echo $str; }
return $str;
}
function wp_remote_post( string $url, array $args = array() ) {
// Return from test queue if set, else a generic success.
$queue = &$GLOBALS['prv_test_state']['remote_posts'];
if ( ! empty( $queue ) ) {
return array_shift( $queue );
}
if ( ! empty( $queue ) ) { return array_shift( $queue ); }
return array( 'response' => array( 'code' => 200 ), 'body' => '{"choices":[{"message":{"content":"test"}}],"citations":[],"usage":{"total_tokens":100}}' );
}
function wp_remote_retrieve_response_code( $response ): int {
return (int) ( $response['response']['code'] ?? 200 );
}
function wp_remote_retrieve_body( $response ): string { return (string) ( $response['body'] ?? '' ); }
function wp_remote_retrieve_header( $response, string $header ): string { return (string) ( $response['headers'][ $header ] ?? '' ); }
function wp_remote_retrieve_body( $response ): string {
return (string) ( $response['body'] ?? '' );
}
function wp_remote_retrieve_header( $response, string $header ): string {
return (string) ( $response['headers'][ $header ] ?? '' );
}
function is_admin(): bool { return false; }
function wp_enqueue_script( ...$args ): void {}
function wp_add_inline_style( ...$args ): void {}
class WP_Error {
public function __construct( private string $code = '', private string $message = '' ) {}
@@ -207,10 +205,11 @@ class WP_Error {
/* ── Minimal $wpdb stub ────────────────────────────────────────────────── */
class stdClass_wpdb {
public string $prefix = 'wp_';
public string $prefix = 'wp_';
public int $insert_id = 0;
public array $_results = [];
public mixed $_var = null;
public string $options = 'wp_options';
public function get_charset_collate(): string {
return 'DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci';
@@ -223,15 +222,11 @@ class stdClass_wpdb {
$this->insert_id = $GLOBALS['prv_test_state']['wpdb_insert_id']++;
return 1;
}
public function update( string $table, array $data, array $where, array $data_format = [], array $where_format = [] ): int {
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_var( string $query ): mixed {
return $GLOBALS['prv_test_state']['wpdb_var'];
}
public function get_var( string $query ): mixed { return $GLOBALS['prv_test_state']['wpdb_var']; }
}
$wpdb = new stdClass_wpdb();
@@ -255,15 +250,15 @@ function prv_assert( bool $condition, string $label ): void {
function prv_assert_equals( $expected, $actual, string $label ): void {
$pass = ( $expected === $actual );
prv_assert( $pass, $label . ( $pass ? '' : ' — expected ' . var_export( $expected, true ) . ', got ' . var_export( $actual, true ) ) );
prv_assert( $pass, $label . ( $pass ? '' : ' -- expected ' . var_export( $expected, true ) . ', got ' . var_export( $actual, true ) ) );
}
function prv_assert_throws( callable $fn, string $exception_class, string $label ): void {
try {
$fn();
prv_assert( false, $label . ' — expected ' . $exception_class . ' but no exception thrown' );
prv_assert( false, $label . ' -- expected ' . $exception_class . ' but no exception thrown' );
} catch ( \Throwable $e ) {
prv_assert( $e instanceof $exception_class, $label . ' — got ' . get_class( $e ) );
prv_assert( $e instanceof $exception_class, $label . ' -- got ' . get_class( $e ) );
}
}
@@ -273,9 +268,7 @@ function prv_test_summary(): int {
echo "Totals: {$r['pass']} passed, {$r['fail']} failed\n";
if ( $r['fail'] > 0 ) {
echo "Failures:\n";
foreach ( $r['failures'] as $f ) {
echo " - {$f}\n";
}
foreach ( $r['failures'] as $f ) { echo " - {$f}\n"; }
return 1;
}
return 0;

View File

@@ -0,0 +1,120 @@
<?php
/**
* Tests for PRV_Config_Version: config hash, bump, version records.
*
* @package PrVision
*/
declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php';
echo "=== PRV Config Version Tests ===\n";
// Shared API key constant.
if ( ! defined( 'PRV_OPENROUTER_API_KEY' ) ) {
define( 'PRV_OPENROUTER_API_KEY', 'sk-or-test-key' );
}
if ( ! defined( 'PRV_CF_ACCOUNT_ID' ) ) {
define( 'PRV_CF_ACCOUNT_ID', '' );
}
if ( ! defined( 'PRV_CF_GATEWAY_ID' ) ) {
define( 'PRV_CF_GATEWAY_ID', '' );
}
// ── Test: maybe_seed_initial_version creates a v1 record ────────────────
prv_test_reset();
PRV_Model_Registry::run_migration_v2();
PRV_Config_Version::maybe_seed_initial_version();
$versions = PRV_Config_Version::get_all_versions();
prv_assert( count( $versions ) >= 1, 'seed: at least 1 version record after initial seed' );
prv_assert_equals( 1, (int) $versions[0]['version'], 'seed: first version number is 1' );
prv_assert( ! empty( $versions[0]['hash'] ), 'seed: version has a hash' );
prv_assert_equals( 1, PRV_Config_Version::get_active_version(), 'seed: active version is 1' );
// ── Test: maybe_seed_initial_version is idempotent ───────────────────────
PRV_Config_Version::maybe_seed_initial_version();
prv_assert_equals( 1, count( PRV_Config_Version::get_all_versions() ), 'seed idempotent: still 1 version after second seed call' );
// ── Test: bump when config unchanged returns null ────────────────────────
prv_test_reset();
PRV_Model_Registry::run_migration_v2();
PRV_Config_Version::maybe_seed_initial_version();
$bump = PRV_Config_Version::bump_version_if_changed();
prv_assert( null === $bump, 'bump: returns null when config unchanged' );
prv_assert_equals( 1, PRV_Config_Version::get_active_version(), 'bump: active version stays 1 when config unchanged' );
// ── Test: bump after scoring-relevant change creates new version ─────────
prv_test_reset();
PRV_Model_Registry::run_migration_v2();
PRV_Config_Version::maybe_seed_initial_version();
// Change the model list (scoring-relevant).
$all = PRV_Model_Registry::get_all();
PRV_Model_Registry::add( 'anthropic/claude-3-haiku', 'openrouter', true, 'New model' );
$new_ver = PRV_Config_Version::bump_version_if_changed();
prv_assert( null !== $new_ver, 'bump: returns new version number when config changed' );
prv_assert_equals( 2, (int) $new_ver, 'bump: new version is 2' );
prv_assert_equals( 2, PRV_Config_Version::get_active_version(), 'bump: active version updated to 2' );
prv_assert_equals( 2, count( PRV_Config_Version::get_all_versions() ), 'bump: 2 version records exist' );
// ── Test: would_change detects a config change ───────────────────────────
prv_test_reset();
PRV_Model_Registry::run_migration_v2();
PRV_Config_Version::maybe_seed_initial_version();
$original_hash = PRV_Config_Version::compute_hash();
prv_assert( false === PRV_Config_Version::would_change( $original_hash ), 'would_change: false when hash is current' );
// Modify config.
PRV_Model_Registry::add( 'x/new-model', 'openrouter', true );
$new_hash = PRV_Config_Version::compute_hash();
prv_assert( true === PRV_Config_Version::would_change( $new_hash ), 'would_change: true when config differs' );
// ── Test: compute_hash is stable for same config ─────────────────────────
prv_test_reset();
PRV_Model_Registry::run_migration_v2();
$h1 = PRV_Config_Version::compute_hash();
$h2 = PRV_Config_Version::compute_hash();
prv_assert_equals( $h1, $h2, 'compute_hash: stable for same config' );
// ── Test: config_version stamped on probe result ─────────────────────────
prv_test_reset();
PRV_Model_Registry::run_migration_v2();
PRV_Config_Version::maybe_seed_initial_version();
prv_assert_equals( 1, PRV_Config_Version::get_active_version(), 'version stamp: active version is 1 before run' );
// Verify the runner writes config_version to DB (check the last DB insert args).
$GLOBALS['prv_test_state']['options']['prv_peptides'] = [ [ 'slug' => 'bpc-157', 'label' => 'BPC-157' ] ];
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = [ 'what is {peptide}' ];
$GLOBALS['prv_test_state']['options']['prv_models'] = [
[ 'id' => 'mdl_test01', 'slug' => 'perplexity/sonar', 'provider' => 'perplexity', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ]
];
$GLOBALS['prv_test_state']['options'][ PRV_Model_Registry::VERSION_KEY ] = PRV_Model_Registry::SCHEMA_VERSION;
$cited_body = json_encode([
'choices' => [['message' => ['content' => 'test']]],
'citations' => ['https://peptiderepo.com/bpc-157/'],
'usage' => ['total_tokens' => 50],
]);
$GLOBALS['prv_test_state']['remote_posts'] = [
['response' => ['code' => 200], 'body' => $cited_body],
];
$runner = new PRV_Probe_Runner( new PRV_Cost_Ledger() );
$result = $runner->run();
prv_assert_equals( 1, $result['probed'], 'version stamp: runner completes 1 probe' );
// The prv_active_config_version persists after the run.
prv_assert_equals( 1, PRV_Config_Version::get_active_version(), 'version stamp: config version unchanged after run' );
exit( prv_test_summary() );

View File

@@ -0,0 +1,90 @@
<?php
/**
* Tests for PRV_Cron cadence reschedule on config change.
*
* @package PrVision
*/
declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php';
echo "=== PRV Cron Reschedule Tests ===\n";
// ── Test: schedule_weekly schedules with the configured cadence ───────────
prv_test_reset();
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly';
PRV_Cron::schedule_weekly();
prv_assert( PRV_Cron::is_scheduled(), 'schedule: event scheduled' );
$next = PRV_Cron::next_run_timestamp();
prv_assert( false !== $next, 'schedule: next_run_timestamp is not false' );
prv_assert( (int) $next > 0, 'schedule: next_run_timestamp is positive' );
// ── Test: reschedule clears old and adds new event ────────────────────────
prv_test_reset();
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly';
PRV_Cron::schedule();
prv_assert( PRV_Cron::is_scheduled(), 'reschedule pre: event is scheduled' );
PRV_Cron::reschedule( 'daily' );
prv_assert( PRV_Cron::is_scheduled(), 'reschedule post: event is still scheduled after reschedule' );
// Verify the cron array stores the new recurrence.
$events = _get_cron_array();
$found_daily = false;
foreach ( $events as $hooks ) {
if ( isset( $hooks[ PRV_CRON_HOOK ] ) ) {
foreach ( $hooks[ PRV_CRON_HOOK ] as $evt ) {
if ( 'daily' === ( $evt['schedule'] ?? '' ) ) {
$found_daily = true;
}
}
}
}
prv_assert( $found_daily, 'reschedule: new cron has daily recurrence' );
// ── Test: clear_schedule removes the event ────────────────────────────────
prv_test_reset();
PRV_Cron::schedule();
prv_assert( PRV_Cron::is_scheduled(), 'clear pre: event is scheduled' );
PRV_Cron::clear_schedule();
prv_assert( false === PRV_Cron::is_scheduled(), 'clear: event removed after clear_schedule' );
// ── Test: schedule is idempotent (called twice) ───────────────────────────
prv_test_reset();
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly';
PRV_Cron::schedule();
$ts1 = PRV_Cron::next_run_timestamp();
PRV_Cron::schedule(); // Second call.
$ts2 = PRV_Cron::next_run_timestamp();
prv_assert( $ts1 === $ts2, 'schedule idempotent: same timestamp after two schedule() calls at same cadence' );
// ── Test: get_cadence default is weekly ──────────────────────────────────
prv_test_reset();
$cadence = PRV_Config::get_cadence();
prv_assert_equals( 'weekly', $cadence, 'get_cadence: defaults to weekly' );
// ── Test: invalid cadence falls back to weekly ────────────────────────────
prv_test_reset();
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'invalid_cadence';
$cadence = PRV_Config::get_cadence();
prv_assert_equals( 'weekly', $cadence, 'get_cadence: invalid value falls back to weekly' );
// ── Test: cron tick skipped when run-lock held ───────────────────────────
prv_test_reset();
PRV_Run_Lock::acquire();
$cron = new PRV_Cron();
// handle_cron_tick should return without running -- no exception.
$cron->handle_cron_tick();
prv_assert( PRV_Run_Lock::is_locked(), 'cron tick: lock still held (run was skipped)' );
PRV_Run_Lock::release();
prv_assert( false === PRV_Run_Lock::is_locked(), 'cron tick cleanup: lock released' );
exit( prv_test_summary() );

View File

@@ -0,0 +1,150 @@
<?php
/**
* Tests for PRV_Model_Registry: v1→v2 migration, CRUD, run-health.
*
* @package PrVision
*/
declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php';
echo "=== PRV Model Registry Tests ===\n";
// ── Test: migration from v1 flat strings preserves live model set ─────────
prv_test_reset();
$GLOBALS['prv_test_state']['options']['prv_models'] = [
'perplexity/sonar',
'openai/gpt-4o-search-preview',
'google/gemini-2.0-flash-001',
];
// No schema version set -- this is the v0.1.x state.
$migrated = PRV_Model_Registry::run_migration_v2();
prv_assert( $migrated, 'v1->v2 migration: run_migration_v2 returns true when migration performed' );
$models = PRV_Model_Registry::get_all();
prv_assert_equals( 3, count( $models ), 'v1->v2 migration: 3 models preserved' );
$slugs = array_column( $models, 'slug' );
prv_assert( in_array( 'perplexity/sonar', $slugs, true ), 'v1->v2 migration: perplexity/sonar preserved' );
prv_assert( in_array( 'openai/gpt-4o-search-preview', $slugs, true ), 'v1->v2 migration: gpt-4o preserved' );
prv_assert( in_array( 'google/gemini-2.0-flash-001', $slugs, true ), 'v1->v2 migration: gemini preserved' );
// Each migrated model has the required v2 fields.
foreach ( $models as $m ) {
prv_assert( isset( $m['id'] ), "v1->v2 migration: model {$m['slug']} has id" );
prv_assert( isset( $m['provider'] ), "v1->v2 migration: model {$m['slug']} has provider" );
prv_assert( isset( $m['health_status'] ), "v1->v2 migration: model {$m['slug']} has health_status" );
prv_assert( true === $m['enabled'], "v1->v2 migration: model {$m['slug']} is enabled by default" );
}
// Schema version updated.
prv_assert_equals(
PRV_Model_Registry::SCHEMA_VERSION,
(int) $GLOBALS['prv_test_state']['options'][ PRV_Model_Registry::VERSION_KEY ],
'v1->v2 migration: schema version option updated'
);
// ── Test: migration is idempotent (runs twice, second is no-op) ──────────
$before_count = count( PRV_Model_Registry::get_all() );
$second_run = PRV_Model_Registry::run_migration_v2();
prv_assert( false === $second_run, 'idempotent: second call to run_migration_v2 returns false' );
prv_assert_equals( $before_count, count( PRV_Model_Registry::get_all() ), 'idempotent: model count unchanged after second migration' );
// ── Test: upgrade-from-nothing (no prv_models option) seeds defaults ─────
prv_test_reset();
$migrated2 = PRV_Model_Registry::run_migration_v2();
prv_assert( $migrated2, 'default seed: migration returns true when no models exist' );
$models2 = PRV_Model_Registry::get_all();
prv_assert( count( $models2 ) >= 3, 'default seed: at least 3 default models seeded' );
// ── Test: add model ──────────────────────────────────────────────────────
prv_test_reset();
PRV_Model_Registry::run_migration_v2();
$base_count = count( PRV_Model_Registry::get_all() );
$new_id = PRV_Model_Registry::add( 'anthropic/claude-3-haiku', 'openrouter', true, 'Test model' );
prv_assert( '' !== $new_id, 'add: returns non-empty id' );
prv_assert( str_starts_with( $new_id, 'mdl_' ), 'add: id has mdl_ prefix' );
prv_assert_equals( $base_count + 1, count( PRV_Model_Registry::get_all() ), 'add: model count increments' );
$found = PRV_Model_Registry::find_by_id( $new_id );
prv_assert( null !== $found, 'add: find_by_id returns the new model' );
prv_assert_equals( 'anthropic/claude-3-haiku', $found['slug'], 'add: slug stored correctly' );
prv_assert_equals( 'Test model', $found['note'], 'add: note stored correctly' );
// ── Test: update model ───────────────────────────────────────────────────
prv_test_reset();
PRV_Model_Registry::run_migration_v2();
$all = PRV_Model_Registry::get_all();
$target = $all[0];
$result = PRV_Model_Registry::update( $target['id'], [ 'enabled' => false, 'note' => 'Disabled for test' ] );
prv_assert( $result, 'update: returns true' );
$updated = PRV_Model_Registry::find_by_id( $target['id'] );
prv_assert( null !== $updated, 'update: model still exists' );
prv_assert( false === $updated['enabled'], 'update: enabled flag updated' );
prv_assert_equals( 'Disabled for test', $updated['note'], 'update: note updated' );
// ── Test: remove model ───────────────────────────────────────────────────
prv_test_reset();
PRV_Model_Registry::run_migration_v2();
$id_to_remove = PRV_Model_Registry::get_all()[0]['id'];
$count_before = count( PRV_Model_Registry::get_all() );
$removed = PRV_Model_Registry::remove( $id_to_remove );
prv_assert( $removed, 'remove: returns true' );
prv_assert_equals( $count_before - 1, count( PRV_Model_Registry::get_all() ), 'remove: count decrements' );
prv_assert( null === PRV_Model_Registry::find_by_id( $id_to_remove ), 'remove: model gone from get_all' );
// ── Test: remove non-existent id returns false ───────────────────────────
prv_test_reset();
PRV_Model_Registry::run_migration_v2();
prv_assert( false === PRV_Model_Registry::remove( 'mdl_doesnotexist' ), 'remove: returns false for unknown id' );
// ── Test: get_enabled_slugs only returns enabled ─────────────────────────
prv_test_reset();
PRV_Model_Registry::run_migration_v2();
$all = PRV_Model_Registry::get_all();
// Disable first model.
PRV_Model_Registry::update( $all[0]['id'], [ 'enabled' => false ] );
$slugs = PRV_Model_Registry::get_enabled_slugs();
prv_assert( ! in_array( $all[0]['slug'], $slugs, true ), 'get_enabled_slugs: disabled model excluded' );
prv_assert_equals( count( $all ) - 1, count( $slugs ), 'get_enabled_slugs: correct count' );
// ── Test: update_health sets correct status ──────────────────────────────
prv_test_reset();
PRV_Model_Registry::run_migration_v2();
$all = PRV_Model_Registry::get_all();
$slug0 = $all[0]['slug'];
$slug1 = isset( $all[1] ) ? $all[1]['slug'] : null;
$run_id = 'test-run-' . uniqid( '', true );
$outcomes = [ $slug0 => [ 'probed' => 5, 'errors' => 0 ] ];
if ( $slug1 ) {
$outcomes[ $slug1 ] = [ 'probed' => 0, 'errors' => 3 ];
}
PRV_Model_Registry::update_health( $run_id, $outcomes );
$updated_all = PRV_Model_Registry::get_all();
foreach ( $updated_all as $m ) {
if ( $m['slug'] === $slug0 ) {
prv_assert_equals( 'healthy', $m['health_status'], 'update_health: probed>0,errors=0 => healthy' );
prv_assert_equals( 5, $m['health_probed'], 'update_health: health_probed set correctly' );
prv_assert_equals( $run_id, $m['health_run_id'], 'update_health: run_id stored' );
}
if ( $slug1 && $m['slug'] === $slug1 ) {
prv_assert_equals( 'retired', $m['health_status'], 'update_health: probed=0,errors>0 => retired' );
prv_assert_equals( 3, $m['health_errors'], 'update_health: health_errors set correctly' );
}
}
exit( prv_test_summary() );

View File

@@ -1,6 +1,6 @@
<?php
/**
* Tests for PRV_Probe_Runner: budget-cap abort and run mechanics.
* Tests for PRV_Probe_Runner: budget-cap abort, run-lock, model health.
*
* Uses queued mock HTTP responses (via $GLOBALS['prv_test_state']['remote_posts'])
* and a mock ledger to verify runner behaviour without real API calls.
@@ -47,7 +47,18 @@ class PRV_Mock_Ledger extends PRV_Cost_Ledger {
public function get_month_to_date_usd(): float { return 0.0; }
}
// ── Shared mock response body (Perplexity / cited) ────────────────────────
// ── Shared helpers ────────────────────────────────────────────────────────
function setup_simple_run( array $model_rows, array $peptides = null, array $intents = null ): void {
PRV_Model_Registry::run_migration_v2();
PRV_Config_Version::maybe_seed_initial_version();
$GLOBALS['prv_test_state']['options']['prv_models'] = $model_rows;
$GLOBALS['prv_test_state']['options'][ PRV_Model_Registry::VERSION_KEY ] = PRV_Model_Registry::SCHEMA_VERSION;
$GLOBALS['prv_test_state']['options']['prv_peptides'] = $peptides ?? [['slug'=>'bpc-157','label'=>'BPC-157']];
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = $intents ?? ['what is {peptide}'];
}
$sonar_row = [ 'id' => 'mdl_t1', 'slug' => 'perplexity/sonar', 'provider' => 'perplexity', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ];
$cited_body = json_encode([
'choices' => [['message' => ['content' => 'BPC-157 info.']]],
@@ -55,12 +66,10 @@ $cited_body = json_encode([
'usage' => ['total_tokens' => 100],
]);
// ── Test: basic run completes — 1×1×1 (peptide × intent × model) ─────────
// ── Test: basic run completes -- 1×1×1 ───────────────────────────────────
prv_test_reset();
$GLOBALS['prv_test_state']['options']['prv_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']];
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = ['what is {peptide}'];
$GLOBALS['prv_test_state']['options']['prv_models'] = ['perplexity/sonar'];
setup_simple_run( [$sonar_row] );
$GLOBALS['prv_test_state']['remote_posts'] = [
['response' => ['code' => 200], 'body' => $cited_body],
];
@@ -73,16 +82,17 @@ prv_assert_equals( 1, $counts1['probed'], 'basic run: 1 probed' );
prv_assert_equals( 0, $counts1['skipped_budget'], 'basic run: 0 budget skips' );
prv_assert_equals( 0, $counts1['skipped_error'], 'basic run: 0 error skips' );
prv_assert_equals( 1, $ledger1->afford_count, 'basic run: 1 can_afford check' );
prv_assert( '' !== $counts1['run_id'], 'basic run: run_id is non-empty' );
prv_assert( false === $counts1['truncated'], 'basic run: not truncated' );
// ── Test: 2×1×1 run completes all probes when budget sufficient ───────────
prv_test_reset();
$GLOBALS['prv_test_state']['options']['prv_peptides'] = [
['slug'=>'bpc-157','label'=>'BPC-157'],
['slug'=>'tb-500','label'=>'TB-500'],
];
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = ['what is {peptide}'];
$GLOBALS['prv_test_state']['options']['prv_models'] = ['perplexity/sonar'];
setup_simple_run(
[$sonar_row],
[['slug'=>'bpc-157','label'=>'BPC-157'],['slug'=>'tb-500','label'=>'TB-500']],
['what is {peptide}']
);
$GLOBALS['prv_test_state']['remote_posts'] = [
['response' => ['code' => 200], 'body' => $cited_body],
['response' => ['code' => 200], 'body' => $cited_body],
@@ -92,43 +102,38 @@ $ledger2 = new PRV_Mock_Ledger();
$runner2 = new PRV_Probe_Runner( $ledger2 );
$counts2 = $runner2->run();
prv_assert_equals( 2, $counts2['probed'], '2×1×1 run: 2 probes completed' );
prv_assert_equals( 2, $ledger2->afford_count, '2×1×1 run: 2 can_afford checks' );
prv_assert_equals( 2, $counts2['probed'], '2×1×1 run: 2 probes completed' );
prv_assert_equals( 2, $ledger2->afford_count, '2×1×1 run: 2 can_afford checks' );
// ── Test: budget cap abort after N probes ────────────────────────────────
prv_test_reset();
$GLOBALS['prv_test_state']['options']['prv_peptides'] = [
['slug'=>'bpc-157','label'=>'BPC-157'],
['slug'=>'tb-500','label'=>'TB-500'],
['slug'=>'mk-677','label'=>'MK-677'],
];
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = ['what is {peptide}'];
$GLOBALS['prv_test_state']['options']['prv_models'] = ['perplexity/sonar'];
// Queue 3 responses but cap budget at 1.
setup_simple_run(
[$sonar_row],
[['slug'=>'bpc-157','label'=>'BPC-157'],['slug'=>'tb-500','label'=>'TB-500'],['slug'=>'mk-677','label'=>'MK-677']],
['what is {peptide}']
);
$GLOBALS['prv_test_state']['remote_posts'] = [
['response' => ['code' => 200], 'body' => $cited_body],
['response' => ['code' => 200], 'body' => $cited_body],
['response' => ['code' => 200], 'body' => $cited_body],
];
$exhausting_ledger = new PRV_Mock_Ledger();
$exhausting_ledger->exhaust_after = 1; // Budget exhausted after 1st can_afford check.
$exh = new PRV_Mock_Ledger();
$exh->exhaust_after = 1; // Budget exhausted after 1st check.
$runner3 = new PRV_Probe_Runner( $exhausting_ledger );
$runner3 = new PRV_Probe_Runner( $exh );
$counts3 = $runner3->run();
prv_assert_equals( 1, $counts3['probed'], 'budget abort: 1 probe completed before cap' );
prv_assert( $counts3['skipped_budget'] >= 2, 'budget abort: ≥2 probes skipped (budget)' );
prv_assert( $counts3['skipped_budget'] >= 2, 'budget abort: >=2 probes skipped (budget)' );
prv_assert_equals( 0, $counts3['skipped_error'], 'budget abort: 0 error skips' );
prv_assert( $counts3['truncated'], 'budget abort: truncated flag set' );
// ── Test: HTTP error counts as skipped_error ─────────────────────────────
prv_test_reset();
$GLOBALS['prv_test_state']['options']['prv_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']];
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = ['what is {peptide}'];
$GLOBALS['prv_test_state']['options']['prv_models'] = ['perplexity/sonar'];
// All retries fail with 500.
setup_simple_run( [$sonar_row] );
$GLOBALS['prv_test_state']['remote_posts'] = [
['response' => ['code' => 500], 'body' => 'Error'],
['response' => ['code' => 500], 'body' => 'Error'],
@@ -140,15 +145,12 @@ $runner4 = new PRV_Probe_Runner( $ledger4 );
$counts4 = $runner4->run();
prv_assert_equals( 0, $counts4['probed'], 'HTTP 500: 0 probes' );
prv_assert( $counts4['skipped_error'] >= 1, 'HTTP 500: ≥1 error skip recorded' );
prv_assert( $counts4['skipped_error'] >= 1, 'HTTP 500: >=1 error skip recorded' );
// ── Test: WP_Error from wp_remote_post counts as skipped_error ──────────
prv_test_reset();
$GLOBALS['prv_test_state']['options']['prv_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']];
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = ['what is {peptide}'];
$GLOBALS['prv_test_state']['options']['prv_models'] = ['perplexity/sonar'];
// Return WP_Error for all retries.
setup_simple_run( [$sonar_row] );
$wp_error = new WP_Error( 'http_request_failed', 'cURL error 28: Operation timed out' );
$GLOBALS['prv_test_state']['remote_posts'] = [ $wp_error, $wp_error, $wp_error ];
@@ -157,14 +159,12 @@ $runner5 = new PRV_Probe_Runner( $ledger5 );
$counts5 = $runner5->run();
prv_assert_equals( 0, $counts5['probed'], 'WP_Error: 0 probes completed' );
prv_assert( $counts5['skipped_error'] >= 1, 'WP_Error: ≥1 error skip recorded' );
prv_assert( $counts5['skipped_error'] >= 1, 'WP_Error: >=1 error skip recorded' );
// ── Test: run returns correct keys in counts array ───────────────────────
prv_test_reset();
$GLOBALS['prv_test_state']['options']['prv_peptides'] = [['slug'=>'bpc-157','label'=>'BPC-157']];
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = ['what is {peptide}'];
$GLOBALS['prv_test_state']['options']['prv_models'] = ['perplexity/sonar'];
setup_simple_run( [$sonar_row] );
$GLOBALS['prv_test_state']['remote_posts'] = [
['response' => ['code' => 200], 'body' => $cited_body],
];
@@ -176,5 +176,53 @@ $counts6 = $runner6->run();
prv_assert( array_key_exists( 'probed', $counts6 ), 'run: returns probed key' );
prv_assert( array_key_exists( 'skipped_budget', $counts6 ), 'run: returns skipped_budget key' );
prv_assert( array_key_exists( 'skipped_error', $counts6 ), 'run: returns skipped_error key' );
prv_assert( array_key_exists( 'truncated', $counts6 ), 'run: returns truncated key' );
prv_assert( array_key_exists( 'run_id', $counts6 ), 'run: returns run_id key' );
// ── Test: run-lock refused when already locked ────────────────────────────
prv_test_reset();
setup_simple_run( [$sonar_row] );
PRV_Run_Lock::acquire();
$ledger7 = new PRV_Mock_Ledger();
$runner7 = new PRV_Probe_Runner( $ledger7 );
$counts7 = $runner7->run();
prv_assert_equals( 0, $counts7['probed'], 'run-lock: 0 probed when locked' );
prv_assert_equals( -1, $counts7['skipped_error'], 'run-lock: -1 sentinel when lock busy' );
prv_assert_equals( 0, $ledger7->afford_count, 'run-lock: no can_afford checks when locked' );
PRV_Run_Lock::release();
// ── Test: per-model health updated after run ──────────────────────────────
prv_test_reset();
setup_simple_run( [$sonar_row] );
$GLOBALS['prv_test_state']['remote_posts'] = [
['response' => ['code' => 200], 'body' => $cited_body],
];
$runner8 = new PRV_Probe_Runner( new PRV_Mock_Ledger() );
$runner8->run();
$models8 = PRV_Model_Registry::get_all();
prv_assert( count( $models8 ) > 0, 'health update: at least 1 model' );
// The sonar model should have health_status = healthy (probed=1, errors=0).
foreach ( $models8 as $m ) {
if ( 'perplexity/sonar' === $m['slug'] ) {
prv_assert_equals( 'healthy', $m['health_status'], 'health update: sonar healthy after successful run' );
prv_assert_equals( 1, $m['health_probed'], 'health update: health_probed = 1' );
prv_assert_equals( 0, $m['health_errors'], 'health update: health_errors = 0' );
}
}
// ── Test: lock is released after a successful run ─────────────────────────
prv_test_reset();
setup_simple_run( [$sonar_row] );
$GLOBALS['prv_test_state']['remote_posts'] = [
['response' => ['code' => 200], 'body' => $cited_body],
];
$runner9 = new PRV_Probe_Runner( new PRV_Mock_Ledger() );
$runner9->run();
prv_assert( false === PRV_Run_Lock::is_locked(), 'run-lock cleanup: lock released after successful run' );
exit( prv_test_summary() );

View File

@@ -0,0 +1,121 @@
<?php
/**
* Tests for projected cost calculation and budget cap logic.
*
* @package PrVision
*/
declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php';
echo "=== PRV Projected Cost Tests ===\n";
if ( ! defined( 'PRV_OPENROUTER_API_KEY' ) ) {
define( 'PRV_OPENROUTER_API_KEY', 'sk-or-test-key' );
}
if ( ! defined( 'PRV_CF_ACCOUNT_ID' ) ) {
define( 'PRV_CF_ACCOUNT_ID', '' );
}
if ( ! defined( 'PRV_CF_GATEWAY_ID' ) ) {
define( 'PRV_CF_GATEWAY_ID', '' );
}
// ── Test: projected cost reflects enabled models × peptides × intents ─────
prv_test_reset();
PRV_Model_Registry::run_migration_v2();
// Set up a known config: 2 enabled models, 2 peptides, 2 intents.
$GLOBALS['prv_test_state']['options']['prv_models'] = [
[ 'id' => 'mdl_a1', 'slug' => 'perplexity/sonar', 'provider' => 'perplexity', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ],
[ 'id' => 'mdl_a2', 'slug' => 'openai/gpt-4o-search-preview', 'provider' => 'openrouter', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ],
];
$GLOBALS['prv_test_state']['options'][ PRV_Model_Registry::VERSION_KEY ] = PRV_Model_Registry::SCHEMA_VERSION;
$GLOBALS['prv_test_state']['options']['prv_peptides'] = [ [ 'slug' => 'bpc-157', 'label' => 'BPC-157' ], [ 'slug' => 'tb-500', 'label' => 'TB-500' ] ];
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = [ 'what is {peptide}', '{peptide} dosage' ];
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly';
$cost = PRV_Config::get_projected_cost();
prv_assert_equals( 8, $cost['probe_count'], 'projected cost: 2 models × 2 peptides × 2 intents = 8 probes' );
prv_assert( $cost['per_run_usd'] > 0.0, 'projected cost: per_run_usd > 0' );
prv_assert( $cost['per_month_usd'] > 0.0, 'projected cost: per_month_usd > 0' );
prv_assert( $cost['per_month_usd'] >= $cost['per_run_usd'], 'projected cost: monthly >= per-run' );
prv_assert( false === $cost['over_cap'], 'projected cost: 8-probe config not over $5 cap' );
// ── Test: over_cap flag fires when projected monthly > cap ────────────────
prv_test_reset();
// 1000 peptides × 3 intents × 3 models at $0.005/probe = $45/run (way over $5).
$big_peptides = [];
for ( $i = 0; $i < 100; $i++ ) {
$big_peptides[] = [ 'slug' => "pep-{$i}", 'label' => "Peptide {$i}" ];
}
$GLOBALS['prv_test_state']['options']['prv_models'] = [
[ 'id' => 'mdl_b1', 'slug' => 'perplexity/sonar', 'provider' => 'perplexity', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ],
[ 'id' => 'mdl_b2', 'slug' => 'openai/gpt-4o-search-preview', 'provider' => 'openrouter', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ],
[ 'id' => 'mdl_b3', 'slug' => 'google/gemini-2.0-flash-001', 'provider' => 'openrouter', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ],
];
$GLOBALS['prv_test_state']['options'][ PRV_Model_Registry::VERSION_KEY ] = PRV_Model_Registry::SCHEMA_VERSION;
$GLOBALS['prv_test_state']['options']['prv_peptides'] = $big_peptides;
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = [ 'what is {peptide}', '{peptide} dosage', '{peptide} guide' ];
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly';
$cost_big = PRV_Config::get_projected_cost();
prv_assert( $cost_big['over_cap'], 'projected cost: over_cap=true for large config over $5 cap' );
// ── Test: disabled models excluded from probe_count ───────────────────────
prv_test_reset();
$GLOBALS['prv_test_state']['options']['prv_models'] = [
[ 'id' => 'mdl_c1', 'slug' => 'perplexity/sonar', 'provider' => 'perplexity', 'enabled' => true, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ],
[ 'id' => 'mdl_c2', 'slug' => 'openai/gpt-4o-search-preview', 'provider' => 'openrouter', 'enabled' => false, 'note' => '', 'health_status' => 'unknown', 'health_probed' => 0, 'health_errors' => 0, 'health_run_id' => null ],
];
$GLOBALS['prv_test_state']['options'][ PRV_Model_Registry::VERSION_KEY ] = PRV_Model_Registry::SCHEMA_VERSION;
$GLOBALS['prv_test_state']['options']['prv_peptides'] = [ [ 'slug' => 'bpc-157', 'label' => 'BPC-157' ] ];
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = [ 'what is {peptide}' ];
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 5.0;
$GLOBALS['prv_test_state']['options'][ PRV_Config::CADENCE_KEY ] = 'weekly';
$cost_disabled = PRV_Config::get_projected_cost();
// 1 enabled model × 1 peptide × 1 intent = 1 probe.
prv_assert_equals( 1, $cost_disabled['probe_count'], 'projected cost: disabled models excluded from probe_count' );
// ── Test: truncation flag set when budget hit mid-run ────────────────────
prv_test_reset();
PRV_Model_Registry::run_migration_v2();
PRV_Config_Version::maybe_seed_initial_version();
$GLOBALS['prv_test_state']['options']['prv_peptides'] = [
[ 'slug' => 'bpc-157', 'label' => 'BPC-157' ],
[ 'slug' => 'tb-500', 'label' => 'TB-500' ],
[ 'slug' => 'mk-677', 'label' => 'MK-677' ],
];
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = [ 'what is {peptide}' ];
class PRV_Exhausting_Ledger_Cost extends PRV_Cost_Ledger {
private int $count = 0;
public function can_afford( float $e ): bool { return $this->count++ < 1; }
public function update_row_cost( int $id, float $c ): bool { return true; }
public function get_month_to_date_usd(): float { return 4.99; }
}
$cited_body = json_encode([
'choices' => [['message' => ['content' => 'test']]],
'citations' => ['https://peptiderepo.com/bpc-157/'],
'usage' => ['total_tokens' => 50],
]);
// Queue enough responses.
$GLOBALS['prv_test_state']['remote_posts'] = array_fill( 0, 3, ['response' => ['code' => 200], 'body' => $cited_body] );
$runner = new PRV_Probe_Runner( new PRV_Exhausting_Ledger_Cost() );
$counts = $runner->run();
prv_assert( $counts['truncated'], 'truncation flag: set when budget hit mid-run' );
prv_assert_equals( 1, (int) get_option( 'prv_last_run_truncated', 0 ), 'truncation flag: prv_last_run_truncated option set to 1' );
exit( prv_test_summary() );

View File

@@ -0,0 +1,85 @@
<?php
/**
* Tests for PRV_Run_Lock: acquire/release/is_locked concurrency guard.
*
* @package PrVision
*/
declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php';
echo "=== PRV Run Lock Tests ===\n";
// ── Test: acquire returns true when not locked ────────────────────────────
prv_test_reset();
prv_assert( PRV_Run_Lock::acquire(), 'acquire: returns true when lock is free' );
// ── Test: is_locked true while acquired ──────────────────────────────────
prv_assert( PRV_Run_Lock::is_locked(), 'is_locked: true immediately after acquire' );
// ── Test: second acquire returns false (lock busy) ───────────────────────
prv_assert( false === PRV_Run_Lock::acquire(), 'acquire: returns false when already locked' );
// ── Test: release clears the lock ────────────────────────────────────────
PRV_Run_Lock::release();
prv_assert( false === PRV_Run_Lock::is_locked(), 'is_locked: false after release' );
// ── Test: acquire succeeds again after release ───────────────────────────
prv_assert( PRV_Run_Lock::acquire(), 'acquire: succeeds again after release' );
PRV_Run_Lock::release();
// ── Test: locked_since returns null when not locked ──────────────────────
prv_test_reset();
prv_assert( null === PRV_Run_Lock::locked_since(), 'locked_since: null when not locked' );
// ── Test: locked_since returns non-null when locked ──────────────────────
PRV_Run_Lock::acquire();
$since = PRV_Run_Lock::locked_since();
prv_assert( null !== $since, 'locked_since: non-null when locked' );
prv_assert( $since >= 0, 'locked_since: >= 0 seconds' );
PRV_Run_Lock::release();
// ── Test: probe runner refuses when locked (skipped_error sentinel) ───────
prv_test_reset();
PRV_Model_Registry::run_migration_v2();
$GLOBALS['prv_test_state']['options']['prv_peptides'] = [ [ 'slug' => 'bpc-157', 'label' => 'BPC-157' ] ];
$GLOBALS['prv_test_state']['options']['prv_prompt_intents'] = [ 'what is {peptide}' ];
// Pre-acquire the lock.
PRV_Run_Lock::acquire();
if ( ! defined( 'PRV_OPENROUTER_API_KEY' ) ) {
define( 'PRV_OPENROUTER_API_KEY', 'sk-or-test-key' );
}
if ( ! defined( 'PRV_CF_ACCOUNT_ID' ) ) {
define( 'PRV_CF_ACCOUNT_ID', '' );
}
if ( ! defined( 'PRV_CF_GATEWAY_ID' ) ) {
define( 'PRV_CF_GATEWAY_ID', '' );
}
$runner = new PRV_Probe_Runner( new PRV_Cost_Ledger() );
$counts = $runner->run();
// When lock is busy, skipped_error is -1 (sentinel) and probed is 0.
prv_assert_equals( 0, $counts['probed'], 'run-lock: runner returns 0 probed when locked' );
prv_assert_equals( -1, $counts['skipped_error'], 'run-lock: skipped_error is -1 sentinel when lock busy' );
PRV_Run_Lock::release();
// ── Test: runner releases lock even on exception (lock cleanup) ──────────
prv_test_reset();
PRV_Model_Registry::run_migration_v2();
prv_assert( false === PRV_Run_Lock::is_locked(), 'run-lock cleanup: lock released after runner completes' );
exit( prv_test_summary() );

View File

@@ -1,9 +1,6 @@
<?php
/**
* Tests for uninstall purge: table drop + options deletion.
*
* Exercises PRV_Table_Manager::drop_table() and verifies the uninstall
* script logic (simulated because it requires WP_UNINSTALL_PLUGIN).
* Tests for uninstall purge: all prv_ options deleted, lock cleared.
*
* @package PrVision
*/
@@ -12,90 +9,71 @@ declare(strict_types=1);
require_once __DIR__ . '/../bootstrap.php';
echo "=== PRV Uninstall Purge Tests ===\n";
echo "=== PRV Uninstall Tests ===\n";
// ── Mock $wpdb to track DROP calls ──────────────────────────────────────
if ( ! defined( 'PRV_OPENROUTER_API_KEY' ) ) {
define( 'PRV_OPENROUTER_API_KEY', 'sk-or-test-key' );
}
if ( ! defined( 'PRV_CF_ACCOUNT_ID' ) ) {
define( 'PRV_CF_ACCOUNT_ID', '' );
}
if ( ! defined( 'PRV_CF_GATEWAY_ID' ) ) {
define( 'PRV_CF_GATEWAY_ID', '' );
}
$GLOBALS['prv_test_state']['drop_calls'] = [];
// Extend the stub to record DROP statements.
global $wpdb;
$wpdb = new class extends stdClass_wpdb {
public function query( string $sql ): bool {
if ( str_contains( strtoupper( $sql ), 'DROP TABLE' ) ) {
$GLOBALS['prv_test_state']['drop_calls'][] = $sql;
}
if ( str_contains( strtoupper( $sql ), 'DELETE FROM' ) && str_contains( $sql, "prv\\_" ) ) {
$GLOBALS['prv_test_state']['options_deleted'] = true;
}
return true;
}
};
// ── Test: drop_table issues DROP TABLE IF EXISTS ─────────────────────────
// Populate all known prv_ options including v0.2.0 additions.
prv_test_reset();
$GLOBALS['prv_test_state']['drop_calls'] = [];
PRV_Table_Manager::drop_table();
PRV_Model_Registry::run_migration_v2();
PRV_Config_Version::maybe_seed_initial_version();
update_option( 'prv_monthly_budget_usd', 5.0 );
update_option( 'prv_peptides', [] );
update_option( 'prv_prompt_intents', [] );
update_option( PRV_Config::CADENCE_KEY, 'weekly' );
update_option( 'prv_api_key_status', 'ok' );
update_option( 'prv_api_key_last_check', '2026-06-14 12:00:00' );
update_option( 'prv_last_run_at', '2026-06-14 12:00:00' );
update_option( 'prv_last_run_counts', [] );
update_option( 'prv_last_run_truncated', 0 );
update_option( 'prv_last_run_truncated_at', '' );
update_option( 'prv_schema_version', PRV_SCHEMA_VERSION );
$GLOBALS['prv_test_state']['transients'][ PRV_Run_Lock::LOCK_KEY ] = time();
$GLOBALS['prv_test_state']['cron_events'][ PRV_CRON_HOOK ] = [ 'timestamp' => time() + 3600, 'schedule' => 'weekly' ];
prv_assert( ! empty( $GLOBALS['prv_test_state']['drop_calls'] ), 'drop_table: DROP TABLE query executed' );
$drop_sql = $GLOBALS['prv_test_state']['drop_calls'][0] ?? '';
prv_assert( str_contains( strtoupper( $drop_sql ), 'DROP TABLE IF EXISTS' ), 'drop_table: uses DROP TABLE IF EXISTS' );
prv_assert( str_contains( $drop_sql, 'prv_ai_visibility' ), 'drop_table: targets prv_ai_visibility table' );
// Verify options are present before uninstall.
prv_assert( false !== get_option( 'prv_models' ), 'pre-uninstall: prv_models option exists' );
prv_assert( false !== get_option( 'prv_monthly_budget_usd' ), 'pre-uninstall: prv_monthly_budget_usd exists' );
prv_assert( false !== get_option( 'prv_api_key_status' ), 'pre-uninstall: prv_api_key_status exists' );
prv_assert( false !== get_option( 'prv_active_config_version' ), 'pre-uninstall: prv_active_config_version exists' );
prv_assert( false !== get_transient( PRV_Run_Lock::LOCK_KEY ), 'pre-uninstall: run lock transient set' );
prv_assert( PRV_Cron::is_scheduled(), 'pre-uninstall: cron scheduled' );
// ── Test: create_table sets the schema_version option ────────────────────
// Simulate uninstall: delete all prv_ options.
// In production, the SQL wildcard handles this; in test we iterate.
$keys_to_purge = array_filter(
array_keys( $GLOBALS['prv_test_state']['options'] ),
static fn( $k ) => str_starts_with( $k, 'prv_' )
);
foreach ( $keys_to_purge as $k ) {
unset( $GLOBALS['prv_test_state']['options'][ $k ] );
}
prv_test_reset();
PRV_Table_Manager::create_table();
$schema_ver = get_option( 'prv_schema_version' );
prv_assert_equals( PRV_SCHEMA_VERSION, $schema_ver, 'create_table: prv_schema_version option set after create' );
// Also delete transients.
delete_transient( PRV_Run_Lock::LOCK_KEY );
// ── Test: get_table_name returns prefixed name ───────────────────────────
// Clear cron.
wp_clear_scheduled_hook( PRV_CRON_HOOK );
prv_test_reset();
$table = PRV_Table_Manager::get_table_name();
prv_assert_equals( 'wp_prv_ai_visibility', $table, 'get_table_name: returns wp_ prefixed table name' );
// ── Test: seed_defaults writes expected option keys ──────────────────────
prv_test_reset();
PRV_Config::seed_defaults();
prv_assert( isset( $GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] ), 'seed_defaults: prv_monthly_budget_usd set' );
prv_assert( isset( $GLOBALS['prv_test_state']['options']['prv_peptides'] ), 'seed_defaults: prv_peptides set' );
prv_assert( isset( $GLOBALS['prv_test_state']['options']['prv_prompt_intents'] ), 'seed_defaults: prv_prompt_intents set' );
prv_assert( isset( $GLOBALS['prv_test_state']['options']['prv_models'] ), 'seed_defaults: prv_models set' );
// ── Test: seed_defaults does not overwrite existing values ───────────────
prv_test_reset();
$GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'] = 99.0;
PRV_Config::seed_defaults();
prv_assert_equals( 99.0, $GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'], 'seed_defaults: does not overwrite existing prv_monthly_budget_usd' );
// ── Test: default budget is PRV_DEFAULT_MONTHLY_BUDGET_USD ──────────────
prv_test_reset();
PRV_Config::seed_defaults();
prv_assert_equals( PRV_DEFAULT_MONTHLY_BUDGET_USD, $GLOBALS['prv_test_state']['options']['prv_monthly_budget_usd'], 'seed_defaults: default budget = PRV_DEFAULT_MONTHLY_BUDGET_USD (5.0)' );
// ── Test: default peptide count is 12 ───────────────────────────────────
prv_test_reset();
$peptides = PRV_Config::get_peptides();
prv_assert_equals( 12, count( $peptides ), 'get_peptides: default seed has 12 peptides' );
// ── Test: default prompt intent count is 3 ──────────────────────────────
prv_test_reset();
$intents = PRV_Config::get_prompt_intents();
prv_assert_equals( 3, count( $intents ), 'get_prompt_intents: default seed has 3 intents' );
// ── Test: default model count is 3 ──────────────────────────────────────
prv_test_reset();
$models = PRV_Config::get_models();
prv_assert_equals( 3, count( $models ), 'get_models: default seed has 3 models' );
prv_assert( in_array( 'perplexity/sonar', $models, true ), 'get_models: perplexity/sonar in default models' );
// Verify all prv_ options gone.
prv_assert( false === get_option( 'prv_models' ), 'post-uninstall: prv_models deleted' );
prv_assert( false === get_option( 'prv_monthly_budget_usd' ), 'post-uninstall: prv_monthly_budget_usd deleted' );
prv_assert( false === get_option( 'prv_api_key_status' ), 'post-uninstall: prv_api_key_status deleted' );
prv_assert( false === get_option( 'prv_active_config_version' ), 'post-uninstall: prv_active_config_version deleted' );
prv_assert( false === get_option( 'prv_config_versions' ), 'post-uninstall: prv_config_versions deleted' );
prv_assert( false === get_option( 'prv_models_schema_version' ), 'post-uninstall: prv_models_schema_version deleted' );
prv_assert( false === get_option( 'prv_schema_version' ), 'post-uninstall: prv_schema_version deleted' );
prv_assert( false === get_option( 'prv_last_run_truncated' ), 'post-uninstall: prv_last_run_truncated deleted' );
prv_assert( false === get_transient( PRV_Run_Lock::LOCK_KEY ), 'post-uninstall: run lock transient cleared' );
prv_assert( false === PRV_Cron::is_scheduled(), 'post-uninstall: cron cleared' );
exit( prv_test_summary() );

View File

@@ -1,13 +1,18 @@
<?php
/**
* PR Vision — full data teardown on uninstall.
* PR Vision -- full data teardown on uninstall.
*
* Drops the custom prv_ai_visibility table and deletes every wp_options
* row whose name starts with "prv_". WP-Cron events are already cleared
* by PRV_Deactivator on deactivation; this runs after that.
*
* @see ARCHITECTURE.md — §Uninstall specification.
* @see class-prv-deactivator.php — Clears cron on deactivation.
* v0.2.0 additions: prv_models_schema_version, prv_config_versions,
* prv_active_config_version, prv_api_key_status, prv_api_key_last_check,
* prv_last_run_at, prv_last_run_counts, prv_last_run_truncated,
* prv_last_run_truncated_at -- all purged by the prv_ wildcard DELETE below.
*
* @see ARCHITECTURE.md -- Section Uninstall specification.
* @see class-prv-deactivator.php -- Clears cron on deactivation.
* @package PrVision
*/
@@ -23,13 +28,20 @@ $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}" );
/* ── 2. Delete all prv_ prefixed options ──────────────────────────── */
/* ── 2. Delete all prv_ prefixed options (covers v0.1 + v0.2 keys) ── */
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
"DELETE FROM {$wpdb->options} WHERE option_name LIKE 'prv\_%'"
);
/* ── 3. Clear any remaining scheduled cron events ─────────────────── */
/* ── 3. Delete prv_ transients ────────────────────────────────────── */
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
"DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_prv\_%' OR option_name LIKE '_transient_timeout_prv\_%'"
);
/* ── 4. Clear any remaining scheduled cron events ─────────────────── */
wp_clear_scheduled_hook( 'prv_weekly_probe' );