Compare commits
6 Commits
claude/pr-
...
claude/pr-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5cfa39f60 | ||
|
|
da25f0d9cb | ||
|
|
b3a96cf627 | ||
|
|
7b9f53ec60 | ||
|
|
2a2424ce8c | ||
|
|
2fcef7a323 |
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@@ -47,12 +47,24 @@ jobs:
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.1'
|
||||
tools: composer:v2, phpcs
|
||||
|
||||
- name: Install Composer dependencies
|
||||
run: composer install --no-interaction --prefer-dist --no-progress
|
||||
- name: Install WordPress Coding Standards (global)
|
||||
run: |
|
||||
composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
|
||||
composer global require --no-interaction \
|
||||
"dealerdirect/phpcodesniffer-composer-installer:^1.0" \
|
||||
"wp-coding-standards/wpcs:^3.0" \
|
||||
"phpcsstandards/phpcsutils:^1.0"
|
||||
|
||||
- name: Run PHPCS
|
||||
run: composer run lint
|
||||
run: |
|
||||
phpcs --standard=phpcs.xml.dist \
|
||||
--extensions=php \
|
||||
--ignore=vendor/*,tests/*,node_modules/* \
|
||||
--warning-severity=0 \
|
||||
--report=full \
|
||||
includes/ pr-vision.php uninstall.php
|
||||
|
||||
# ── 300-line file check ──────────────────────────────────────────
|
||||
file-size:
|
||||
|
||||
70
.github/workflows/deploy.yml
vendored
Normal file
70
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
name: Deploy to Hostinger
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-pr-vision
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
name: Pre-deploy CI
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
deploy:
|
||||
name: Deploy (rsync to Hostinger PROD)
|
||||
needs: ci
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_deploy
|
||||
chmod 600 ~/.ssh/id_deploy
|
||||
ssh-keyscan -p "${{ secrets.SSH_PORT }}" "${{ secrets.SSH_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: rsync plugin to PROD
|
||||
run: |
|
||||
rsync -az --delete \
|
||||
--exclude='.git' \
|
||||
--exclude='.github' \
|
||||
--exclude='tests' \
|
||||
--exclude='phpunit.xml.dist' \
|
||||
--exclude='composer.json' \
|
||||
--exclude='composer.lock' \
|
||||
--exclude='vendor' \
|
||||
--exclude='phpcs.xml.dist' \
|
||||
--exclude='*.md' \
|
||||
-e "ssh -p ${{ secrets.SSH_PORT }} -i ~/.ssh/id_deploy -o StrictHostKeyChecking=no" \
|
||||
./ \
|
||||
"${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }}:~/domains/peptiderepo.com/public_html/wp-content/plugins/pr-vision/"
|
||||
|
||||
- name: Purge LiteSpeed cache
|
||||
run: |
|
||||
ssh -p "${{ secrets.SSH_PORT }}" \
|
||||
-i ~/.ssh/id_deploy \
|
||||
-o StrictHostKeyChecking=no \
|
||||
"${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }}" \
|
||||
'wp --path=~/domains/peptiderepo.com/public_html litespeed-purge all 2>/dev/null || true'
|
||||
|
||||
- name: Health check (expect 200 or 403)
|
||||
run: |
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
--max-time 15 \
|
||||
"https://peptiderepo.com/wp-admin/admin.php?page=pr-vision" \
|
||||
-L --user-agent "GitHub-Actions-Deploy-Health-Check/1.0")
|
||||
echo "HTTP status: $STATUS"
|
||||
if [ "$STATUS" = "200" ] || [ "$STATUS" = "403" ]; then
|
||||
echo "Health check passed."
|
||||
else
|
||||
echo "Health check FAILED with status $STATUS"
|
||||
exit 1
|
||||
fi
|
||||
194
ARCHITECTURE.md
194
ARCHITECTURE.md
@@ -1,14 +1,15 @@
|
||||
# Architecture — PR Vision
|
||||
|
||||
**Version:** 0.1.1 | **Last updated:** 2026-06-13
|
||||
**Version:** 0.2.1 | **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
|
||||
@@ -45,7 +55,8 @@ pr-vision/
|
||||
│ │ └── class-prv-ai-visibility-collector.php # AI-visibility data from DB
|
||||
│ └── panel/
|
||||
│ ├── interface-prv-dashboard-panel.php # render(data): void seam
|
||||
│ └── class-prv-ai-visibility-panel.php # Renders trendline + standings
|
||||
│ ├── class-prv-ai-visibility-panel.php # [v0.2.1] Orchestrates render; delegates to PRV_Dashboard_Renderer
|
||||
│ └── class-prv-dashboard-renderer.php # [v0.2.1] Bento tiles (run-health pill) + trendline config-change markers
|
||||
├── tests/
|
||||
│ ├── bootstrap.php # WP-stub bootstrap (no PHPUnit)
|
||||
│ └── unit/
|
||||
@@ -54,9 +65,15 @@ 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
|
||||
└── .github/workflows/ci.yml # PHP lint matrix + PHPCS + 300-line check
|
||||
│ ├── 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
|
||||
└── .github/workflows/deploy.yml # [v0.2.1] Standalone rsync deploy to Hostinger PROD (CI gated)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -64,42 +81,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 +142,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 +206,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). |
|
||||
|
||||
69
CHANGELOG.md
69
CHANGELOG.md
@@ -56,3 +56,72 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## [0.2.1] — 2026-06-14
|
||||
|
||||
Dashboard visuals + deploy pipeline.
|
||||
|
||||
### Added
|
||||
|
||||
- **KPI bento bar on dashboard:** Three-tile band replacing the flat meta-bar. Score tile carries a **run-health pill** (dot + word: "All models healthy" / "N model(s) degraded" / "No run yet") sourced from `PRV_Model_Registry` health fields via `PRV_Ai_Visibility_Collector::collect_model_health()`. Cost tile carries the MTD meter + optional truncation badge. Last-run tile shows timestamp.
|
||||
- **Config-change trendline marker:** Inline Chart.js plugin (`configMarker`) draws a vertical dashed orange line + pin circle at every `config_version` boundary in the trendline data. A legend swatch and a plain-language annotation note appear below the chart when breaks exist.
|
||||
- **`PRV_Dashboard_Renderer`:** New class (`includes/panel/class-prv-dashboard-renderer.php`) owns the bento tiles and trendline rendering; `PRV_Ai_Visibility_Panel` delegates to it. Split keeps all files ≤ 300 lines.
|
||||
- **Collector extensions:** `PRV_Ai_Visibility_Collector::collect()` now returns two new keys: `last_run_counts` (per-model health map from `PRV_Model_Registry`) and `config_versions` (all version records from `PRV_Config_Version`). The trendline query now includes `MIN(config_version)` per `run_id`.
|
||||
- **Admin page CSS:** `PRV_Admin_Page::get_dashboard_css()` returns the "Assay" dark palette CSS (CSS custom-property palette, bento grid, health pills, chart legend swatches, cfg-note, standings status chips) scoped to `.prv-*` selectors.
|
||||
- **`.github/workflows/deploy.yml`:** Standalone rsync deploy — on push to `main`: runs CI as prerequisite, rsyncs the plugin to `~/domains/peptiderepo.com/public_html/wp-content/plugins/pr-vision/` (excludes `.git .github tests phpunit.xml.dist composer.json composer.lock vendor phpcs.xml.dist *.md`), purges LiteSpeed cache via WP-CLI, health-checks (200 or 403). Uses repo secrets `SSH_HOST`/`SSH_USERNAME`/`SSH_PRIVATE_KEY`/`SSH_PORT`. Standalone — does NOT wire into the smoke-gated shared pipeline.
|
||||
- **`tests/unit/test-dashboard-panel.php`:** 27 assertions covering: `derive_health_pill_state` (all states + edge cases), `PRV_Dashboard_Renderer::build_delta_html` (up/down/flat/null), collector `config_version` field on trendline rows, collector `last_run_counts` and `config_versions` keys, null config_version graceful handling, model health payload from registry, trendline output capture (marker present/absent, noscript fallback element).
|
||||
|
||||
### Changed
|
||||
|
||||
- `PRV_Ai_Visibility_Panel` refactored: bento and trendline rendering extracted to `PRV_Dashboard_Renderer`; panel is now a thin orchestrator (237 lines). No interface change.
|
||||
- `PRV_Admin_Page::enqueue_assets()` now calls `get_dashboard_css()` for the "Assay" palette CSS instead of the legacy inline style block.
|
||||
- Version bumped to **0.2.1**.
|
||||
|
||||
37
CONTEXT.md
37
CONTEXT.md
@@ -1,6 +1,6 @@
|
||||
# Context — Domain Glossary (PR Vision)
|
||||
|
||||
**Version:** 0.1.1 | **Last updated:** 2026-06-13
|
||||
**Version:** 0.2.1 | **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.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Conventions — PR Vision
|
||||
|
||||
**Version:** 0.1.1 | **Last updated:** 2026-06-13
|
||||
**Version:** 0.2.1 | **Last updated:** 2026-06-14
|
||||
|
||||
Applies to all contributors and AI agents working in this repo. Where this document and `AGENT-OPERATING-STANDARD.md` conflict on process, the Standard wins; this document wins on code style.
|
||||
|
||||
|
||||
@@ -11,9 +11,11 @@ declare(strict_types=1);
|
||||
* AI-visibility data collector — v1 implementation of PRV_Data_Collector.
|
||||
*
|
||||
* Reads the prv_ai_visibility table and returns structured data for:
|
||||
* - Trendline: per-run visibility score (% cited, position-weighted).
|
||||
* - Trendline: per-run visibility score (% cited, position-weighted) with config_version stamp.
|
||||
* - Standings: per-peptide latest cited status, our position, competing domains.
|
||||
* - Run metadata: last run time, MTD cost vs cap.
|
||||
* - Per-model run-health from PRV_Model_Registry (last_run_counts).
|
||||
* - All config-version records for trendline break annotations (config_versions).
|
||||
*
|
||||
* Score formula (documented in CONTEXT.md):
|
||||
* base_score = cited_probes / total_probes (range 0–1)
|
||||
@@ -21,10 +23,13 @@ declare(strict_types=1);
|
||||
* visibility_score = round((base_score + position_bonus) / 2, 4)
|
||||
*
|
||||
* Who triggers: PRV_Admin_Page calls the registered collector via the registry.
|
||||
* Dependencies: $wpdb, PRV_Table_Manager, PRV_Cost_Ledger, PRV_Config.
|
||||
* Dependencies: $wpdb, PRV_Table_Manager, PRV_Cost_Ledger, PRV_Config,
|
||||
* PRV_Model_Registry, PRV_Config_Version.
|
||||
*
|
||||
* @see interface-prv-data-collector.php — Interface this implements.
|
||||
* @see class-prv-ai-visibility-panel.php — Renders the data returned here.
|
||||
* @see class-prv-model-registry.php — Source of per-model health data.
|
||||
* @see class-prv-config-version.php — Source of config-version records.
|
||||
* @see CONTEXT.md — Score formula + glossary.
|
||||
* @see ARCHITECTURE.md — §Storage, §Score.
|
||||
* @package PrVision
|
||||
@@ -51,11 +56,13 @@ class PRV_Ai_Visibility_Collector implements PRV_Data_Collector {
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* @return array{
|
||||
* trendline: array<int, array{run_id: string, captured_at: string, score: float}>,
|
||||
* trendline: array<int, array{run_id: string, captured_at: string, score: float, config_version: int|null}>,
|
||||
* standings: array<string, array{label: string, cited: bool, our_position: int|null, top_domains: string[], model_count: int}>,
|
||||
* last_run_at: string|null,
|
||||
* mtd_cost_usd: float,
|
||||
* monthly_cap_usd: float,
|
||||
* last_run_counts: array<string, array{health_status: string, health_probed: int, health_errors: int}>,
|
||||
* config_versions: array<int, array<string, mixed>>,
|
||||
* }
|
||||
*/
|
||||
public function collect(): array {
|
||||
@@ -63,13 +70,14 @@ class PRV_Ai_Visibility_Collector implements PRV_Data_Collector {
|
||||
|
||||
$table = PRV_Table_Manager::get_table_name();
|
||||
|
||||
// ── Trendline: one score per run_id ────────────────────────────
|
||||
// ── Trendline: one score per run_id, with config_version ───────
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$run_rows = $wpdb->get_results(
|
||||
"SELECT run_id, MIN(captured_at) AS captured_at,
|
||||
SUM(cited) AS cited_count,
|
||||
COUNT(*) AS total_count,
|
||||
SUM(CASE WHEN our_position IS NOT NULL THEN 1.0/our_position ELSE 0 END) AS position_sum
|
||||
SUM(CASE WHEN our_position IS NOT NULL THEN 1.0/our_position ELSE 0 END) AS position_sum,
|
||||
MIN(config_version) AS config_version
|
||||
FROM {$table}
|
||||
GROUP BY run_id
|
||||
ORDER BY MIN(captured_at) ASC
|
||||
@@ -81,13 +89,14 @@ class PRV_Ai_Visibility_Collector implements PRV_Data_Collector {
|
||||
$trendline = array();
|
||||
foreach ( (array) $run_rows as $row ) {
|
||||
$trendline[] = array(
|
||||
'run_id' => (string) $row['run_id'],
|
||||
'captured_at' => (string) $row['captured_at'],
|
||||
'score' => $this->compute_score(
|
||||
'run_id' => (string) $row['run_id'],
|
||||
'captured_at' => (string) $row['captured_at'],
|
||||
'score' => $this->compute_score(
|
||||
(int) $row['cited_count'],
|
||||
(int) $row['total_count'],
|
||||
(float) $row['position_sum']
|
||||
),
|
||||
'config_version' => isset( $row['config_version'] ) ? (int) $row['config_version'] : null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,7 +117,7 @@ class PRV_Ai_Visibility_Collector implements PRV_Data_Collector {
|
||||
|
||||
$standings = array();
|
||||
foreach ( (array) $standing_rows as $row ) {
|
||||
$top_domains = $this->extract_top_domains( (string) ( $row['domains_json_list'] ?? '' ) ); // phpcs:ignore Generic.Formatting.MultipleStatementAlignment.NotSameWarning
|
||||
$top_domains = $this->extract_top_domains( (string) ( $row['domains_json_list'] ?? '' ) );
|
||||
$standings[ (string) $row['peptide_slug'] ] = array(
|
||||
'label' => (string) $row['peptide_label'],
|
||||
'cited' => (bool) (int) $row['cited'],
|
||||
@@ -123,12 +132,20 @@ class PRV_Ai_Visibility_Collector implements PRV_Data_Collector {
|
||||
$last_run = $wpdb->get_var( "SELECT MAX(captured_at) FROM {$table}" );
|
||||
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
|
||||
// ── Per-model health from PRV_Model_Registry ────────────────────
|
||||
$last_run_counts = $this->collect_model_health();
|
||||
|
||||
// ── Config-version records for trendline break annotations ──────
|
||||
$config_versions = PRV_Config_Version::get_all_versions();
|
||||
|
||||
return array(
|
||||
'trendline' => $trendline,
|
||||
'standings' => $standings,
|
||||
'last_run_at' => $last_run ? (string) $last_run : null,
|
||||
'mtd_cost_usd' => $this->ledger->get_month_to_date_usd(),
|
||||
'monthly_cap_usd' => PRV_Config::get_monthly_budget_usd(),
|
||||
'last_run_counts' => $last_run_counts,
|
||||
'config_versions' => $config_versions,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,6 +176,32 @@ class PRV_Ai_Visibility_Collector implements PRV_Data_Collector {
|
||||
return round( ( $base_score + $position_bonus ) / 2.0, 4 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect per-model health data from PRV_Model_Registry for the dashboard pill.
|
||||
*
|
||||
* Returns a keyed map of model slug -> health fields. Models with
|
||||
* health_status='disabled' are included so the pill reflects all registered
|
||||
* models, not just the active ones.
|
||||
*
|
||||
* @return array<string, array{health_status: string, health_probed: int, health_errors: int}>
|
||||
*/
|
||||
private function collect_model_health(): array {
|
||||
$models = PRV_Model_Registry::get_all();
|
||||
$out = array();
|
||||
foreach ( $models as $m ) {
|
||||
$slug = (string) ( $m['slug'] ?? '' );
|
||||
if ( '' === $slug ) {
|
||||
continue;
|
||||
}
|
||||
$out[ $slug ] = array(
|
||||
'health_status' => (string) ( $m['health_status'] ?? 'unknown' ),
|
||||
'health_probed' => (int) ( $m['health_probed'] ?? 0 ),
|
||||
'health_errors' => (int) ( $m['health_errors'] ?? 0 ),
|
||||
);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and deduplicate the top domain names from a GROUP_CONCAT result.
|
||||
*
|
||||
|
||||
@@ -10,9 +10,10 @@ declare(strict_types=1);
|
||||
/**
|
||||
* PR Vision top-level admin page.
|
||||
*
|
||||
* Registers the "PR Vision" menu item (manage_options), enqueues Chart.js,
|
||||
* handles the "Run now" POST action (nonce + capability), and delegates
|
||||
* rendering to the registered panels via PRV_Collector_Registry.
|
||||
* Registers the "PR Vision" menu item (manage_options), enqueues Chart.js
|
||||
* and the dashboard stylesheet, handles the "Run now" POST action (nonce +
|
||||
* capability), and delegates rendering to the registered panels via
|
||||
* PRV_Collector_Registry.
|
||||
*
|
||||
* Who triggers: PRV_Plugin::init() → is_admin() guard.
|
||||
* Dependencies: PRV_Collector_Registry, PRV_Probe_Runner.
|
||||
@@ -20,6 +21,7 @@ declare(strict_types=1);
|
||||
* @see class-prv-plugin.php — Calls register_hooks().
|
||||
* @see class-prv-collector-registry.php — Provides panels.
|
||||
* @see class-prv-probe-runner.php — Invoked by the Run now action.
|
||||
* @see class-prv-ai-visibility-panel.php — Panel renderer, consumes CSS added here.
|
||||
* @see ARCHITECTURE.md — §Admin dashboard.
|
||||
* @package PrVision
|
||||
*/
|
||||
@@ -73,7 +75,10 @@ class PRV_Admin_Page {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue Chart.js on the PR Vision page only.
|
||||
* Enqueue Chart.js and dashboard styles on the PR Vision page only.
|
||||
*
|
||||
* Chart.js is loaded from the jsdelivr CDN. The dashboard panel degrades
|
||||
* gracefully when the CDN is blocked (typeof guard + .prv-noscript fallback).
|
||||
*
|
||||
* @param string $hook Current admin page hook.
|
||||
*
|
||||
@@ -92,16 +97,7 @@ class PRV_Admin_Page {
|
||||
true
|
||||
);
|
||||
|
||||
wp_add_inline_style(
|
||||
'wp-admin',
|
||||
'.prv-meta-bar{display:flex;gap:20px;margin:10px 0 15px;flex-wrap:wrap;}
|
||||
.prv-meta-item{background:#f6f7f7;padding:6px 12px;border-radius:4px;font-size:13px;}
|
||||
.prv-card{background:#fff;border:1px solid #c3c4c7;padding:16px 20px;margin-bottom:20px;border-radius:4px;}
|
||||
.prv-card h2{margin-top:0;}
|
||||
.prv-proxy-note{margin:10px 0;}
|
||||
.prv-standings th,.prv-standings td{vertical-align:middle;}
|
||||
canvas#prv-trendline-chart{max-height:300px;}'
|
||||
);
|
||||
wp_add_inline_style( 'wp-admin', $this->get_dashboard_css() );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,7 +154,6 @@ class PRV_Admin_Page {
|
||||
|
||||
$this->render_run_done_notice();
|
||||
|
||||
// "Run now" button form.
|
||||
echo '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '">';
|
||||
echo '<input type="hidden" name="action" value="prv_run_now">';
|
||||
wp_nonce_field( self::NONCE_ACTION, self::NONCE_FIELD );
|
||||
@@ -204,4 +199,77 @@ class PRV_Admin_Page {
|
||||
);
|
||||
echo '</p></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the dashboard CSS for the PR Vision admin page.
|
||||
*
|
||||
* Uses CSS custom properties matching the "Assay" dark design palette from
|
||||
* design/pr-vision/design-proposal.md. Scoped to .prv-* selectors so it
|
||||
* cannot leak into WP admin chrome.
|
||||
*
|
||||
* @return string CSS string.
|
||||
*/
|
||||
private function get_dashboard_css(): string {
|
||||
return '
|
||||
/* === PR Vision dashboard — "Assay" palette (dark-adapted) === */
|
||||
.prv-bento{display:grid;grid-template-columns:1.25fr 1fr 1fr;gap:16px;margin:18px 0 22px;}
|
||||
@media(max-width:820px){.prv-bento{grid-template-columns:1fr;}}
|
||||
.prv-tile{background:#1C2228;border:1px solid #3A4651;border-radius:12px;padding:20px 22px;position:relative;min-height:140px;display:flex;flex-direction:column;}
|
||||
.prv-tile-label{font-size:11.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:#9AA7B2;margin-bottom:6px;}
|
||||
.prv-tile-big{font-size:42px;font-weight:700;color:#EEF2F5;line-height:1;font-variant-numeric:tabular-nums;margin-top:auto;}
|
||||
.prv-tile-big small{font-size:16px;font-weight:600;color:#9AA7B2;margin-left:3px;}
|
||||
.prv-tile-sub{font-size:12.5px;color:#9AA7B2;margin-top:8px;}
|
||||
.prv-tile-delta{font-size:12.5px;font-weight:600;margin-top:8px;}
|
||||
.prv-delta--up{color:#9BE635;} .prv-delta--down{color:#FF6B5E;} .prv-delta--flat{color:#9AA7B2;}
|
||||
/* health pill */
|
||||
.prv-health-pill{position:absolute;top:14px;right:14px;display:inline-flex;align-items:center;gap:5px;font-size:11.5px;font-weight:600;padding:3px 9px 3px 7px;border-radius:999px;}
|
||||
.prv-health-dot{width:7px;height:7px;border-radius:50%;flex:0 0 7px;}
|
||||
.prv-health--ok{background:rgba(155,230,53,.15);color:#9BE635;} .prv-health--ok .prv-health-dot{background:#9BE635;}
|
||||
.prv-health--warn{background:rgba(255,157,77,.15);color:#FFB36E;} .prv-health--warn .prv-health-dot{background:#FF9D4D;}
|
||||
.prv-health--neutral{background:rgba(136,147,160,.14);color:#B4BDC7;} .prv-health--neutral .prv-health-dot{background:#8893A0;}
|
||||
/* cost tile */
|
||||
.prv-cost-row{display:flex;align-items:baseline;gap:4px;margin-top:auto;}
|
||||
.prv-cost-big{font-size:28px;font-weight:700;color:#EEF2F5;font-variant-numeric:tabular-nums;}
|
||||
.prv-cost-cap{font-size:13px;color:#9AA7B2;}
|
||||
.prv-meter{height:10px;border-radius:999px;background:#2A333C;margin-top:8px;border:1px solid #333D47;overflow:hidden;}
|
||||
.prv-meter>span{display:block;height:100%;border-radius:999px;background:#34C0CA;transition:width .3s ease;}
|
||||
.prv-meter--capped>span{background:#FF9D4D;}
|
||||
.prv-trunc-badge{display:flex;align-items:flex-start;gap:7px;margin-top:10px;background:rgba(255,157,77,.15);border:1px solid rgba(255,157,77,.42);border-radius:6px;padding:8px 10px;font-size:12px;color:#EEF2F5;line-height:1.4;}
|
||||
.prv-trunc-icon{color:#FFB36E;font-size:14px;flex:0 0 16px;margin-top:1px;}
|
||||
.prv-trunc-badge b{color:#FFB36E;}
|
||||
/* last run tile */
|
||||
.prv-tile-ts{font-size:20px;font-weight:700;color:#EEF2F5;margin-top:auto;line-height:1.2;}
|
||||
.prv-tile-meta{font-size:12.5px;color:#9AA7B2;margin-top:6px;}
|
||||
/* cards */
|
||||
.prv-card{background:#1C2228;border:1px solid #3A4651;border-radius:12px;margin:0 0 20px;}
|
||||
.prv-card-head{display:flex;align-items:center;justify-content:space-between;padding:18px 22px 0;}
|
||||
.prv-card-head h2{font-size:16px;font-weight:600;color:#EEF2F5;margin:0;}
|
||||
.prv-card-body{padding:10px 22px 20px;}
|
||||
/* chart */
|
||||
.prv-chartbox{position:relative;height:300px;max-height:300px;padding:14px 8px 0;overflow:hidden;}
|
||||
.prv-chartbox canvas{max-height:286px;}
|
||||
.prv-chart-fallback{display:none;height:100%;align-items:center;justify-content:center;text-align:center;color:#9AA7B2;font-size:13px;padding:0 24px;}
|
||||
.prv-chartbox.prv-noscript .prv-chart-fallback{display:flex;}
|
||||
.prv-chartbox.prv-noscript canvas{display:none;}
|
||||
/* chart legend */
|
||||
.prv-chartcap{display:flex;align-items:center;gap:16px;flex-wrap:wrap;padding:8px 22px 4px;font-size:12.5px;color:#9AA7B2;}
|
||||
.prv-chartcap-item{display:inline-flex;align-items:center;gap:6px;}
|
||||
.prv-leg-solid{width:20px;height:0;border-top:3px solid #34C0CA;display:inline-block;}
|
||||
.prv-leg-dash{width:20px;height:0;border-top:2px dashed #B6F25A;display:inline-block;}
|
||||
.prv-leg-cfg{display:inline-flex;align-items:center;gap:3px;}
|
||||
.prv-leg-cfg::before{content:"";width:0;height:14px;border-left:2px dashed #FFB36E;display:inline-block;}
|
||||
.prv-leg-cfg::after{content:"";width:8px;height:8px;border-radius:50%;background:#FF9D4D;display:inline-block;}
|
||||
/* config change note */
|
||||
.prv-cfg-note{display:flex;gap:10px;align-items:flex-start;margin:8px 22px 14px;background:rgba(255,157,77,.15);border:1px solid rgba(255,157,77,.42);border-radius:8px;padding:11px 14px;color:#EEF2F5;font-size:13px;line-height:1.45;}
|
||||
.prv-cfg-note-icon{color:#FFB36E;font-style:normal;flex:0 0 16px;margin-top:1px;}
|
||||
.prv-cfg-note b{color:#FFB36E;}
|
||||
/* standings table */
|
||||
.prv-standings{margin:0;}
|
||||
.prv-status{display:inline-flex;align-items:center;gap:4px;font-size:12.5px;font-weight:600;padding:2px 8px;border-radius:999px;}
|
||||
.prv-status--cited{background:rgba(155,230,53,.15);color:#9BE635;}
|
||||
.prv-status--not-yet{background:rgba(136,147,160,.14);color:#B4BDC7;}
|
||||
/* proxy note */
|
||||
.prv-proxy-note{margin:10px 0;}
|
||||
';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
177
includes/core/class-prv-config-version.php
Normal file
177
includes/core/class-prv-config-version.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 );
|
||||
}
|
||||
}
|
||||
|
||||
190
includes/core/class-prv-model-manager-table.php
Normal file
190
includes/core/class-prv-model-manager-table.php
Normal 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">● Healthy (' . esc_html( (string) $probed ) . ' rows)</span>';
|
||||
case 'retired':
|
||||
return '<span class="prv-badge prv-badge-retired">● Retired? (0 rows · ' . esc_html( (string) $errors ) . ' errors)</span>';
|
||||
case 'disabled':
|
||||
return '<span class="prv-badge prv-badge-disabled">● Off · retired</span>';
|
||||
default:
|
||||
return '<span class="prv-badge prv-badge-unknown">● 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>';
|
||||
}
|
||||
}
|
||||
299
includes/core/class-prv-model-registry.php
Normal file
299
includes/core/class-prv-model-registry.php
Normal 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 ) );
|
||||
}
|
||||
}
|
||||
95
includes/core/class-prv-model-test-ajax.php
Normal file
95
includes/core/class-prv-model-test-ajax.php
Normal 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?)',
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
94
includes/core/class-prv-run-lock.php
Normal file
94
includes/core/class-prv-run-lock.php
Normal 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 );
|
||||
}
|
||||
}
|
||||
217
includes/core/class-prv-settings-controller.php
Normal file
217
includes/core/class-prv-settings-controller.php
Normal 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;
|
||||
}
|
||||
}
|
||||
193
includes/core/class-prv-settings-page.php
Normal file
193
includes/core/class-prv-settings-page.php
Normal 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 );
|
||||
}
|
||||
}
|
||||
}
|
||||
298
includes/core/class-prv-settings-renderer.php
Normal file
298
includes/core/class-prv-settings-renderer.php
Normal 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 · ';
|
||||
echo '~$' . esc_html( number_format( $cost['per_month_usd'], 4 ) ) . '/month · ';
|
||||
echo esc_html( (string) $cost['probe_count'] ) . ' probes/run';
|
||||
if ( $cost['over_cap'] ) {
|
||||
echo ' <strong>⚠ 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">● ' . 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">● ' . 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">● ' . 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>';
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
40
includes/core/class-prv-upgrader.php
Normal file
40
includes/core/class-prv-upgrader.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -8,26 +8,50 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* AI-visibility dashboard panel — renders the PR Vision admin page.
|
||||
* AI-visibility dashboard panel — orchestrates the PR Vision admin page.
|
||||
*
|
||||
* Renders four sections:
|
||||
* Renders five sections by delegating the visual heavy-lifting to helper classes:
|
||||
* 1. Proxy-metric disclaimer note.
|
||||
* 2. Chart.js trendline of visibility score across runs.
|
||||
* 3. Per-peptide standings table (cited, position, top competitor domains).
|
||||
* 4. MTD cost vs cap + last-run time.
|
||||
* 2. KPI bento bar (score tile with run-health pill, cost tile, last-run tile)
|
||||
* — via PRV_Dashboard_Renderer::render_bento().
|
||||
* 3. Chart.js trendline with config-change vertical markers
|
||||
* — via PRV_Dashboard_Renderer::render_trendline().
|
||||
* 4. Per-peptide standings table.
|
||||
*
|
||||
* All output is escaped. Chart.js is enqueued via PRV_Admin_Page.
|
||||
* The run-health pill (score tile) and config-change markers (trendline) are
|
||||
* the two v0.2.1 additions.
|
||||
*
|
||||
* Chart.js is enqueued via PRV_Admin_Page. The chart sits in a fixed-height box
|
||||
* and degrades gracefully: if Chart.js is unavailable the typeof guard adds
|
||||
* .prv-noscript, hiding the canvas and showing a fallback note in the same space.
|
||||
*
|
||||
* Who triggers: PRV_Admin_Page::render_page() via PRV_Collector_Registry.
|
||||
* Dependencies: PRV_Ai_Visibility_Collector (data format contract).
|
||||
* Dependencies: PRV_Ai_Visibility_Collector (data format), PRV_Dashboard_Renderer.
|
||||
*
|
||||
* @see interface-prv-dashboard-panel.php — Interface this implements.
|
||||
* @see class-prv-ai-visibility-collector.php — Produces the $data array.
|
||||
* @see class-prv-dashboard-renderer.php — Renders bento + trendline.
|
||||
* @see class-prv-admin-page.php — Enqueues Chart.js; calls render().
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Ai_Visibility_Panel implements PRV_Dashboard_Panel {
|
||||
|
||||
/**
|
||||
* Visual renderer for the bento tiles and trendline.
|
||||
*
|
||||
* @var PRV_Dashboard_Renderer
|
||||
*/
|
||||
private PRV_Dashboard_Renderer $renderer;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param PRV_Dashboard_Renderer|null $renderer Injected for testing; auto-created otherwise.
|
||||
*/
|
||||
public function __construct( ?PRV_Dashboard_Renderer $renderer = null ) {
|
||||
$this->renderer = $renderer ?? new PRV_Dashboard_Renderer();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
@@ -36,15 +60,16 @@ class PRV_Ai_Visibility_Panel implements PRV_Dashboard_Panel {
|
||||
* @return void
|
||||
*/
|
||||
public function render( array $data ): void {
|
||||
$trendline = is_array( $data['trendline'] ?? null ) ? $data['trendline'] : array();
|
||||
$standings = is_array( $data['standings'] ?? null ) ? $data['standings'] : array();
|
||||
$last_run_at = isset( $data['last_run_at'] ) && $data['last_run_at'] ? esc_html( (string) $data['last_run_at'] ) : esc_html__( 'Never', 'pr-vision' );
|
||||
$mtd_cost = isset( $data['mtd_cost_usd'] ) ? (float) $data['mtd_cost_usd'] : 0.0;
|
||||
$cap = isset( $data['monthly_cap_usd'] ) ? (float) $data['monthly_cap_usd'] : PRV_DEFAULT_MONTHLY_BUDGET_USD;
|
||||
$trendline = is_array( $data['trendline'] ?? null ) ? $data['trendline'] : array();
|
||||
$standings = is_array( $data['standings'] ?? null ) ? $data['standings'] : array();
|
||||
$last_run_at = isset( $data['last_run_at'] ) && $data['last_run_at'] ? esc_html( (string) $data['last_run_at'] ) : esc_html__( 'Never', 'pr-vision' );
|
||||
$mtd_cost = isset( $data['mtd_cost_usd'] ) ? (float) $data['mtd_cost_usd'] : 0.0;
|
||||
$cap = isset( $data['monthly_cap_usd'] ) ? (float) $data['monthly_cap_usd'] : PRV_DEFAULT_MONTHLY_BUDGET_USD;
|
||||
$last_run_counts = is_array( $data['last_run_counts'] ?? null ) ? $data['last_run_counts'] : array();
|
||||
|
||||
$this->render_proxy_note();
|
||||
$this->render_meta_bar( $last_run_at, $mtd_cost, $cap );
|
||||
$this->render_trendline( $trendline );
|
||||
$this->render_bento_band( $trendline, $standings, $last_run_at, $mtd_cost, $cap, $last_run_counts );
|
||||
$this->renderer->render_trendline( $trendline );
|
||||
$this->render_standings( $standings );
|
||||
}
|
||||
|
||||
@@ -68,55 +93,53 @@ class PRV_Ai_Visibility_Panel implements PRV_Dashboard_Panel {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the run metadata bar (last run, cost, cap).
|
||||
* Prepare bento-band data and delegate rendering to PRV_Dashboard_Renderer.
|
||||
*
|
||||
* @param string $last_run_at Formatted last-run timestamp or "Never".
|
||||
* @param float $mtd_cost Month-to-date spend in USD.
|
||||
* @param float $cap Monthly cap in USD.
|
||||
* @param array $trendline Trendline data points from PRV_Ai_Visibility_Collector.
|
||||
* @param array $standings Per-peptide standings map.
|
||||
* @param string $last_run_at Formatted run timestamp or "Never".
|
||||
* @param float $mtd_cost Month-to-date spend in USD.
|
||||
* @param float $cap Monthly budget cap in USD.
|
||||
* @param array $last_run_counts Per-model health map (slug → {health_status}).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_meta_bar( string $last_run_at, float $mtd_cost, float $cap ): void {
|
||||
$pct = $cap > 0 ? min( 100.0, round( $mtd_cost / $cap * 100, 1 ) ) : 0;
|
||||
echo '<div class="prv-meta-bar">';
|
||||
echo '<span class="prv-meta-item"><strong>' . esc_html__( 'Last run:', 'pr-vision' ) . '</strong> ' . esc_html( $last_run_at ) . '</span>';
|
||||
echo '<span class="prv-meta-item"><strong>' . esc_html__( 'MTD cost:', 'pr-vision' ) . '</strong> $' . esc_html( number_format( $mtd_cost, 4 ) ) . ' / $' . esc_html( number_format( $cap, 2 ) ) . ' (' . esc_html( $pct ) . '% of cap)</span>';
|
||||
echo '</div>';
|
||||
}
|
||||
private function render_bento_band(
|
||||
array $trendline,
|
||||
array $standings,
|
||||
string $last_run_at,
|
||||
float $mtd_cost,
|
||||
float $cap,
|
||||
array $last_run_counts
|
||||
): void {
|
||||
$current_score = empty( $trendline ) ? 0.0 : (float) end( $trendline )['score'];
|
||||
$prev_score = count( $trendline ) >= 2 ? (float) $trendline[ count( $trendline ) - 2 ]['score'] : null;
|
||||
$cited_count = 0;
|
||||
$total_count = count( $standings );
|
||||
|
||||
/**
|
||||
* Render the Chart.js visibility score trendline.
|
||||
*
|
||||
* @param array<int, array{run_id: string, captured_at: string, score: float}> $trendline Trendline data points.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_trendline( array $trendline ): void {
|
||||
echo '<div class="prv-card"><h2>' . esc_html__( 'Visibility Score — Trend', 'pr-vision' ) . '</h2>';
|
||||
|
||||
if ( empty( $trendline ) ) {
|
||||
echo '<p>' . esc_html__( 'No probe runs recorded yet. Click "Run now" to collect the first data point.', 'pr-vision' ) . '</p></div>';
|
||||
return;
|
||||
foreach ( $standings as $row ) {
|
||||
if ( ! empty( $row['cited'] ) ) {
|
||||
$cited_count++;
|
||||
}
|
||||
}
|
||||
|
||||
$labels = array();
|
||||
$scores = array();
|
||||
foreach ( $trendline as $point ) {
|
||||
$labels[] = esc_js( gmdate( 'M j', (int) strtotime( $point['captured_at'] ) ) );
|
||||
$scores[] = (float) $point['score'];
|
||||
}
|
||||
$health = $this->derive_health_pill_state( $last_run_counts );
|
||||
$pct = $cap > 0 ? min( 100.0, round( $mtd_cost / $cap * 100, 1 ) ) : 0;
|
||||
$truncated = (bool) get_option( 'prv_last_run_truncated', false );
|
||||
|
||||
$labels_json = wp_json_encode( $labels );
|
||||
$scores_json = wp_json_encode( $scores );
|
||||
|
||||
echo '<canvas id="prv-trendline-chart" height="120"></canvas>';
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo '<script>document.addEventListener("DOMContentLoaded",function(){';
|
||||
echo 'var ctx=document.getElementById("prv-trendline-chart").getContext("2d");';
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo 'new Chart(ctx,{type:"line",data:{labels:' . $labels_json . ',datasets:[{label:"Visibility Score",data:' . $scores_json . ',borderColor:"#2271b1",tension:0.3,fill:false}]},options:{scales:{y:{min:0,max:1,title:{display:true,text:"Score"}}},plugins:{legend:{display:false}}}});';
|
||||
echo '});</script>';
|
||||
echo '</div>';
|
||||
$this->renderer->render_bento(
|
||||
$current_score,
|
||||
$prev_score,
|
||||
$cited_count,
|
||||
$total_count,
|
||||
$health,
|
||||
$mtd_cost,
|
||||
$cap,
|
||||
$pct,
|
||||
$truncated,
|
||||
$last_run_at,
|
||||
$total_count
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,15 +150,14 @@ class PRV_Ai_Visibility_Panel implements PRV_Dashboard_Panel {
|
||||
* @return void
|
||||
*/
|
||||
private function render_standings( array $standings ): void {
|
||||
echo '<div class="prv-card"><h2>' . esc_html__( 'Current Standings', 'pr-vision' ) . '</h2>';
|
||||
echo '<div class="prv-card"><div class="prv-card-head"><h2>' . esc_html__( 'Current Standings', 'pr-vision' ) . '</h2></div><div class="prv-card-body">';
|
||||
|
||||
if ( empty( $standings ) ) {
|
||||
echo '<p>' . esc_html__( 'No standings data yet.', 'pr-vision' ) . '</p></div>';
|
||||
echo '<p>' . esc_html__( 'No standings data yet.', 'pr-vision' ) . '</p></div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
echo '<table class="wp-list-table widefat fixed striped prv-standings">';
|
||||
echo '<thead><tr>';
|
||||
echo '<table class="wp-list-table widefat fixed striped prv-standings"><thead><tr>';
|
||||
echo '<th>' . esc_html__( 'Peptide', 'pr-vision' ) . '</th>';
|
||||
echo '<th>' . esc_html__( 'Cited?', 'pr-vision' ) . '</th>';
|
||||
echo '<th>' . esc_html__( 'Our Position', 'pr-vision' ) . '</th>';
|
||||
@@ -143,18 +165,74 @@ class PRV_Ai_Visibility_Panel implements PRV_Dashboard_Panel {
|
||||
echo '</tr></thead><tbody>';
|
||||
|
||||
foreach ( $standings as $row ) {
|
||||
$cited_icon = $row['cited'] ? '✅' : '❌';
|
||||
$position = null !== $row['our_position'] ? '#' . (int) $row['our_position'] : '—';
|
||||
$domains = implode( ', ', array_map( 'esc_html', (array) $row['top_domains'] ) );
|
||||
$is_cited = ! empty( $row['cited'] );
|
||||
$cited_dot = $is_cited ? '●' : '○';
|
||||
$cited_label = $is_cited ? esc_html__( 'Cited', 'pr-vision' ) : esc_html__( 'Not yet', 'pr-vision' );
|
||||
$cited_class = $is_cited ? 'prv-status prv-status--cited' : 'prv-status prv-status--not-yet';
|
||||
$position = null !== $row['our_position'] ? '#' . (int) $row['our_position'] : '—';
|
||||
$domains = implode( ', ', array_map( 'esc_html', (array) $row['top_domains'] ) );
|
||||
|
||||
echo '<tr>';
|
||||
echo '<td>' . esc_html( (string) $row['label'] ) . '</td>';
|
||||
echo '<td>' . $cited_icon . '</td>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- only emoji
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- dot is a fixed HTML entity; label is escaped by esc_html__() above; class is esc_attr'd
|
||||
echo '<td><span class="' . esc_attr( $cited_class ) . '">' . $cited_dot . ' ' . $cited_label . '</span></td>';
|
||||
echo '<td>' . esc_html( $position ) . '</td>';
|
||||
echo '<td>' . wp_kses_post( $domains ) . '</td>';
|
||||
echo '</tr>';
|
||||
}
|
||||
|
||||
echo '</tbody></table></div>';
|
||||
echo '</tbody></table></div></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the run-health pill state and label from per-model health data.
|
||||
*
|
||||
* States: 'ok' (all healthy/disabled), 'warn' (any retired), 'neutral' (no data yet).
|
||||
*
|
||||
* @param array<string, array{health_status: string}> $last_run_counts Per-model health map.
|
||||
*
|
||||
* @return array{state: string, label: string}
|
||||
*/
|
||||
private function derive_health_pill_state( array $last_run_counts ): array {
|
||||
if ( empty( $last_run_counts ) ) {
|
||||
return array(
|
||||
'state' => 'neutral',
|
||||
'label' => __( 'No run yet', 'pr-vision' ),
|
||||
);
|
||||
}
|
||||
|
||||
$retired_count = 0;
|
||||
$healthy_count = 0;
|
||||
foreach ( $last_run_counts as $model ) {
|
||||
$status = (string) ( $model['health_status'] ?? 'unknown' );
|
||||
if ( 'retired' === $status ) {
|
||||
$retired_count++;
|
||||
} elseif ( 'healthy' === $status ) {
|
||||
$healthy_count++;
|
||||
}
|
||||
}
|
||||
|
||||
if ( $retired_count > 0 ) {
|
||||
return array(
|
||||
'state' => 'warn',
|
||||
'label' => sprintf(
|
||||
/* translators: %d: number of degraded models */
|
||||
_n( '%d model degraded', '%d models degraded', $retired_count, 'pr-vision' ),
|
||||
$retired_count
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if ( 0 === $healthy_count ) {
|
||||
return array(
|
||||
'state' => 'neutral',
|
||||
'label' => __( 'No run yet', 'pr-vision' ),
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'state' => 'ok',
|
||||
'label' => __( 'All models healthy', 'pr-vision' ),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
299
includes/panel/class-prv-dashboard-renderer.php
Normal file
299
includes/panel/class-prv-dashboard-renderer.php
Normal file
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
/**
|
||||
* Dashboard visual renderer: bento tiles + trendline chart for the AI-visibility panel.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Renders the KPI bento bar and the Chart.js trendline for PRV_Ai_Visibility_Panel.
|
||||
*
|
||||
* Split from PRV_Ai_Visibility_Panel to keep each file under 300 lines.
|
||||
* Handles the v0.2.1 additions: run-health pill on the score tile, config-change
|
||||
* vertical dashed markers on the trendline, and the budget-truncation badge on
|
||||
* the cost tile.
|
||||
*
|
||||
* Who triggers: PRV_Ai_Visibility_Panel::render() — called directly.
|
||||
* Dependencies: none (all data passed as arguments).
|
||||
*
|
||||
* @see class-prv-ai-visibility-panel.php — Orchestrator that calls this renderer.
|
||||
* @see class-prv-ai-visibility-collector.php — Produces the data consumed here.
|
||||
* @see design/pr-vision/design-proposal.md — Palette + layout specification.
|
||||
* @package PrVision
|
||||
*/
|
||||
class PRV_Dashboard_Renderer {
|
||||
|
||||
/**
|
||||
* Render the three-tile KPI bento band.
|
||||
*
|
||||
* @param float $current_score Current visibility score 0–1.
|
||||
* @param float|null $prev_score Previous-run score, null if first run.
|
||||
* @param int $cited_count Number of cited peptides.
|
||||
* @param int $total_count Total peptides tracked.
|
||||
* @param array $health Run-health pill state + label ({state, label}).
|
||||
* @param float $mtd_cost Month-to-date spend USD.
|
||||
* @param float $cap Monthly budget cap USD.
|
||||
* @param float $pct Percentage of cap consumed.
|
||||
* @param bool $truncated Whether the last run hit the cap.
|
||||
* @param string $last_run_at Formatted run timestamp or "Never".
|
||||
* @param int $row_count Total peptide row count.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_bento(
|
||||
float $current_score,
|
||||
?float $prev_score,
|
||||
int $cited_count,
|
||||
int $total_count,
|
||||
array $health,
|
||||
float $mtd_cost,
|
||||
float $cap,
|
||||
float $pct,
|
||||
bool $truncated,
|
||||
string $last_run_at,
|
||||
int $row_count
|
||||
): void {
|
||||
echo '<div class="prv-bento">';
|
||||
$this->render_score_tile( $current_score, $prev_score, $cited_count, $total_count, $health );
|
||||
$this->render_cost_tile( $mtd_cost, $cap, $pct, $truncated );
|
||||
$this->render_lastrun_tile( $last_run_at, $row_count );
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Chart.js trendline with config-change vertical markers.
|
||||
*
|
||||
* The chart lives in a fixed-height .prv-chartbox (300px) and is created
|
||||
* inside a typeof-Chart guard. If Chart.js is unavailable the guard adds
|
||||
* .prv-noscript, hiding the canvas and showing an in-box fallback note
|
||||
* without reflow or throw.
|
||||
*
|
||||
* Config-change markers are detected from consecutive config_version values
|
||||
* in the trendline. An inline configMarker Chart.js plugin draws a vertical
|
||||
* dashed orange line + pin circle at each boundary index.
|
||||
*
|
||||
* @param array<int, array{run_id: string, captured_at: string, score: float, config_version?: int}> $trendline Trendline data points.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function render_trendline( array $trendline ): void {
|
||||
echo '<div class="prv-card prv-card--chart">';
|
||||
echo '<div class="prv-card-head"><h2>' . esc_html__( 'Visibility Score — Trend', 'pr-vision' ) . '</h2></div>';
|
||||
|
||||
if ( empty( $trendline ) ) {
|
||||
echo '<div class="prv-card-body"><p>' . esc_html__( 'No probe runs recorded yet. Click "Run now" to collect the first data point.', 'pr-vision' ) . '</p></div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$labels = array();
|
||||
$scores = array();
|
||||
$marker_indices = array();
|
||||
$prev_version = null;
|
||||
|
||||
foreach ( $trendline as $idx => $point ) {
|
||||
$labels[] = gmdate( 'M j', (int) strtotime( (string) $point['captured_at'] ) );
|
||||
$scores[] = round( (float) $point['score'] * 100, 2 );
|
||||
|
||||
$cv = isset( $point['config_version'] ) ? (int) $point['config_version'] : null;
|
||||
if ( null !== $prev_version && null !== $cv && $cv !== $prev_version ) {
|
||||
$marker_indices[] = $idx;
|
||||
}
|
||||
$prev_version = $cv;
|
||||
}
|
||||
|
||||
$labels_json = wp_json_encode( $labels );
|
||||
$scores_json = wp_json_encode( $scores );
|
||||
$marker_indices_json = wp_json_encode( $marker_indices );
|
||||
$has_markers = ! empty( $marker_indices );
|
||||
|
||||
echo '<div class="prv-chartbox">';
|
||||
echo '<canvas id="prv-trendline-chart" aria-label="' . esc_attr__( 'Visibility score over time', 'pr-vision' ) . '" role="img"></canvas>';
|
||||
echo '<div class="prv-chart-fallback">' . esc_html__( 'Trend chart needs Chart.js — the data still reads in the standings table below.', 'pr-vision' ) . '</div>';
|
||||
echo '</div>';
|
||||
|
||||
echo '<div class="prv-chartcap">';
|
||||
echo '<span class="prv-chartcap-item"><span class="prv-leg-solid"></span>' . esc_html__( 'Visibility score (%)', 'pr-vision' ) . '</span>';
|
||||
echo '<span class="prv-chartcap-item"><span class="prv-leg-dash"></span>' . esc_html__( 'First-citation goal', 'pr-vision' ) . '</span>';
|
||||
if ( $has_markers ) {
|
||||
echo '<span class="prv-chartcap-item"><span class="prv-leg-cfg"></span>' . esc_html__( 'Config changed', 'pr-vision' ) . '</span>';
|
||||
}
|
||||
echo '</div>';
|
||||
|
||||
if ( $has_markers ) {
|
||||
echo '<div class="prv-cfg-note"><span class="prv-cfg-note-icon" aria-hidden="true">ℹ</span>';
|
||||
echo '<span><b>' . esc_html__( 'Scored config changed', 'pr-vision' ) . '</b> — ';
|
||||
echo esc_html__( 'segments either side of the orange markers are separate baselines; the line does not represent one continuous trend across breaks.', 'pr-vision' );
|
||||
echo '</span></div>';
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo '<script>(function(){';
|
||||
echo 'var labels=' . $labels_json . ';';
|
||||
echo 'var scores=' . $scores_json . ';';
|
||||
echo 'var markerIdx=' . $marker_indices_json . ';';
|
||||
echo 'var box=document.getElementById("prv-trendline-chart");';
|
||||
echo 'if(!box){return;}';
|
||||
echo 'var wrap=box.closest?box.closest(".prv-chartbox"):box.parentNode;';
|
||||
echo 'if(typeof Chart==="undefined"){if(wrap){wrap.classList.add("prv-noscript");}return;}';
|
||||
$this->echo_chart_js_plugin();
|
||||
echo 'var ctx=box.getContext("2d");';
|
||||
echo 'var grad=ctx.createLinearGradient(0,0,0,300);';
|
||||
echo 'grad.addColorStop(0,"rgba(52,192,202,0.28)");grad.addColorStop(1,"rgba(52,192,202,0)");';
|
||||
echo 'new Chart(ctx,{plugins:[configMarker],type:"line",data:{labels:labels,datasets:[';
|
||||
echo '{label:' . wp_json_encode( __( 'Visibility Score (%)', 'pr-vision' ) ) . ',data:scores,';
|
||||
echo 'borderColor:"#34C0CA",backgroundColor:grad,tension:0.3,fill:true,';
|
||||
echo 'pointBackgroundColor:"#34C0CA",pointBorderColor:"#14181C",pointBorderWidth:1,pointRadius:4},';
|
||||
echo '{label:' . wp_json_encode( __( 'First-citation goal (5%)', 'pr-vision' ) ) . ',data:scores.map(function(){return 5;}),';
|
||||
echo 'borderColor:"#B6F25A",borderDash:[6,4],borderWidth:1.5,pointRadius:0,fill:false}';
|
||||
echo ']},options:{maintainAspectRatio:false,interaction:{mode:"index",intersect:false},';
|
||||
echo 'scales:{x:{grid:{color:"#2C353E"},ticks:{color:"#9AA7B2",font:{size:11}}},';
|
||||
echo 'y:{min:0,max:100,grid:{color:"#2C353E"},ticks:{color:"#9AA7B2",font:{size:11}},';
|
||||
echo 'title:{display:true,text:"Score %",color:"#9AA7B2",font:{size:11}}}},';
|
||||
echo 'plugins:{legend:{display:false},tooltip:{backgroundColor:"#232B33",borderColor:"#3A4651",borderWidth:1,';
|
||||
echo 'titleColor:"#EEF2F5",bodyColor:"#C2CCD6",callbacks:{afterBody:function(items){';
|
||||
echo 'var idx=items[0].dataIndex;return markerIdx.indexOf(idx)>=0?["⚑ Scored config changed here"]:[];';
|
||||
echo '}}}}';
|
||||
echo '}});})();</script>';
|
||||
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the configMarker inline Chart.js plugin (no external CDN).
|
||||
*
|
||||
* Draws a vertical dashed orange line + pin circle at each marker index.
|
||||
* Must be called inside a <script> block after markerIdx is defined.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function echo_chart_js_plugin(): void {
|
||||
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo 'var configMarker={id:"configMarker",afterDraw:function(chart){';
|
||||
echo 'if(!markerIdx.length){return;}';
|
||||
echo 'var c=chart.ctx,xA=chart.scales.x,yA=chart.scales.y;';
|
||||
echo 'c.save();';
|
||||
echo 'markerIdx.forEach(function(idx){';
|
||||
echo 'var xP=xA.getPixelForValue(idx);';
|
||||
echo 'c.strokeStyle="#FFB36E";c.lineWidth=2;c.setLineDash([5,4]);';
|
||||
echo 'c.beginPath();c.moveTo(xP,yA.top);c.lineTo(xP,yA.bottom);c.stroke();';
|
||||
echo 'c.setLineDash([]);c.fillStyle="#FF9D4D";c.strokeStyle="#14181C";c.lineWidth=1.5;';
|
||||
echo 'c.beginPath();c.arc(xP,yA.top+8,5,0,Math.PI*2);c.fill();c.stroke();';
|
||||
echo '});c.restore();';
|
||||
echo '}};';
|
||||
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the visibility score KPI tile with run-health pill.
|
||||
*
|
||||
* @param float $score Current visibility score 0–1.
|
||||
* @param float|null $prev_score Previous run score, null if first run.
|
||||
* @param int $cited Number of cited peptides.
|
||||
* @param int $total Total peptides tracked.
|
||||
* @param array $health Run-health pill state ({state, label}).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_score_tile( float $score, ?float $prev_score, int $cited, int $total, array $health ): void {
|
||||
$score_pct = round( $score * 100, 1 );
|
||||
$delta_html = $this->build_delta_html( $score, $prev_score );
|
||||
|
||||
echo '<div class="prv-tile prv-tile--score">';
|
||||
echo '<span class="prv-health-pill prv-health--' . esc_attr( $health['state'] ) . '" aria-label="' . esc_attr__( 'Run health', 'pr-vision' ) . '">';
|
||||
echo '<span class="prv-health-dot" aria-hidden="true"></span>';
|
||||
echo esc_html( $health['label'] );
|
||||
echo '</span>';
|
||||
echo '<div class="prv-tile-label">' . esc_html__( 'Visibility Score', 'pr-vision' ) . '</div>';
|
||||
echo '<div class="prv-tile-big">' . esc_html( (string) $score_pct ) . '<small>%</small></div>';
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built by build_delta_html; esc_html() applied on all user values inside
|
||||
echo $delta_html;
|
||||
echo '<div class="prv-tile-sub">' . esc_html(
|
||||
sprintf(
|
||||
/* translators: 1: cited count, 2: total peptide count */
|
||||
__( '%1$d of %2$d peptides cited', 'pr-vision' ),
|
||||
$cited,
|
||||
$total
|
||||
)
|
||||
) . '</div>';
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the MTD cost KPI tile with budget meter and optional truncation badge.
|
||||
*
|
||||
* @param float $mtd_cost Month-to-date spend USD.
|
||||
* @param float $cap Monthly cap USD.
|
||||
* @param float $pct Percentage of cap consumed.
|
||||
* @param bool $truncated Whether the last run was truncated by the cap.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_cost_tile( float $mtd_cost, float $cap, float $pct, bool $truncated ): void {
|
||||
$meter_class = $pct >= 100 ? 'prv-meter prv-meter--capped' : 'prv-meter';
|
||||
echo '<div class="prv-tile prv-tile--cost">';
|
||||
echo '<div class="prv-tile-label">' . esc_html__( 'MTD Cost', 'pr-vision' ) . '</div>';
|
||||
echo '<div class="prv-cost-row">';
|
||||
echo '<span class="prv-cost-big">$' . esc_html( number_format( $mtd_cost, 4 ) ) . '</span>';
|
||||
echo '<span class="prv-cost-cap"> / $' . esc_html( number_format( $cap, 2 ) ) . '</span>';
|
||||
echo '</div>';
|
||||
echo '<div class="' . esc_attr( $meter_class ) . '" role="progressbar" aria-valuenow="' . esc_attr( (string) $pct ) . '" aria-valuemin="0" aria-valuemax="100" aria-label="' . esc_attr__( 'Budget used', 'pr-vision' ) . '"><span style="width:' . esc_attr( $pct ) . '%"></span></div>';
|
||||
if ( $truncated ) {
|
||||
echo '<div class="prv-trunc-badge"><span class="prv-trunc-icon" aria-hidden="true">⚠</span>';
|
||||
echo '<span><b>' . esc_html__( 'Last run truncated', 'pr-vision' ) . '</b> — ';
|
||||
echo esc_html__( 'hit the cap before all probes completed.', 'pr-vision' ) . '</span></div>';
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the last-run KPI tile.
|
||||
*
|
||||
* @param string $last_run_at Formatted run timestamp or "Never".
|
||||
* @param int $row_count Peptide count tracked.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function render_lastrun_tile( string $last_run_at, int $row_count ): void {
|
||||
echo '<div class="prv-tile prv-tile--lastrun">';
|
||||
echo '<div class="prv-tile-label">' . esc_html__( 'Last Run', 'pr-vision' ) . '</div>';
|
||||
echo '<div class="prv-tile-ts">' . esc_html( $last_run_at ) . '</div>';
|
||||
if ( $row_count > 0 ) {
|
||||
echo '<div class="prv-tile-meta">' . esc_html(
|
||||
sprintf(
|
||||
/* translators: %d: peptide row count */
|
||||
__( '%d peptides tracked', 'pr-vision' ),
|
||||
$row_count
|
||||
)
|
||||
) . '</div>';
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a pre-escaped delta indicator HTML string for the score tile.
|
||||
*
|
||||
* @param float $current Current score 0–1.
|
||||
* @param float|null $previous Previous run score, null if first run.
|
||||
*
|
||||
* @return string Pre-escaped HTML, or empty string when no previous score.
|
||||
*/
|
||||
public function build_delta_html( float $current, ?float $previous ): string {
|
||||
if ( null === $previous ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$diff = round( ( $current - $previous ) * 100, 2 );
|
||||
|
||||
if ( $diff > 0 ) {
|
||||
return '<div class="prv-tile-delta prv-delta--up">▲ +' . esc_html( number_format( $diff, 2 ) ) . '%</div>';
|
||||
}
|
||||
if ( $diff < 0 ) {
|
||||
return '<div class="prv-tile-delta prv-delta--down">▼ ' . esc_html( number_format( $diff, 2 ) ) . '%</div>';
|
||||
}
|
||||
|
||||
return '<div class="prv-tile-delta prv-delta--flat">— ' . esc_html__( 'no change', 'pr-vision' ) . '</div>';
|
||||
}
|
||||
}
|
||||
@@ -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.1
|
||||
* 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.1' );
|
||||
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 );
|
||||
|
||||
@@ -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,22 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
define( 'ABSPATH', __DIR__ . '/' );
|
||||
}
|
||||
|
||||
define( 'PRV_VERSION', '0.1.1' );
|
||||
// WordPress DB result-format constants.
|
||||
if ( ! defined( 'ARRAY_A' ) ) {
|
||||
define( 'ARRAY_A', 'ARRAY_A' );
|
||||
}
|
||||
if ( ! defined( 'OBJECT' ) ) {
|
||||
define( 'OBJECT', 'OBJECT' );
|
||||
}
|
||||
if ( ! defined( 'ARRAY_N' ) ) {
|
||||
define( 'ARRAY_N', 'ARRAY_N' );
|
||||
}
|
||||
|
||||
define( 'PRV_VERSION', '0.2.1' );
|
||||
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 +43,7 @@ define( 'PRV_TARGET_DOMAIN', 'peptiderepo.com' );
|
||||
|
||||
$GLOBALS['prv_test_state'] = [
|
||||
'options' => [],
|
||||
'transients' => [],
|
||||
'actions' => [],
|
||||
'wpdb_insert_id' => 1,
|
||||
'wpdb_results' => [],
|
||||
@@ -43,6 +55,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 +84,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 +129,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 +145,17 @@ 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 _n( string $single, string $plural, int $number, string $domain = 'default' ): string { return 1 === $number ? $single : $plural; }
|
||||
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 $domain = 'default' ): string { return esc_attr( $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 +165,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 +218,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 +235,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 +263,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 +281,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;
|
||||
|
||||
120
tests/unit/test-config-version.php
Normal file
120
tests/unit/test-config-version.php
Normal 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() );
|
||||
90
tests/unit/test-cron-reschedule.php
Normal file
90
tests/unit/test-cron-reschedule.php
Normal 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() );
|
||||
264
tests/unit/test-dashboard-panel.php
Normal file
264
tests/unit/test-dashboard-panel.php
Normal file
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRV_Ai_Visibility_Panel and PRV_Dashboard_Renderer data-shaping added
|
||||
* in v0.2.1: config-version trendline annotation, KPI health pill, and collector
|
||||
* last_run_counts / config_versions payload fields.
|
||||
*
|
||||
* @package PrVision
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../bootstrap.php';
|
||||
|
||||
echo "=== test-dashboard-panel.php ===\n";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Invoke a private or protected method on an object instance.
|
||||
*
|
||||
* @param object $obj Instance to invoke on.
|
||||
* @param string $method Method name.
|
||||
* @param array $args Arguments.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
function prv_call_private( object $obj, string $method, array $args = array() ) {
|
||||
$ref = new ReflectionMethod( $obj, $method );
|
||||
$ref->setAccessible( true );
|
||||
return $ref->invokeArgs( $obj, $args );
|
||||
}
|
||||
|
||||
// ── 1. Panel: derive_health_pill_state ───────────────────────────────────────
|
||||
|
||||
echo "\n-- derive_health_pill_state --\n";
|
||||
|
||||
$panel = new PRV_Ai_Visibility_Panel();
|
||||
|
||||
$result = prv_call_private( $panel, 'derive_health_pill_state', array( array() ) );
|
||||
prv_assert_equals( 'neutral', $result['state'], 'health pill: empty map → neutral' );
|
||||
|
||||
$result = prv_call_private(
|
||||
$panel,
|
||||
'derive_health_pill_state',
|
||||
array(
|
||||
array(
|
||||
'slug-a' => array( 'health_status' => 'healthy' ),
|
||||
'slug-b' => array( 'health_status' => 'healthy' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
prv_assert_equals( 'ok', $result['state'], 'health pill: all healthy → ok' );
|
||||
prv_assert( false !== strpos( $result['label'], 'healthy' ), 'health pill: ok label contains "healthy"' );
|
||||
|
||||
$result = prv_call_private(
|
||||
$panel,
|
||||
'derive_health_pill_state',
|
||||
array(
|
||||
array(
|
||||
'slug-a' => array( 'health_status' => 'healthy' ),
|
||||
'slug-b' => array( 'health_status' => 'retired' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
prv_assert_equals( 'warn', $result['state'], 'health pill: one retired → warn' );
|
||||
prv_assert( false !== strpos( $result['label'], '1' ), 'health pill: warn label contains count "1"' );
|
||||
|
||||
$result = prv_call_private(
|
||||
$panel,
|
||||
'derive_health_pill_state',
|
||||
array(
|
||||
array(
|
||||
'slug-a' => array( 'health_status' => 'retired' ),
|
||||
'slug-b' => array( 'health_status' => 'retired' ),
|
||||
),
|
||||
)
|
||||
);
|
||||
prv_assert_equals( 'warn', $result['state'], 'health pill: two retired → warn' );
|
||||
prv_assert( false !== strpos( $result['label'], '2' ), 'health pill: warn label contains count "2"' );
|
||||
|
||||
$result = prv_call_private(
|
||||
$panel,
|
||||
'derive_health_pill_state',
|
||||
array( array( 'slug-a' => array( 'health_status' => 'disabled' ) ) )
|
||||
);
|
||||
prv_assert_equals( 'neutral', $result['state'], 'health pill: disabled-only → neutral' );
|
||||
|
||||
$result = prv_call_private(
|
||||
$panel,
|
||||
'derive_health_pill_state',
|
||||
array( array( 'slug-a' => array( 'health_status' => 'unknown' ) ) )
|
||||
);
|
||||
prv_assert_equals( 'neutral', $result['state'], 'health pill: unknown status → neutral' );
|
||||
|
||||
// ── 2. Dashboard Renderer: build_delta_html ──────────────────────────────────
|
||||
|
||||
echo "\n-- PRV_Dashboard_Renderer::build_delta_html --\n";
|
||||
|
||||
$renderer = new PRV_Dashboard_Renderer();
|
||||
|
||||
$delta_up = $renderer->build_delta_html( 0.10, 0.08 );
|
||||
$delta_down = $renderer->build_delta_html( 0.05, 0.08 );
|
||||
$delta_flat = $renderer->build_delta_html( 0.08, 0.08 );
|
||||
$delta_null = $renderer->build_delta_html( 0.08, null );
|
||||
|
||||
prv_assert( false !== strpos( $delta_up, 'prv-delta--up' ), 'delta: positive → --up class' );
|
||||
prv_assert( false !== strpos( $delta_up, '+' ), 'delta: positive → "+" prefix' );
|
||||
prv_assert( false !== strpos( $delta_down, 'prv-delta--down' ), 'delta: negative → --down class' );
|
||||
prv_assert( false !== strpos( $delta_flat, 'prv-delta--flat' ), 'delta: zero → --flat class' );
|
||||
prv_assert_equals( '', $delta_null, 'delta: null previous → empty string' );
|
||||
|
||||
// Precision: 0.10 - 0.08 = +2.00 pp
|
||||
prv_assert( false !== strpos( $delta_up, '2.00' ), 'delta: value formatted to 2dp' );
|
||||
|
||||
// ── 3. Collector: trendline includes config_version ──────────────────────────
|
||||
|
||||
echo "\n-- collector trendline config_version field --\n";
|
||||
|
||||
prv_test_reset();
|
||||
|
||||
$GLOBALS['prv_test_state']['wpdb_results'] = array(
|
||||
array(
|
||||
'run_id' => 'run-001',
|
||||
'captured_at' => '2026-06-01 10:00:00',
|
||||
'cited_count' => 2,
|
||||
'total_count' => 10,
|
||||
'position_sum' => 0.5,
|
||||
'config_version' => 1,
|
||||
),
|
||||
array(
|
||||
'run_id' => 'run-002',
|
||||
'captured_at' => '2026-06-08 10:00:00',
|
||||
'cited_count' => 3,
|
||||
'total_count' => 10,
|
||||
'position_sum' => 0.6,
|
||||
'config_version' => 2,
|
||||
),
|
||||
);
|
||||
$GLOBALS['prv_test_state']['wpdb_var'] = '2026-06-08 10:00:00';
|
||||
|
||||
$collector = new PRV_Ai_Visibility_Collector();
|
||||
$data = $collector->collect();
|
||||
|
||||
prv_assert( isset( $data['trendline'] ), 'collector: trendline key present' );
|
||||
prv_assert_equals( 2, count( $data['trendline'] ), 'collector: two trendline rows' );
|
||||
prv_assert( array_key_exists( 'config_version', $data['trendline'][0] ), 'trendline[0] has config_version key' );
|
||||
prv_assert_equals( 1, $data['trendline'][0]['config_version'], 'trendline[0].config_version = 1' );
|
||||
prv_assert_equals( 2, $data['trendline'][1]['config_version'], 'trendline[1].config_version = 2' );
|
||||
|
||||
// ── 4. Collector: last_run_counts and config_versions keys present ────────────
|
||||
|
||||
echo "\n-- collector: new payload keys --\n";
|
||||
|
||||
prv_assert( isset( $data['last_run_counts'] ), 'collector: last_run_counts key present' );
|
||||
prv_assert( is_array( $data['last_run_counts'] ), 'collector: last_run_counts is array' );
|
||||
prv_assert( isset( $data['config_versions'] ), 'collector: config_versions key present' );
|
||||
prv_assert( is_array( $data['config_versions'] ), 'collector: config_versions is array' );
|
||||
|
||||
// ── 5. Collector: null config_version handled gracefully ─────────────────────
|
||||
|
||||
echo "\n-- collector: null config_version row --\n";
|
||||
|
||||
prv_test_reset();
|
||||
$GLOBALS['prv_test_state']['wpdb_results'] = array(
|
||||
array(
|
||||
'run_id' => 'run-001',
|
||||
'captured_at' => '2026-06-01 10:00:00',
|
||||
'cited_count' => 1,
|
||||
'total_count' => 5,
|
||||
'position_sum' => 0.25,
|
||||
'config_version' => null,
|
||||
),
|
||||
);
|
||||
$GLOBALS['prv_test_state']['wpdb_var'] = '2026-06-01 10:00:00';
|
||||
|
||||
$collector2 = new PRV_Ai_Visibility_Collector();
|
||||
$data2 = $collector2->collect();
|
||||
|
||||
prv_assert_equals( 1, count( $data2['trendline'] ), 'null config_version row included' );
|
||||
prv_assert( null === $data2['trendline'][0]['config_version'], 'null config_version preserved as null' );
|
||||
|
||||
// ── 6. Collector: collect_model_health from registry ─────────────────────────
|
||||
|
||||
echo "\n-- collector: collect_model_health --\n";
|
||||
|
||||
prv_test_reset();
|
||||
|
||||
$GLOBALS['prv_test_state']['options']['prv_models_schema_version'] = 2;
|
||||
$GLOBALS['prv_test_state']['options']['prv_models'] = array(
|
||||
array(
|
||||
'id' => 'mdl_aaa',
|
||||
'slug' => 'perplexity/sonar',
|
||||
'provider' => 'perplexity',
|
||||
'enabled' => true,
|
||||
'note' => '',
|
||||
'health_status' => 'healthy',
|
||||
'health_probed' => 10,
|
||||
'health_errors' => 0,
|
||||
'health_run_id' => 'run-001',
|
||||
),
|
||||
array(
|
||||
'id' => 'mdl_bbb',
|
||||
'slug' => 'openai/gpt-4o-search-preview',
|
||||
'provider' => 'openrouter',
|
||||
'enabled' => true,
|
||||
'note' => '',
|
||||
'health_status' => 'retired',
|
||||
'health_probed' => 0,
|
||||
'health_errors' => 5,
|
||||
'health_run_id' => 'run-001',
|
||||
),
|
||||
);
|
||||
$GLOBALS['prv_test_state']['wpdb_results'] = array();
|
||||
$GLOBALS['prv_test_state']['wpdb_var'] = null;
|
||||
|
||||
$collector3 = new PRV_Ai_Visibility_Collector();
|
||||
$data3 = $collector3->collect();
|
||||
$last_run_counts = $data3['last_run_counts'];
|
||||
|
||||
prv_assert( isset( $last_run_counts['perplexity/sonar'] ), 'sonar slug present in last_run_counts' );
|
||||
prv_assert_equals( 'healthy', $last_run_counts['perplexity/sonar']['health_status'], 'sonar = healthy' );
|
||||
prv_assert( isset( $last_run_counts['openai/gpt-4o-search-preview'] ), 'gpt slug present in last_run_counts' );
|
||||
prv_assert_equals( 'retired', $last_run_counts['openai/gpt-4o-search-preview']['health_status'], 'gpt = retired' );
|
||||
|
||||
// Health pill should be warn from this data.
|
||||
$pill = prv_call_private( $panel, 'derive_health_pill_state', array( $last_run_counts ) );
|
||||
prv_assert_equals( 'warn', $pill['state'], 'health pill from registry data → warn' );
|
||||
|
||||
// ── 7. Renderer: trendline marker detection ───────────────────────────────────
|
||||
|
||||
echo "\n-- renderer: config-change marker detection (output capture) --\n";
|
||||
|
||||
// Verify that a single-version trendline produces no cfg-note.
|
||||
ob_start();
|
||||
$renderer->render_trendline(
|
||||
array(
|
||||
array( 'run_id' => 'r1', 'captured_at' => '2026-06-01 10:00:00', 'score' => 0.0, 'config_version' => 1 ),
|
||||
array( 'run_id' => 'r2', 'captured_at' => '2026-06-08 10:00:00', 'score' => 0.0, 'config_version' => 1 ),
|
||||
)
|
||||
);
|
||||
$out_no_marker = ob_get_clean();
|
||||
prv_assert( false === strpos( $out_no_marker, 'prv-cfg-note' ), 'no marker: no cfg-note rendered when config version unchanged' );
|
||||
prv_assert( false !== strpos( $out_no_marker, 'prv-trendline-chart' ), 'no marker: canvas present' );
|
||||
|
||||
// Verify that a two-version trendline produces cfg-note and markerIdx.
|
||||
ob_start();
|
||||
$renderer->render_trendline(
|
||||
array(
|
||||
array( 'run_id' => 'r1', 'captured_at' => '2026-06-01 10:00:00', 'score' => 0.0, 'config_version' => 1 ),
|
||||
array( 'run_id' => 'r2', 'captured_at' => '2026-06-08 10:00:00', 'score' => 0.0, 'config_version' => 2 ),
|
||||
)
|
||||
);
|
||||
$out_with_marker = ob_get_clean();
|
||||
prv_assert( false !== strpos( $out_with_marker, 'prv-cfg-note' ), 'marker: cfg-note rendered when config version changes' );
|
||||
prv_assert( false !== strpos( $out_with_marker, 'configMarker' ), 'marker: configMarker plugin JS emitted' );
|
||||
prv_assert( false !== strpos( $out_with_marker, 'prv-leg-cfg' ), 'marker: legend swatch present' );
|
||||
|
||||
// Verify graceful-degradation path is present.
|
||||
prv_assert( false !== strpos( $out_with_marker, 'prv-noscript' ), 'trendline: noscript fallback class in JS guard' );
|
||||
prv_assert( false !== strpos( $out_with_marker, 'prv-chart-fallback' ), 'trendline: fallback note element present' );
|
||||
|
||||
// ── Summary ──────────────────────────────────────────────────────────────────
|
||||
|
||||
exit( prv_test_summary() );
|
||||
150
tests/unit/test-model-registry.php
Normal file
150
tests/unit/test-model-registry.php
Normal 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() );
|
||||
@@ -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() );
|
||||
|
||||
121
tests/unit/test-projected-cost.php
Normal file
121
tests/unit/test-projected-cost.php
Normal 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() );
|
||||
85
tests/unit/test-run-lock.php
Normal file
85
tests/unit/test-run-lock.php
Normal 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() );
|
||||
@@ -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() );
|
||||
|
||||
@@ -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' );
|
||||
|
||||
Reference in New Issue
Block a user